terraform-policy
Write, test, or convert Terraform Policy files (.policy.hcl, .policytest.hcl, Sentinel→tfpolicy). Triggers: policy.hcl, policytest, convert sentinel, tfpolicy, write a policy.
terraform-policy
UTILITY SKILL — INVOKES: tfpolicy-author (references/tfpolicy-author.md) | tfpolicy-test (references/tfpolicy-test.md)
USE FOR:
- Writing a new
.policy.hclpolicy from a description or requirement - Converting a
.sentinelpolicy to Terraform Policy - Writing or debugging a
.policytest.hcltest file - Migrating a Sentinel policy library to Terraform Policy
Before giving authoring or testing instructions, check the installed tfpolicy CLI version and tailor guidance accordingly. This skill maintains guidance for the two most recent minor lines, 0.2.x and 0.3.x; when a new minor ships, drop the oldest line and add the new one.
- If the CLI is
0.2.x(baseline), include a top-levelpolicy { required_providers { ... } }block when authoring.policy.hclfiles containing resource or provider policies. It is mandatory fortfpolicy validate; version-range validation is best effort, and wildcard targets such asresource_policy "*"are not schema-validated.tfpolicy testdoes not preflight mockedattrs/prior_attrsagainst provider schemas,core::alltrue/core::anytruedo not exist, and, only in this0.2.xline, mockresource {}blocks may omitattrs/prior_attrsentirely. - If the CLI is
0.3.xor newer, the other guidance above still applies, but the0.2.xallowance for omitting resource state does not: every mockresource {}block in.policytest.hclfiles must declareattrsorprior_attrs; if both evaluate to empty, the test case is skipped (provider {}andmodule {}mocks are unaffected) (see tfpolicy-test (references/tfpolicy-test.md#every-resource--mock-must-declare-a-non-empty-state-block-tfpolicy-030)).tfpolicy testreuses the target.policy.hcl's existing top-levelpolicy { required_providers { ... } }block (there is no separate.policytest.hcl-level declaration) to validate provider, resource, and data-source policies andcore::getdatasource()/core::getresources()arguments against resolved provider schemas before any test runs, failing the whole run on a schema mismatch (see tfpolicy-test (references/tfpolicy-test.md#test-execution-behavior)).core::alltrue(list)andcore::anytrue(list)are also available — prefer them over thecore::length()list-comprehension workaround (see tfpolicy-author (references/tfpolicy-author.md#core-functions--common-idioms)).meta.tfe_stackandmeta.tfe_workspace.tagsare available to resource, provider, and module policies; Stack fields are empty outside Stack evaluations. - If the CLI version is unknown, ask the user to check it first or provide guidance that clearly distinguishes the
0.2.xand0.3.xpaths.
DO NOT USE FOR:
- Writing
.tftest.hclfiles for Terraform modules — useterraform-test - General Terraform HCL authoring — use
terraform-style-guide
Routing
| Task | Sub-skill |
|---|---|
Write or convert a .policy.hcl policy |
tfpolicy-author (references/tfpolicy-author.md) |
Write or debug a .policytest.hcl test |
tfpolicy-test (references/tfpolicy-test.md) |
Examples
- "Block EC2 instances without encryption" → tfpolicy-author (references/tfpolicy-author.md)
- "Convert this Sentinel policy to tfpolicy" → tfpolicy-author (references/tfpolicy-author.md)
- "Write a policytest for my EBS policy" → tfpolicy-test (references/tfpolicy-test.md)
Troubleshooting
- Wrong skill triggered? Load the sub-skill directly from the routing table above.
npx skills add hashicorp/agent-skills/terraform/terraform-policy/skills/tfpolicy-author
npx skills add hashicorp/agent-skills/terraform/terraform-policy/skills/tfpolicy-test- SKILL.md
- .gitignore
- README.md
- examples/README.md
- examples/conversion/cloudfront-associated-with-waf/README.md
- examples/conversion/cloudfront-associated-with-waf/cloudfront-associated-with-waf.policy.hcl
- examples/conversion/cloudfront-associated-with-waf/cloudfront-associated-with-waf.sentinel
- examples/conversion/cloudtrail-server-side-encryption-enabled/README.md
- examples/conversion/cloudtrail-server-side-encryption-enabled/cloudtrail-server-side-encryption-enabled.policy.hcl
- examples/conversion/cloudtrail-server-side-encryption-enabled/cloudtrail-server-side-encryption-enabled.sentinel
- examples/conversion/dms-endpoint-should-be-ssl-configured/README.md
- examples/conversion/dms-endpoint-should-be-ssl-configured/dms-endpoint-should-be-ssl-configured.policy.hcl
- examples/conversion/dms-endpoint-should-be-ssl-configured/dms-endpoint-should-be-ssl-configured.sentinel
- examples/conversion/dms-endpoints-should-use-ssl/README.md
- examples/conversion/dms-endpoints-should-use-ssl/dms-endpoints-should-use-ssl.policy.hcl
- examples/conversion/dms-endpoints-should-use-ssl/dms-endpoints-should-use-ssl.sentinel
- examples/conversion/ec2-network-acl-should-have-subnet-ids/README.md
- examples/conversion/ec2-network-acl-should-have-subnet-ids/ec2-network-acl-should-have-subnet-ids.policy.hcl
- examples/conversion/ec2-network-acl-should-have-subnet-ids/ec2-network-acl-should-have-subnet-ids.sentinel
- examples/conversion/ec2-vpc-default-security-group-no-traffic/README.md
- examples/conversion/ec2-vpc-default-security-group-no-traffic/ec2-vpc-default-security-group-no-traffic.policy.hcl
- examples/conversion/ec2-vpc-default-security-group-no-traffic/ec2-vpc-default-security-group-no-traffic.sentinel
- examples/conversion/efs-access-point-should-enforce-user-identity/README.md
- examples/conversion/efs-access-point-should-enforce-user-identity/efs-access-point-should-enforce-user-identity.policy.hcl
- examples/conversion/efs-access-point-should-enforce-user-identity/efs-access-point-should-enforce-user-identity.sentinel
- examples/conversion/elasticache-redis-replication-group-encryption-at-transit-enabled/README.md
- examples/conversion/elasticache-redis-replication-group-encryption-at-transit-enabled/elasticache-redis-replication-group-encryption-at-transit-enabled.policy.hcl
- examples/conversion/elasticache-redis-replication-group-encryption-at-transit-enabled/elasticache-redis-replication-group-encryption-at-transit-enabled.sentinel
- examples/conversion/elasticsearch-encrypted-at-rest/README.md
- examples/conversion/elasticsearch-encrypted-at-rest/elasticsearch-encrypted-at-rest.policy.hcl
- examples/conversion/elasticsearch-encrypted-at-rest/elasticsearch-encrypted-at-rest.sentinel
- examples/conversion/elasticsearch-https-required/README.md
- examples/conversion/elasticsearch-https-required/elasticsearch-https-required.policy.hcl
- examples/conversion/elasticsearch-https-required/elasticsearch-https-required.sentinel
- examples/conversion/elasticsearch-in-vpc-only/README.md
- examples/conversion/elasticsearch-in-vpc-only/elasticsearch-in-vpc-only.policy.hcl
- examples/conversion/elasticsearch-in-vpc-only/elasticsearch-in-vpc-only.sentinel
- examples/conversion/eventbridge-custom-event-bus-should-have-attached-policy/README.md
- examples/conversion/eventbridge-custom-event-bus-should-have-attached-policy/eventbridge-custom-event-bus-should-have-attached-policy.policy.hcl
- examples/conversion/eventbridge-custom-event-bus-should-have-attached-policy/eventbridge-custom-event-bus-should-have-attached-policy.sentinel
- examples/conversion/s3-block-public-access-bucket-level/README.md
- examples/conversion/s3-block-public-access-bucket-level/s3-block-public-access-bucket-level.policy.hcl
- examples/conversion/s3-block-public-access-bucket-level/s3-block-public-access-bucket-level.sentinel
- examples/conversion/s3-bucket-should-have-object-lock-enabled/README.md
- examples/conversion/s3-bucket-should-have-object-lock-enabled/s3-bucket-should-have-object-lock-enabled.policy.hcl
- examples/conversion/s3-bucket-should-have-object-lock-enabled/s3-bucket-should-have-object-lock-enabled.sentinel
- examples/conversion/secretsmanager-auto-rotation-enabled-check/README.md
- examples/conversion/secretsmanager-auto-rotation-enabled-check/secretsmanager-auto-rotation-enabled-check.policy.hcl
- examples/conversion/secretsmanager-auto-rotation-enabled-check/secretsmanager-auto-rotation-enabled-check.sentinel
- examples/conversion/step-functions-state-machine-logging-enabled/README.md
- examples/conversion/step-functions-state-machine-logging-enabled/step-functions-state-machine-logging-enabled.policy.hcl
- examples/conversion/step-functions-state-machine-logging-enabled/step-functions-state-machine-logging-enabled.sentinel
- references/tfpolicy-author.md
- references/tfpolicy-test.md
- references/verified-syntax.md
SKILL.md
SKILL.md holds the skill's instructions; it is edited on the Instructions tab.
.gitignore
.DS_Store
__pycache__/
*.pyc
README.md
Terraform Policy Agent Skills
A family of focused agent skills for working with Terraform Policy — HCP Terraform's native policy-as-code engine for .policy.hcl and .policytest.hcl files.
Routing
Pick the skill that matches the user's journey:
| Journey | Reference |
|---|---|
| Write a new Terraform Policy from an English description | tfpolicy-author (references/tfpolicy-author.md) |
| Translate Sentinel (or adjacent OPA/Rego) to Terraform Policy | tfpolicy-author (references/tfpolicy-author.md) |
Write or debug a .policytest.hcl test, mock resources, reason about the runner |
tfpolicy-test (references/tfpolicy-test.md) |
Repository layout
terraform-policy/
├── SKILL.md # Router — routes to references below
├── references/
│ ├── tfpolicy-author.md # Authoring + Sentinel conversion (v0.2.0)
│ ├── tfpolicy-test.md # Testing + full testing guide
│ └── verified-syntax.md # Shared source-of-truth syntax reference
├── examples/
│ └── conversion/ # Side-by-side .sentinel / .policy.hcl examples
└── evals/
├── eval.yaml
└── tasks/Shared reference
references/verified-syntax.md (references/verified-syntax.md) is the single source of truth for verified Terraform Policy syntax, function names, and runtime limitations. All reference files link to it rather than duplicating facts — when reference content disagrees with this file, the reference wins.
Required provider declarations for .policy.hcl
Authored .policy.hcl files must include a top-level policy { required_providers { ... } } block. tfpolicy validate uses these declarations for schema-aware validation, and validation fails if the block is omitted.
For version ranges, validation is best effort: provider schemas at the lower and upper bounds of the declared range are evaluated. Wildcard targets such as resource_policy "*" are not schema-validated because they may match multiple resource types.
Starting in tfpolicy 0.3.0, tfpolicy test reuses this same .policy.hcl declaration to preflight the attrs/prior_attrs values mocked in the corresponding .policytest.hcl file against resolved provider schemas — .policytest.hcl does not declare its own required_providers block.
See:
references/tfpolicy-author.md(references/tfpolicy-author.md) for authoring guidance and examplesreferences/verified-syntax.md(references/verified-syntax.md) for syntax and validation limitations
Versioning
Each reference is versioned independently via its metadata.version field.
License
MPL-2.0. Copyright IBM Corp. 2026.
examples/README.md
Sentinel to tfpolicy Conversion Examples
This folder packages representative Sentinel-to-tfpolicy conversion examples for sharing with teammates.
Each example subfolder contains:
<sentinel-policy-name>.sentinel- the actual Sentinel policy file included for comparison<sentinel-policy-name>.policy.hcl- the tfpolicy version or best approximationREADME.md- explanation of the conversion quality, what changed, and any limitations
Converted tfpolicy examples in this bundle prefer remediation-focused diagnostics over repeating Terraform addresses from Sentinel summary {} output. Terraform Policy diagnostics already identify the failing object and point to the relevant location, so converted examples avoid ${meta.address} in error messages.
Included examples:
dms-endpoints-should-use-ssl- direct attribute conversion (Perfect)elasticsearch-https-required- nested block conversion (Good)eventbridge-custom-event-bus-should-have-attached-policy- cross-resource conversion viacore::getresources()(Limited)cloudfront-associated-with-waf- approximation only due to missing reference metadata (Not convertibleas an exact translation)efs-access-point-should-enforce-user-identity- direct presence check (Perfect)elasticsearch-encrypted-at-rest- nested encryption block check (Good)dms-endpoint-should-be-ssl-configured- config-derived certificate check (Good)ec2-network-acl-should-have-subnet-ids- association-aware approximation (Limited)secretsmanager-auto-rotation-enabled-check- secret-to-rotation relationship viacore::getresources()(Good)s3-bucket-should-have-object-lock-enabled- object lock association approximation (Limited)ec2-vpc-default-security-group-no-traffic- inline-only approximation of a broader graph check (Not convertibleas an exact translation)elasticsearch-in-vpc-only- config-to-end-state VPC placement approximation (Limited)cloudtrail-server-side-encryption-enabled- config-to-end-state encryption check (Good)step-functions-state-machine-logging-enabled- nested logging block conversion (Good)elasticache-redis-replication-group-encryption-at-transit-enabled- direct boolean check (Perfect)s3-block-public-access-bucket-level- variable and association heavy approximation (Not convertibleas an exact translation)
Note: The Sentinel policy files in this bundle come from the locally cloned policy library so reviewers can inspect the original Sentinel and converted tfpolicy side by side in one place.
examples/conversion/cloudfront-associated-with-waf/README.md
CloudFront Associated with WAF
Source Sentinel Policy
cloudfront-associated-with-waf.sentinel
Conversion Quality
Not convertible as an exact translation
What the approximation does
The included tfpolicy approximation checks only that web_acl_id is set to a non-empty value on aws_cloudfront_distribution resources.
Why exact conversion is not possible today
The Sentinel policy uses tfconfig/v2 plus reference metadata (references) to reason about whether the CloudFront distribution is associated with a WAF resource. Current tfpolicy guidance does not expose equivalent reference metadata, so it cannot distinguish:
- literal values
- references to WAF resources
- computed values
Key limitation
This means tfpolicy can enforce presence of a web_acl_id, but it cannot safely reproduce the Sentinel policy's reference-aware behavior.
examples/conversion/cloudfront-associated-with-waf/cloudfront-associated-with-waf.policy.hcl
# Copyright IBM Corp. 2025, 2026
# SPDX-License-Identifier: MPL-2.0
# Approximation of HashiCorp PCI DSS Sentinel example: cloudfront-associated-with-waf.sentinel
# Exact conversion quality: Not convertible
# This tfpolicy only checks for a non-empty web_acl_id value.
resource_policy "aws_cloudfront_distribution" "require_web_acl_id" {
locals {
web_acl_id = core::try(attrs.web_acl_id, "")
}
enforce {
condition = local.web_acl_id != ""
error_message = "CloudFront distributions should set web_acl_id to associate a WAF or WAF Classic ACL"
}
}
examples/conversion/cloudfront-associated-with-waf/cloudfront-associated-with-waf.sentinel
// This policy checks whether 'aws_cloudfront_distribution' are associated with either AWS WAF Classic or AWS WAF web ACLs.
# Copyright IBM Corp. 2025, 2026
# SPDX-License-Identifier: BUSL-1.1
// Imports
import "tfconfig/v2" as tfconfig
import "tfresources" as tf
import "report" as report
import "collection" as collection
import "collection/maps" as maps
// Constants
const = {
"policy_name": "cloudfront-associated-with-waf",
"message": "'aws_cloudfront_distribution' are associated with either AWS WAF Classic or AWS WAF web ACLs. Refer to https://docs.aws.amazon.com/securityhub/latest/userguide/cloudfront-controls.html#cloudfront-6 for more details.",
"resource_aws_cloudfront_distribution": "aws_cloudfront_distribution",
}
// Functions
get_violations = func(resources) {
return collection.reject(resources, func(res) {
web_acl_id = maps.get(res.config, "web_acl_id", {})
if web_acl_id is null or web_acl_id is empty {
return false
}
references = maps.get(web_acl_id, "references", [])
return references is not empty
})
}
// Variables
config_resources = tf.config(tfconfig.resources)
cloudfront_distribution_resource = config_resources.type(const.resource_aws_cloudfront_distribution).resources
violations = get_violations(cloudfront_distribution_resource)
summary = {
"policy_name": const.policy_name,
"violations": map violations as _, v {
{
"address": v.address,
"module_address": v.module_address,
"message": const.message,
}
},
}
// Outputs
print(report.generate_policy_report(summary))
// Rules
main = rule {
violations is empty
}
examples/conversion/cloudtrail-server-side-encryption-enabled/README.md
CloudTrail Server-Side Encryption Enabled
Source Sentinel Policy
cloudtrail-server-side-encryption-enabled.sentinel
Conversion Quality
Good
Why this is Good
The Sentinel policy is config-oriented and checks whether kms_key_id is present as a configured value. tfpolicy can preserve the same enforcement intent by validating the planned end-state value for attrs.kms_key_id.
Key translation notes
tfconfig/v2config inspection becomes a planned-value check in tfpolicy- The converted policy focuses on whether
kms_key_idis ultimately present, not whether it originated as a constant in the config
Limitations encountered
The tfpolicy version does not preserve the config-level distinction between explicit constant values and other configuration forms. It validates the final planned attribute value instead.
examples/conversion/cloudtrail-server-side-encryption-enabled/cloudtrail-server-side-encryption-enabled.policy.hcl
# Copyright IBM Corp. 2025, 2026
# SPDX-License-Identifier: MPL-2.0
# Converted from HashiCorp PCI DSS Sentinel example: cloudtrail-server-side-encryption-enabled.sentinel
# Conversion quality: Good
resource_policy "aws_cloudtrail" "cloudtrail_server_side_encryption_enabled" {
locals {
kms_key_id = core::try(attrs.kms_key_id, "")
}
enforce {
condition = local.kms_key_id != ""
error_message = "CloudTrail resources must set kms_key_id for server-side encryption"
}
}
examples/conversion/cloudtrail-server-side-encryption-enabled/cloudtrail-server-side-encryption-enabled.sentinel
# This policy requires that resources of type `aws_cloudtrail` have server-side encryption enabled.
# Copyright IBM Corp. 2025, 2026
# SPDX-License-Identifier: BUSL-1.1
# Imports
import "tfconfig/v2" as tfconfig
import "tfresources" as tf
import "report" as report
import "collection" as collection
import "collection/maps" as maps
# Constants
const = {
"resource_aws_cloudtrail": "aws_cloudtrail",
"policy_name": "cloudtrail-server-side-encryption-enabled",
"message": "Attribute 'kms_key_id' must be present for 'aws_cloudtrail' resources. Refer to https://docs.aws.amazon.com/securityhub/latest/userguide/cloudtrail-controls.html#cloudtrail-2 for more details.",
"cloudtrail_attribute_kms_key_id": "kms_key_id",
"constant_value": "constant_value",
}
# Variables
resources = tf.config(tfconfig.resources).type(const.resource_aws_cloudtrail).resources
violations = collection.reject(resources, func(res) {
key_path = "config.kms_key_id"
return maps.get(res, key_path, false) is not false and
maps.get(res, key_path + "." + const.constant_value, false) is not ""
})
summary = {
"policy_name": const.policy_name,
"violations": map violations as _, v {
{
"address": v.address,
"module_address": v.module_address,
"message": const.message,
}
},
}
# Outputs
print(report.generate_policy_report(summary))
# Rules
main = rule {
violations is empty
}
examples/conversion/dms-endpoint-should-be-ssl-configured/README.md
DMS Endpoint Should Be SSL Configured
Source Sentinel Policy
dms-endpoint-should-be-ssl-configured.sentinel
Conversion Quality
Good
Why this converts reasonably well
The Sentinel version uses tfconfig/v2 to accept either a constant value or a reference for certificate_arn. tfpolicy cannot inspect Terraform config reference metadata the same way, but it can still validate that the planned certificate_arn value is non-empty.
Key translation notes
- Config-oriented Sentinel checks become an end-state tfpolicy check on
attrs.certificate_arn - tfpolicy focuses on the resulting planned value instead of whether it came from a literal or a reference
Limitations encountered
The tfpolicy version does not preserve the source-level distinction between constant values and references. It only checks that the final planned value is present.
examples/conversion/dms-endpoint-should-be-ssl-configured/dms-endpoint-should-be-ssl-configured.policy.hcl
# Copyright IBM Corp. 2025, 2026
# SPDX-License-Identifier: MPL-2.0
# Converted from HashiCorp PCI DSS Sentinel example: dms-endpoint-should-be-ssl-configured.sentinel
# Conversion quality: Good
resource_policy "aws_dms_endpoint" "dms_endpoint_should_be_ssl_configured" {
locals {
certificate_arn = core::try(attrs.certificate_arn, "")
}
enforce {
condition = local.certificate_arn != ""
error_message = "DMS endpoints should set certificate_arn for SSL configuration"
}
}
examples/conversion/dms-endpoint-should-be-ssl-configured/dms-endpoint-should-be-ssl-configured.sentinel
# This policy checks if resources of type 'aws_dms_endpoint' have the 'certificate_arn'
# shouldn't be empty
# Copyright IBM Corp. 2025, 2026
# SPDX-License-Identifier: BUSL-1.1
import "tfconfig/v2" as tfconfig
import "tfresources" as tf
import "report" as report
import "collection" as collection
import "collection/maps" as maps
# Constants
const = {
"policy_name": "dms-endpoint-should-be-ssl-configured",
"message": "Attribute 'certificate_arn' shouldn't be empty for AWS DMS Endpoint. Refer to https://docs.aws.amazon.com/securityhub/latest/userguide/dms-controls.html#dms-9 for more details.",
"resource_aws_dms_endpoint": "aws_dms_endpoint",
}
# Functions
get_violations = func(resources) {
return collection.reject(resources, func(res) {
certificate_arn_values = maps.get(res, "config.certificate_arn", "")
if certificate_arn_values is empty {
return false
}
return maps.get(certificate_arn_values, "constant_value", "") is not empty or maps.get(certificate_arn_values, "references", "") is not empty
})
}
# Variables
dms_endpoint_resource = tf.config(tfconfig.resources).type(const.resource_aws_dms_endpoint).resources
violations = get_violations(dms_endpoint_resource)
summary = {
"policy_name": const.policy_name,
"violations": map violations as _, v {
{
"address": v.address,
"module_address": v.module_address,
"message": const.message,
}
},
}
# Outputs
print(report.generate_policy_report(summary))
# Rules
main = rule {
violations is empty
}
examples/conversion/dms-endpoints-should-use-ssl/README.md
DMS Endpoint SSL Mode
Source Sentinel Policy
dms-endpoints-should-use-ssl.sentinel
Conversion Quality
Perfect
Why it converts well
This policy is a straightforward single-resource attribute check. The Sentinel version iterates over aws_dms_endpoint resources and rejects any resource whose ssl_mode is not in an allowlist. tfpolicy can express the same intent directly with one resource_policy, one allowlist, and one enforce block.
Key translation notes
- Sentinel
collection.reject()becomes one positivecondition maps.get(res, "values.ssl_mode", null)becomescore::try(attrs.ssl_mode, "")- No cross-resource logic, state inspection, or reference metadata is involved
Limitations encountered
No significant tfpolicy limitation blocks this conversion.
examples/conversion/dms-endpoints-should-use-ssl/dms-endpoints-should-use-ssl.policy.hcl
# Copyright IBM Corp. 2025, 2026
# SPDX-License-Identifier: MPL-2.0
# Converted from HashiCorp PCI DSS Sentinel example: dms-endpoints-should-use-ssl.sentinel
# Conversion quality: Perfect
resource_policy "aws_dms_endpoint" "require_ssl_mode" {
locals {
ssl_mode = core::try(attrs.ssl_mode, "")
valid_ssl_modes = ["require", "verify-ca", "verify-full"]
}
enforce {
condition = core::contains(local.valid_ssl_modes, local.ssl_mode)
error_message = "DMS endpoints must set ssl_mode to one of: require, verify-ca, verify-full"
}
}
examples/conversion/dms-endpoints-should-use-ssl/dms-endpoints-should-use-ssl.sentinel
# This policy requires resources of type `aws_dms_endpoint` have attribute "ssl_mode" set to one of: require, verify-ca, verify-full.
# Copyright IBM Corp. 2025, 2026
# SPDX-License-Identifier: BUSL-1.1
# Imports
import "tfplan/v2" as tfplan
import "tfresources" as tf
import "report" as report
import "collection" as collection
import "collection/maps" as maps
# Constants
const = {
"policy_name": "dms-ssl-enabled",
"message": "Attribute 'ssl_mode' must be set to one of: require, verify-ca, verify-full for 'aws_dms_endpoint' resources. Refer to https://docs.aws.amazon.com/securityhub/latest/userguide/dms-controls.html#dms-9 for more details.",
"resource_aws_dms_endpoint": "aws_dms_endpoint",
"ssl_mode": "ssl_mode",
"valid_ssl_modes": ["require", "verify-ca", "verify-full"],
}
# Variables
resources = tf.plan(tfplan.planned_values.resources).type(const.resource_aws_dms_endpoint).resources
violations = collection.reject(resources, func(res) {
return maps.get(res, "values." + const.ssl_mode, null) in const.valid_ssl_modes
})
summary = {
"policy_name": "dms-ssl-enabled",
"violations": map violations as _, v {
{
"address": v.address,
"module_address": v.module_address,
"message": const.message,
}
},
}
# Outputs
print(report.generate_policy_report(summary))
# Rules
main = rule {
violations is empty
}
examples/conversion/ec2-network-acl-should-have-subnet-ids/README.md
EC2 Network ACL Should Have Subnet IDs
Source Sentinel Policy
ec2-network-acl-should-have-subnet-ids.sentinel
Conversion Quality
Limited
Why this is limited
The Sentinel policy uses tfconfig/v2, reference metadata, and module-aware address reconstruction to determine whether a network ACL is connected through aws_network_acl_association. Current tfpolicy guidance does not expose equivalent reference metadata, so an exact translation is not possible.
What the approximation does
The tfpolicy version checks either:
subnet_idsis present directly on the network ACL, or- a matching
aws_network_acl_associationcan be found viacore::getresources()and a value-based lookup
Limitations encountered
- This is value matching, not true Terraform graph reasoning
- It may behave differently for newly created resources with unresolved values
- It does not reproduce the Sentinel policy's module-aware reference reconstruction exactly
examples/conversion/ec2-network-acl-should-have-subnet-ids/ec2-network-acl-should-have-subnet-ids.policy.hcl
# Copyright IBM Corp. 2025, 2026
# SPDX-License-Identifier: MPL-2.0
# Approximation of HashiCorp PCI DSS Sentinel example: ec2-network-acl-should-have-subnet-ids.sentinel
# Exact conversion quality: Limited
locals {
all_network_acl_associations = core::getresources("aws_network_acl_association", {})
associated_network_acl_ids = {
for association in local.all_network_acl_associations :
core::try(association.network_acl_id, "") => true
}
}
resource_policy "aws_network_acl" "network_acl_should_have_subnet_ids" {
locals {
subnet_ids = core::try(attrs.subnet_ids, [])
has_subnet_ids = core::length(local.subnet_ids) > 0
network_acl_id = core::try(attrs.id, "")
has_association = core::try(local.associated_network_acl_ids[local.network_acl_id], false)
}
enforce {
condition = local.has_subnet_ids || local.has_association
error_message = "Network ACLs should define subnet_ids directly or have a matching aws_network_acl_association"
}
}
examples/conversion/ec2-network-acl-should-have-subnet-ids/ec2-network-acl-should-have-subnet-ids.sentinel
// This policy requires `aws_network_acl` resources to have 'subnet_ids' present.
# Copyright IBM Corp. 2025, 2026
# SPDX-License-Identifier: BUSL-1.1
// Imports
import "tfconfig/v2" as tfconfig
import "tfresources" as tf
import "report" as report
import "collection" as collection
import "collection/maps" as maps
import "strings"
// Constants
const = {
"policy_name": "ec2-network-acl-should-have-subnet-ids",
"message": "Attribute 'subnet_ids' must be present for 'aws_network_acl' resources or it should include 'subnet_ids' through 'aws_network_acl_association'. Refer to https://docs.aws.amazon.com/securityhub/latest/userguide/ec2-controls.html#ec2-16 for more details.",
"resource_aws_network_acl": "aws_network_acl",
"resource_aws_network_acl_association": "aws_network_acl_association",
"subnet_ids": "subnet_ids",
"constant_value": "constant_value",
"module_prefix": "module.",
}
// Functions
get_violations = func(network_acl_resources, network_acl_association_resources) {
return collection.reject(network_acl_resources, func(res) {
subnet_id_values = maps.get(res, "config." + const.subnet_ids, [])
if (subnet_id_values is empty or subnet_id_values.constant_value is defined) and check_network_acl_association(res.address, network_acl_association_resources) {
return false
}
return true
})
}
check_network_acl_association = func(address, network_acl_association_resources) {
if network_acl_association_resources is empty {
return true
}
return collection.find(network_acl_association_resources, func(res) {
network_acl_id_reference = get_referenced_resource_address(res, "config.network_acl_id")
if network_acl_id_reference is empty {
return false
}
return address is network_acl_id_reference
}) is not defined
}
get_referenced_resource_address = func(res, attr) {
references_list = maps.get(res, attr, [])
if references_list.references is empty {
return ""
}
referenced_address = references_list.references[1]
if strings.has_prefix(res.address, const.module_prefix) {
referenced_address = res.module_address + "." + referenced_address
}
return referenced_address
}
// Variables
config_resources = tf.config(tfconfig.resources)
network_acl_resources = config_resources.type(const.resource_aws_network_acl).resources
network_acl_association_resources = config_resources.type(const.resource_aws_network_acl_association).resources
violations = get_violations(network_acl_resources, network_acl_association_resources)
summary = {
"policy_name": const.policy_name,
"violations": map violations as _, v {
{
"address": v.address,
"module_address": v.module_address,
"message": const.message,
}
},
}
// Outputs
print(report.generate_policy_report(summary))
// Rules
main = rule {
violations is empty
}
examples/conversion/ec2-vpc-default-security-group-no-traffic/README.md
EC2 VPC Default Security Group No Traffic
Source Sentinel Policy
ec2-vpc-default-security-group-no-traffic.sentinel
Conversion Quality
Not convertible as an exact translation
What the approximation does
The included tfpolicy checks only inline ingress and egress rules on aws_default_security_group resources.
Why exact conversion is not possible today
The Sentinel policy combines several config-level resource types:
aws_default_security_groupaws_security_group_ruleaws_vpc_security_group_ingress_ruleaws_vpc_security_group_egress_rule
It then uses tfconfig/v2 reference metadata and regex checks to determine whether those separate rule resources target the default security group of a VPC. Current tfpolicy guidance does not expose equivalent config graph metadata, so it cannot safely reproduce that full relationship-aware behavior.
Key limitation
This means tfpolicy can approximate the inline-rule case, but it cannot fully enforce the broader Sentinel policy that also reasons over separate security group rule resources attached by reference.
examples/conversion/ec2-vpc-default-security-group-no-traffic/ec2-vpc-default-security-group-no-traffic.policy.hcl
# Copyright IBM Corp. 2025, 2026
# SPDX-License-Identifier: MPL-2.0
# Approximation of HashiCorp PCI DSS Sentinel example: ec2-vpc-default-security-group-no-traffic.sentinel
# Exact conversion quality: Not convertible
# This tfpolicy only checks inline ingress/egress on aws_default_security_group resources.
resource_policy "aws_default_security_group" "ec2_vpc_default_security_group_no_traffic" {
locals {
ingress_rules = core::try(attrs.ingress, [])
egress_rules = core::try(attrs.egress, [])
}
enforce {
condition = core::length(local.ingress_rules) == 0
error_message = "Default security groups should not allow inline ingress traffic"
}
enforce {
condition = core::length(local.egress_rules) == 0
error_message = "Default security groups should not allow inline egress traffic"
}
}
examples/conversion/ec2-vpc-default-security-group-no-traffic/ec2-vpc-default-security-group-no-traffic.sentinel
# This policy requires resources of type `aws_vpc` to have no traffic for default security group.
# Copyright IBM Corp. 2025, 2026
# SPDX-License-Identifier: BUSL-1.1
# Imports
import "tfconfig/v2" as tfconfig
import "tfresources" as tf
import "report" as report
import "collection" as collection
import "collection/maps" as maps
# Constants
const = {
"message": "VPC default security group should not allow inbound and outbound traffic. Refer to https://docs.aws.amazon.com/securityhub/latest/userguide/ec2-controls.html#ec2-2 for more details.",
"policy_name": "ec2-vpc-default-security-group-no-traffic",
"config": "config",
"security_group_id": "security_group_id",
"references": "references",
"constant_value": "constant_value",
"resource_aws_default_security_group": "aws_default_security_group",
"ingress": "ingress",
"egress": "egress",
"resource_aws_vpc": "aws_vpc",
"resource_aws_default_vpc": "aws_default_vpc",
"resource_aws_security_group_rule": "aws_security_group_rule",
"resource_aws_vpc_security_group_ingress_rule": "aws_vpc_security_group_ingress_rule",
"resource_aws_vpc_security_group_egress_rule": "aws_vpc_security_group_egress_rule",
}
# Functions
is_default_security_group_of_vpc = func(reference) {
return reference matches "aws_default_security_group.(.*).id" or
reference matches "aws_vpc.(.*).default_security_group_id$" or
reference matches "aws_default_vpc.(.*).default_security_group_id$"
}
filter_security_group_rule_violations = func(sg_rule_resources) {
return collection.reject(sg_rule_resources, func(r) {
key = "config.security_group_id.references"
val = maps.get(r, key, undefined)
return !(val is defined and length(val) > 0 and is_default_security_group_of_vpc(val[0]))
})
}
# Variables
config_resources = tf.config(tfconfig.resources)
default_security_group_resources = config_resources.type(const.resource_aws_default_security_group).resources
violations = []
violations += collection.reject(default_security_group_resources, func(r) {
ingress_key = const.config + "." + const.ingress + "." + const.constant_value
egress_key = const.config + "." + const.egress + "." + const.constant_value
ingress_key_val = maps.get(r, ingress_key, undefined)
egress_key_val = maps.get(r, egress_key, undefined)
return !((ingress_key_val is defined and length(ingress_key_val) > 0) or
(egress_key_val is defined and length(egress_key_val) > 0))
})
aws_security_group_rule_resources = config_resources.type(const.resource_aws_security_group_rule).resources
violations += filter_security_group_rule_violations(aws_security_group_rule_resources)
aws_security_group_ingress_rule_resources = config_resources.type(const.resource_aws_vpc_security_group_ingress_rule).resources
violations += filter_security_group_rule_violations(aws_security_group_ingress_rule_resources)
aws_security_group_egress_rule_resources = config_resources.type(const.resource_aws_vpc_security_group_egress_rule).resources
violations += filter_security_group_rule_violations(aws_security_group_egress_rule_resources)
summary = {
"policy_name": const.policy_name,
"violations": map violations as _, v {
{
"address": v.address,
"module_address": v.module_address,
"message": const.message,
}
},
}
# Outputs
print(report.generate_policy_report(summary))
# Rules
main = rule {
violations is empty
}
examples/conversion/efs-access-point-should-enforce-user-identity/README.md
EFS Access Point Should Enforce User Identity
Source Sentinel Policy
efs-access-point-should-enforce-user-identity.sentinel
Conversion Quality
Perfect
Why it converts well
This is a simple presence check on a single planned resource type. The Sentinel policy rejects aws_efs_access_point resources that do not define posix_user, and tfpolicy can express that directly with one resource_policy and one enforce block.
Key translation notes
maps.get(res.values, "posix_user", {}) is not emptybecomescore::try(attrs.posix_user, null) != null- No cross-resource reasoning or reference metadata is required
Limitations encountered
No significant tfpolicy limitation blocks this conversion.
examples/conversion/efs-access-point-should-enforce-user-identity/efs-access-point-should-enforce-user-identity.policy.hcl
# Copyright IBM Corp. 2025, 2026
# SPDX-License-Identifier: MPL-2.0
# Converted from HashiCorp PCI DSS Sentinel example: efs-access-point-should-enforce-user-identity.sentinel
# Conversion quality: Perfect
resource_policy "aws_efs_access_point" "efs_access_point_should_enforce_user_identity" {
enforce {
condition = core::try(attrs.posix_user, null) != null
error_message = "EFS access points must define posix_user"
}
}
examples/conversion/efs-access-point-should-enforce-user-identity/efs-access-point-should-enforce-user-identity.sentinel
# This policy requires resources of type `aws_efs_access_point` have attribute `posix_user` should be defined.
# Copyright IBM Corp. 2025, 2026
# SPDX-License-Identifier: BUSL-1.1
# Imports
import "tfplan/v2" as tfplan
import "tfresources" as tf
import "report" as report
import "collection" as collection
import "collection/maps" as maps
# Constants
const = {
"policy_name": "efs-access-point-should-enforce-user-identity",
"message": "Attribute 'posix_user' should be defined for 'aws_efs_access_point' resources. Refer to https://docs.aws.amazon.com/securityhub/latest/userguide/efs-controls.html#efs-4 for more details.",
"resource_aws_efs_access_point": "aws_efs_access_point",
"posix_user": "posix_user",
}
# Variables
resources = tf.plan(tfplan.planned_values.resources).type(const.resource_aws_efs_access_point).resources
violations = collection.reject(resources, func(res) {
return maps.get(res.values, const.posix_user, {}) is not empty
})
summary = {
"policy_name": const.policy_name,
"violations": map violations as _, v {
{
"address": v.address,
"module_address": v.module_address,
"message": const.message,
}
},
}
# Outputs
print(report.generate_policy_report(summary))
# Rules
main = rule {
violations is empty
}
examples/conversion/elasticache-redis-replication-group-encryption-at-transit-enabled/README.md
ElastiCache Redis Replication Group Encryption at Transit Enabled
Source Sentinel Policy
elasticache-redis-replication-group-encryption-at-transit-enabled.sentinel
Conversion Quality
Perfect
Why it converts well
This is a direct boolean check on a single planned resource type. The Sentinel logic checks whether transit_encryption_enabled is true on aws_elasticache_replication_group, and tfpolicy can express the same rule directly.
Key translation notes
maps.get(res, "values.transit_encryption_enabled", ...)becomescore::try(attrs.transit_encryption_enabled, false)- No resource graph traversal, config metadata, or cross-resource matching is required
Limitations encountered
No significant tfpolicy limitation blocks this conversion.
examples/conversion/elasticache-redis-replication-group-encryption-at-transit-enabled/elasticache-redis-replication-group-encryption-at-transit-enabled.policy.hcl
# Copyright IBM Corp. 2025, 2026
# SPDX-License-Identifier: MPL-2.0
# Converted from HashiCorp PCI DSS Sentinel example: elasticache-redis-replication-group-encryption-at-transit-enabled.sentinel
# Conversion quality: Perfect
resource_policy "aws_elasticache_replication_group" "elasticache_redis_replication_group_encryption_at_transit_enabled" {
enforce {
condition = core::try(attrs.transit_encryption_enabled, false) == true
error_message = "ElastiCache replication groups must enable transit_encryption_enabled"
}
}
examples/conversion/elasticache-redis-replication-group-encryption-at-transit-enabled/elasticache-redis-replication-group-encryption-at-transit-enabled.sentinel
# This policy requires that the `transit_encryption_enabled` attribute of the `aws_elasticache_replication_group` resource is true.
# Copyright IBM Corp. 2025, 2026
# SPDX-License-Identifier: BUSL-1.1
# Imports
import "tfplan/v2" as tfplan
import "tfresources" as tf
import "report" as report
import "collection" as collection
import "collection/maps" as maps
# Constants
const = {
"policy_name": "elasticache-redis-replication-group-encryption-at-rest-enabled",
"resource_aws_elasticache_replication_group": "aws_elasticache_replication_group",
}
# Functions
get_violations = func(resources) {
return collection.reject(resources, func(res) {
key = "values.transit_encryption_enabled"
return maps.has(res, key) and maps.get(res, key) is true
})
}
# Variables
elasticache_replication_groups = tf.plan(tfplan.planned_values.resources).type(const.resource_aws_elasticache_replication_group).resources
violations = get_violations(elasticache_replication_groups)
summary = {
"policy_name": const.policy_name,
"violations": map violations as _, v {
{
"address": v.address,
"module_address": v.module_address,
"message": "Attribute 'transit_encryption_enabled' must be true for 'aws_elasticache_replication_group' resources.Refer to https://docs.aws.amazon.com/securityhub/latest/userguide/elasticache-controls.html#elasticache-5 for more details.",
}
},
}
# Outputs
print(report.generate_policy_report(summary))
# Rules
main = rule {
violations is empty
}
examples/conversion/elasticsearch-encrypted-at-rest/README.md
Elasticsearch Encrypted at Rest
Source Sentinel Policy
elasticsearch-encrypted-at-rest.sentinel
Conversion Quality
Good
Why this is Good
The original intent maps cleanly to tfpolicy, but the block shape still has to be rewritten in tfpolicy terms using core::try() around encrypt_at_rest[0].enabled.
Key translation notes
- Nested map access becomes direct tfpolicy block access
- The conversion checks the planned end state of
encrypt_at_rest - The outcome is preserved even though the syntax changes substantially
Limitations encountered
This depends on the provider exposing encrypt_at_rest in the expected block/list structure. As with other tfpolicy policies, raw provider schema shape matters.
examples/conversion/elasticsearch-encrypted-at-rest/elasticsearch-encrypted-at-rest.policy.hcl
# Copyright IBM Corp. 2025, 2026
# SPDX-License-Identifier: MPL-2.0
# Converted from HashiCorp PCI DSS Sentinel example: elasticsearch-encrypted-at-rest.sentinel
# Conversion quality: Good
resource_policy "aws_elasticsearch_domain" "elasticsearch_encrypted_at_rest" {
locals {
encrypt_at_rest = core::try(attrs.encrypt_at_rest, [])
encryption_enabled = core::try(local.encrypt_at_rest[0].enabled, false)
}
enforce {
condition = local.encryption_enabled == true
error_message = "Elasticsearch domains must enable encrypt_at_rest"
}
}
examples/conversion/elasticsearch-encrypted-at-rest/elasticsearch-encrypted-at-rest.sentinel
# This policy requires resources of type `aws_elasticsearch_domain` have the `encrypt_at_rest` should have 'enabled' attribute set to `true`.
# Copyright IBM Corp. 2025, 2026
# SPDX-License-Identifier: BUSL-1.1
# Import
import "tfplan/v2" as tfplan
import "tfresources" as tf
import "report" as report
import "collection" as collection
import "collection/maps" as maps
# Constants
const = {
"policy_name": "elasticsearch-encrypted-at-rest",
"message": "Attribute 'enabled' must be set to true for the attribute 'encrypt_at_rest' for 'aws_elasticsearch_domain' resources. Refer to https://docs.aws.amazon.com/securityhub/latest/userguide/es-controls.html#es-1 for more details.",
"resource_aws_elasticsearch_domain": "aws_elasticsearch_domain",
}
# Functions
get_violations = func(resources) {
return collection.reject(resources, func(res) {
encrypt_at_rest_values = maps.get(res, "values.encrypt_at_rest", [])
return encrypt_at_rest_values is not empty and encrypt_at_rest_values[0].enabled is true
})
}
# Variables
elasticsearch_resources = tf.plan(tfplan.planned_values.resources).type(const.resource_aws_elasticsearch_domain).resources
violations = get_violations(elasticsearch_resources)
summary = {
"policy_name": const.policy_name,
"violations": map violations as _, v {
{
"address": v.address,
"module_address": v.module_address,
"message": const.message,
}
},
}
# Outputs
print(report.generate_policy_report(summary))
# Rules
main = rule {
violations is empty
}
examples/conversion/elasticsearch-https-required/README.md
Elasticsearch HTTPS Required
Source Sentinel Policy
elasticsearch-https-required.sentinel
Conversion Quality
Good
Why it is not labeled Perfect
The enforcement intent is preserved, but the structure changes more noticeably than in a simple attribute check. The Sentinel version uses helper functions plus nested map lookups. The tfpolicy version rewrites that logic into direct block access with core::try() and separate enforce blocks.
Key translation notes
- Nested
maps.get()calls becomecore::try(local.endpoint_options[0]....) - One compound Sentinel predicate becomes multiple focused
enforceblocks - The end-state requirement is preserved clearly in tfpolicy
Limitations encountered
This conversion depends on provider schema shape for domain_endpoint_options. As with other tfpolicy policies, block/list/set handling must match the exposed schema exactly.
examples/conversion/elasticsearch-https-required/elasticsearch-https-required.policy.hcl
# Copyright IBM Corp. 2025, 2026
# SPDX-License-Identifier: MPL-2.0
# Converted from HashiCorp PCI DSS Sentinel example: elasticsearch-https-required.sentinel
# Conversion quality: Good
resource_policy "aws_elasticsearch_domain" "https_required" {
locals {
endpoint_options = core::try(attrs.domain_endpoint_options, [])
endpoint_options_present = core::length(local.endpoint_options) > 0
enforce_https = core::try(local.endpoint_options[0].enforce_https, false)
tls_security_policy = core::try(local.endpoint_options[0].tls_security_policy, "")
}
enforce {
condition = local.endpoint_options_present
error_message = "Elasticsearch domains must define domain_endpoint_options"
}
enforce {
condition = local.enforce_https == true
error_message = "Elasticsearch domains must set domain_endpoint_options.enforce_https = true"
}
enforce {
condition = local.tls_security_policy == "Policy-Min-TLS-1-2-PFS-2023-10"
error_message = "Elasticsearch domains must use tls_security_policy 'Policy-Min-TLS-1-2-PFS-2023-10'"
}
}
examples/conversion/elasticsearch-https-required/elasticsearch-https-required.sentinel
# This policy requires resources of type `aws_elasticsearch_domain` have the `tls_security_policy` set to latest policy that is 'Policy-Min-TLS-1-2-PFS-2023-10' and 'enforce_https' set to true for `domain_endpoint_options` attribute.
# Copyright IBM Corp. 2025, 2026
# SPDX-License-Identifier: BUSL-1.1
# Import
import "tfplan/v2" as tfplan
import "tfresources" as tf
import "report" as report
import "collection" as collection
import "collection/maps" as maps
# Params
param master_count_value default 3
# Constants
const = {
"policy_name": "elasticsearch-https-required",
"message": "Attribute 'tls_security_policy' must be set to latest policy that is 'Policy-Min-TLS-1-2-PFS-2023-10' and 'enforce_https' set to true for the attribute 'domain_endpoint_options' for 'aws_elasticsearch_domain' resources. Refer to https://docs.aws.amazon.com/securityhub/latest/userguide/es-controls.html#es-8 for more details.",
"resource_aws_elasticsearch_domain": "aws_elasticsearch_domain",
"enforce_https": "enforce_https",
"tls_security_policy": "tls_security_policy",
"allowed_tls_latest_policy": "Policy-Min-TLS-1-2-PFS-2023-10",
}
# Functions
get_violations = func(resources) {
return collection.reject(resources, func(res) {
domain_endpoint_options_values = maps.get(res, "values.domain_endpoint_options", [])
if domain_endpoint_options_values is empty {
return false
}
tls_security_policy_value = maps.get(domain_endpoint_options_values[0], const.tls_security_policy, null)
enforce_https_value = maps.get(domain_endpoint_options_values[0], const.enforce_https, true)
if tls_security_policy_value is null {
return false
}
return enforce_https_value is true and tls_security_policy_value == const.allowed_tls_latest_policy
})
}
# Variables
elasticsearch_resources = tf.plan(tfplan.planned_values.resources).type(const.resource_aws_elasticsearch_domain).resources
violations = get_violations(elasticsearch_resources)
summary = {
"policy_name": const.policy_name,
"violations": map violations as _, v {
{
"address": v.address,
"module_address": v.module_address,
"message": const.message,
}
},
}
# Outputs
print(report.generate_policy_report(summary))
# Rules
main = rule {
violations is empty
}
examples/conversion/elasticsearch-in-vpc-only/README.md
Elasticsearch In VPC Only
Source Sentinel Policy
elasticsearch-in-vpc-only.sentinel
Conversion Quality
Limited
Why this is limited
The Sentinel policy is config-oriented and accepts either constant subnet IDs or references inside vpc_options.subnet_ids. tfpolicy does not expose the same config-level constant_value and references metadata, so it cannot preserve that distinction exactly.
What the tfpolicy approximation does
The tfpolicy version checks the planned end state and requires vpc_options[0].subnet_ids to contain one or more values.
Limitations encountered
- It validates the resulting planned subnet IDs, not whether they originated from constants vs references
- It assumes the provider exposes
vpc_optionsandsubnet_idsin the expected schema shape - It is a useful enforcement approximation, but not a one-to-one tfconfig translation
examples/conversion/elasticsearch-in-vpc-only/elasticsearch-in-vpc-only.policy.hcl
# Copyright IBM Corp. 2025, 2026
# SPDX-License-Identifier: MPL-2.0
# Approximation of HashiCorp PCI DSS Sentinel example: elasticsearch-in-vpc-only.sentinel
# Exact conversion quality: Limited
resource_policy "aws_elasticsearch_domain" "elasticsearch_in_vpc_only" {
locals {
vpc_options = core::try(attrs.vpc_options, [])
subnet_ids = core::try(local.vpc_options[0].subnet_ids, [])
}
enforce {
condition = core::length(local.subnet_ids) > 0
error_message = "Elasticsearch domains should define one or more subnet_ids in vpc_options"
}
}
examples/conversion/elasticsearch-in-vpc-only/elasticsearch-in-vpc-only.sentinel
# This policy requires resources of type `aws_elasticsearch_domain` have the `subnet_ids` should not be empty inside 'vpc_options'.
# Copyright IBM Corp. 2025, 2026
# SPDX-License-Identifier: BUSL-1.1
# Import
import "tfconfig/v2" as tfconfig
import "tfresources" as tf
import "report" as report
import "collection" as collection
import "collection/maps" as maps
# Constants
const = {
"policy_name": "elasticsearch-in-vpc-only",
"message": "Attribute 'subnet_ids' should not be empty for the attribute 'vpc_options' for 'aws_elasticsearch_domain' resources. Refer to https://docs.aws.amazon.com/securityhub/latest/userguide/es-controls.html#es-2 for more details.",
"resource_aws_elasticsearch_domain": "aws_elasticsearch_domain",
"subnet_ids": "subnet_ids",
"constant_value": "constant_value",
"references": "references",
}
# Functions
get_violations = func(resources) {
return collection.reject(resources, func(res) {
vpc_options_values = maps.get(res, "config.vpc_options", [])
if vpc_options_values is empty {
return false
}
subnet_ids_values = maps.get(vpc_options_values[0], const.subnet_ids, [])
if subnet_ids_values is empty {
return false
}
return maps.get(subnet_ids_values, const.constant_value, []) is not empty or maps.get(subnet_ids_values, const.references, []) is not empty
})
}
# Variables
elasticsearch_resources = tf.config(tfconfig.resources).type(const.resource_aws_elasticsearch_domain).resources
violations = get_violations(elasticsearch_resources)
summary = {
"policy_name": const.policy_name,
"violations": map violations as _, v {
{
"address": v.address,
"module_address": v.module_address,
"message": const.message,
}
},
}
# Outputs
print(report.generate_policy_report(summary))
# Rules
main = rule {
violations is empty
}
examples/conversion/eventbridge-custom-event-bus-should-have-attached-policy/README.md
EventBridge Bus Must Have Attached Policy
Source Sentinel Policy
eventbridge-custom-event-bus-should-have-attached-policy.sentinel
Conversion Quality
Limited
Why this is only a partial conversion
The Sentinel version can compare planned event bus resources against planned policy resources cleanly inside its own collection-processing model. tfpolicy can approximate that by using core::getresources() and matching on event_bus_name, but this is not a full graph-aware translation.
Key translation notes
- Related resources are discovered with
core::getresources("aws_cloudwatch_event_bus_policy", {}) - Matching is done by explicit value (
event_bus_name) rather than graph/reference semantics - A top-level lookup map keeps the tfpolicy example readable and performant
Limitations encountered
- This approach relies on resolved attribute values, not reference metadata
- New resources with unresolved references may not match reliably on initial creation
core::getresources()is useful for scoped lookups but is not a full replacement for Sentinel graph traversal
examples/conversion/eventbridge-custom-event-bus-should-have-attached-policy/eventbridge-custom-event-bus-should-have-attached-policy.policy.hcl
# Copyright IBM Corp. 2025, 2026
# SPDX-License-Identifier: MPL-2.0
# Converted from HashiCorp PCI DSS Sentinel example: eventbridge-custom-event-bus-should-have-attached-policy.sentinel
# Conversion quality: Limited
locals {
all_event_bus_policies = core::getresources("aws_cloudwatch_event_bus_policy", {})
event_bus_policy_map = {
for policy in local.all_event_bus_policies :
policy.event_bus_name => true
}
}
resource_policy "aws_cloudwatch_event_bus" "require_attached_policy" {
locals {
bus_name = core::try(attrs.name, "")
has_attached_policy = core::try(local.event_bus_policy_map[local.bus_name], false)
}
enforce {
condition = local.has_attached_policy
error_message = "EventBridge buses must have a matching aws_cloudwatch_event_bus_policy resource"
}
}
examples/conversion/eventbridge-custom-event-bus-should-have-attached-policy/eventbridge-custom-event-bus-should-have-attached-policy.sentinel
# This policy requires `aws_cloudwatch_event_bus` resources to be attached to a policy.
# Copyright IBM Corp. 2025, 2026
# SPDX-License-Identifier: BUSL-1.1
# Imports
import "tfplan/v2" as tfplan
import "tfresources" as tf
import "report" as report
import "collection" as collection
import "collection/maps" as maps
import "strings"
# Constants
const = {
"policy_name": "eventbridge-custom-event-bus-should-have-attached-policy",
"message": "Policy should be attached for 'aws_cloudwatch_event_bus' resource. Refer to https://docs.aws.amazon.com/securityhub/latest/userguide/eventbridge-controls.html#eventbridge-3 for more details.",
"resource_aws_cloudwatch_event_bus_policy": "aws_cloudwatch_event_bus_policy",
"resource_aws_cloudwatch_event_bus": "aws_cloudwatch_event_bus",
"event_bus_name": "event_bus_name",
"name": "name",
}
# Functions
get_bus_name_complaint = func(resources) {
return collection.reject(resources, func(res) {
bus_name_values = maps.get(res, "values." + const.event_bus_name, {})
if bus_name_values is empty {
return true
}
return false
})
}
# Variables
plan_resources = tf.plan(tfplan.planned_values.resources)
event_bus_policy_resources = plan_resources.type(const.resource_aws_cloudwatch_event_bus_policy).resources
event_bus_resources = plan_resources.type(const.resource_aws_cloudwatch_event_bus).resources
event_bus_complaint = get_bus_name_complaint(event_bus_policy_resources)
if event_bus_complaint is not defined {
violations = []
}
event_bus_addresses = map event_bus_complaint as _, res {
maps.get(res, "values." + const.event_bus_name, {})
}
violations = filter event_bus_resources as _, res {
maps.get(res, "values." + const.name, {}) not in event_bus_addresses
}
summary = {
"policy_name": const.policy_name,
"violations": map violations as _, v {
{
"address": v.address,
"module_address": v.module_address,
"message": const.message,
}
},
}
# Outputs
print(report.generate_policy_report(summary))
# Rules
main = rule {
violations is empty
}
examples/conversion/s3-block-public-access-bucket-level/README.md
S3 Block Public Access Bucket Level
Source Sentinel Policy
s3-block-public-access-bucket-level.sentinel
Conversion Quality
Not convertible as an exact translation
What the approximation does
The tfpolicy approximation checks whether an aws_s3_bucket has a matching aws_s3_bucket_public_access_block resource and whether all four public access settings are enabled.
Why exact conversion is not possible today
The Sentinel policy combines:
tfconfig/v2tfconfig-functions- plan-time variable resolution
- config reference metadata
- module-aware address reconstruction
Current tfpolicy guidance does not expose that full config-analysis surface. In particular, tfpolicy cannot safely reproduce the Sentinel behavior that inspects variable references and configuration graph relationships before values are fully materialized.
Limitations encountered
- The approximation relies on resolved values via
core::getresources() - It cannot reproduce variable-reference evaluation from the Sentinel policy
- It may differ from Sentinel on first creation or heavily parameterized module usage
examples/conversion/s3-block-public-access-bucket-level/s3-block-public-access-bucket-level.policy.hcl
# Copyright IBM Corp. 2025, 2026
# SPDX-License-Identifier: MPL-2.0
# Approximation of HashiCorp PCI DSS Sentinel example: s3-block-public-access-bucket-level.sentinel
# Exact conversion quality: Not convertible
locals {
all_public_access_blocks = core::getresources("aws_s3_bucket_public_access_block", {})
compliant_public_access_blocks = {
for block in local.all_public_access_blocks :
core::try(block.bucket, "") => (
core::try(block.ignore_public_acls, false) == true &&
core::try(block.restrict_public_buckets, false) == true &&
core::try(block.block_public_acls, false) == true &&
core::try(block.block_public_policy, false) == true
)
}
}
resource_policy "aws_s3_bucket" "s3_block_public_access_bucket_level" {
locals {
bucket_name = core::try(attrs.bucket, "")
block_is_compliant = core::try(local.compliant_public_access_blocks[local.bucket_name], false)
}
enforce {
condition = local.block_is_compliant
error_message = "S3 buckets should have a matching aws_s3_bucket_public_access_block with all four public access settings enabled"
}
}
examples/conversion/s3-block-public-access-bucket-level/s3-block-public-access-bucket-level.sentinel
# This policy verifies if the attributes of the 'aws_s3_bucket_public_access_block'
# resource (if present) block public access of an S3 general purpose bucket.
# Copyright IBM Corp. 2025, 2026
# SPDX-License-Identifier: BUSL-1.1
# Imports
import "tfplan/v2" as plan
import "tfplan-functions" as tfplan
import "tfconfig-functions" as tfconfig
import "tfconfig/v2" as config
import "tfresources" as tf
import "collection/maps" as maps
import "report" as report
import "strings"
# Constants
const = {
"policy_name": "s3-block-public-access-bucket-level",
"module_address": "module_address",
"address": "address",
"message": "Bucket level Amazon S3 block public access settings are not compliant. Refer to https://docs.aws.amazon.com/securityhub/latest/userguide/s3-controls.html#s3-8 for more details.",
"resource_aws_s3_bucket": "aws_s3_bucket",
"module_prefix": "module.",
"resource_aws_s3_bucket_public_access_block": "aws_s3_bucket_public_access_block",
"public_access_block_settings": ["ignore_public_acls", "restrict_public_buckets", "block_public_acls", "block_public_policy"],
}
# Functions
is_public_access_setting_enabled = func(config, setting) {
const_val = maps.get(maps.get(config, setting, {}), "constant_value")
if const_val is defined {
return const_val is true
}
references = maps.get(maps.get(config, setting, {}), "references")
if references is defined and tfconfig.is_variable_reference(references[0]) {
return tfplan.get_variable_value(tfconfig.parse_variable_name_from_reference(references[0])) is true
}
return false
}
is_block_public_access_settings_compliant = func(config) {
return all const.public_access_block_settings as _, setting {
is_public_access_setting_enabled(config, setting)
}
}
# Prefixes the referenced s3 bucket's address with
# the module address. This is done because resource
# addresses comprise of module addresses
sanitize_referenced_s3_bucket_address = func(res) {
module_addr = res[const.module_address]
if res.config.bucket.constant_value is defined {
return ""
}
bucket_reference = res.config.bucket.references[1]
# Check for root module
if not strings.has_prefix(res[const.address], const.module_prefix) {
return bucket_reference
}
return module_addr + "." + bucket_reference
}
# Variables
config_resources = tf.config(config.resources)
compliant_public_access_block_resources = filter config_resources.type(const.resource_aws_s3_bucket_public_access_block).resources as _, res {
is_block_public_access_settings_compliant(res.config)
}
s3_bucket_addresses = map compliant_public_access_block_resources as _, res {
sanitize_referenced_s3_bucket_address(res)
}
violations = filter config_resources.type(const.resource_aws_s3_bucket).resources as _, res {
res.address not in s3_bucket_addresses
}
summary = {
"policy_name": const.policy_name,
"violations": map violations as _, v {
{
"address": v.address,
"module_address": v.module_address,
"message": const.message,
}
},
}
# Outputs
print(report.generate_policy_report(summary))
# Rules
main = rule {
violations is empty
}
examples/conversion/s3-bucket-should-have-object-lock-enabled/README.md
S3 Bucket Should Have Object Lock Enabled
Source Sentinel Policy
s3-bucket-should-have-object-lock-enabled.sentinel
Conversion Quality
Limited
Why this is limited
The Sentinel policy uses tfconfig/v2 plus reference metadata to trace aws_s3_bucket_object_lock_configuration resources back to their aws_s3_bucket resources, including module-aware address reconstruction. tfpolicy does not expose equivalent config graph metadata.
What the tfpolicy approximation does
The tfpolicy version uses core::getresources() to find aws_s3_bucket_object_lock_configuration resources, then matches them to buckets by the resolved bucket value and checks the retention mode.
Limitations encountered
- Matching depends on resolved values, not reference metadata
- Initial creation with unresolved bucket references may not match reliably
- The approximation checks the end-state relationship but cannot reproduce the Sentinel config-graph logic exactly
examples/conversion/s3-bucket-should-have-object-lock-enabled/s3-bucket-should-have-object-lock-enabled.policy.hcl
# Copyright IBM Corp. 2025, 2026
# SPDX-License-Identifier: MPL-2.0
# Approximation of HashiCorp PCI DSS Sentinel example: s3-bucket-should-have-object-lock-enabled.sentinel
# Exact conversion quality: Limited
locals {
all_object_lock_configs = core::getresources("aws_s3_bucket_object_lock_configuration", {})
object_lock_bucket_map = {
for config in local.all_object_lock_configs :
core::try(config.bucket, "") => core::try(config.rule[0].default_retention[0].mode, "")
}
}
resource_policy "aws_s3_bucket" "s3_bucket_should_have_object_lock_enabled" {
locals {
bucket_name = core::try(attrs.bucket, "")
retention_mode = core::try(local.object_lock_bucket_map[local.bucket_name], "")
object_lock_enabled = core::contains(["GOVERNANCE", "COMPLIANCE"], local.retention_mode)
}
enforce {
condition = local.object_lock_enabled
error_message = "S3 buckets should have object lock enabled with default retention mode GOVERNANCE or COMPLIANCE"
}
}
examples/conversion/s3-bucket-should-have-object-lock-enabled/s3-bucket-should-have-object-lock-enabled.sentinel
# S3 Buckets should have object lock enabled
# Copyright IBM Corp. 2025, 2026
# SPDX-License-Identifier: BUSL-1.1
# Imports
import "tfconfig/v2" as tfconfig
import "tfresources" as tf
import "report" as report
import "collection" as collection
import "collection/maps" as maps
import "strings"
import "types"
# Params
param valid_mode default ["GOVERNANCE", "COMPLIANCE"]
# Constants
const = {
"policy_name": "s3-bucket-should-have-object-lock-enabled",
"message": "S3 Buckets should have object lock enabled. Refer to https://docs.aws.amazon.com/securityhub/latest/userguide/s3-controls.html#s3-15 for more details.",
"resource_aws_s3_bucket": "aws_s3_bucket",
"resource_aws_s3_bucket_object_lock_configuration": "aws_s3_bucket_object_lock_configuration",
"address": "address",
"module_address": "module_address",
"module_prefix": "module.",
"rule": "rule",
"default_retention": "default_retention",
"mode": "mode",
}
# Functions
# Prefixes the referenced S3 Bucket's address with
# the module address. This is done because resource
# addresses comprise of module addresses
sanitize_compliant_s3_bucket_address = func(res) {
module_addr = res[const.module_address]
if res.config.bucket.constant_value is defined {
return ""
}
rule_block = maps.get(res.config, const.rule, [])
if rule_block is empty {
return ""
}
default_retention = rule_block[0].default_retention[0]
if default_retention is empty {
return ""
}
mode = maps.get(default_retention, const.mode, "").constant_value
if mode is empty or mode not in valid_mode {
return ""
}
s3_bucket_reference = res.config.bucket.references[1]
# Check for root module
if not strings.has_prefix(res[const.address], const.module_prefix) {
return s3_bucket_reference
}
return module_addr + "." + s3_bucket_reference
}
# Variables
config_resources = tf.config(tfconfig.resources)
bucket_resources = config_resources.type(const.resource_aws_s3_bucket).resources
bucket_object_lock_resources = config_resources.type(const.resource_aws_s3_bucket_object_lock_configuration).resources
# Get S3 Bucket addresses that have object lock enabled
s3_bucket_addresses_with_object_lock = map bucket_object_lock_resources as _, res {
sanitize_compliant_s3_bucket_address(res)
}
# Find violations: S3 Buckets that have policy violations
violations = filter bucket_resources as _, res {
res.address not in s3_bucket_addresses_with_object_lock
}
summary = {
"policy_name": const.policy_name,
"violations": map violations as _, v {
{
"address": v.address,
"module_address": v.module_address,
"message": const.message,
}
},
}
print(report.generate_policy_report(summary))
main = rule {
violations is empty
}
examples/conversion/secretsmanager-auto-rotation-enabled-check/README.md
Secrets Manager Auto Rotation Enabled Check
Source Sentinel Policy
secretsmanager-auto-rotation-enabled-check.sentinel
Conversion Quality
Limited
Why this is limited
The Sentinel policy uses tfconfig/v2 reference metadata to determine whether each aws_secretsmanager_secret is connected to an aws_secretsmanager_secret_rotation resource through config.secret_id. Current tfpolicy guidance does not expose equivalent config-level reference metadata.
What the tfpolicy approximation does
The tfpolicy version uses core::getresources() to collect aws_secretsmanager_secret_rotation resources and matches them to secrets by planned secret_id / id values.
Limitations encountered
- This is value matching, not true Terraform graph reasoning
- It may fail or behave differently when secret identifiers are not resolved yet during creation
- It does not preserve Sentinel's module-aware reference reconstruction exactly
examples/conversion/secretsmanager-auto-rotation-enabled-check/secretsmanager-auto-rotation-enabled-check.policy.hcl
# Copyright IBM Corp. 2025, 2026
# SPDX-License-Identifier: MPL-2.0
# Approximation of HashiCorp PCI DSS Sentinel example: secretsmanager-auto-rotation-enabled-check.sentinel
# Exact conversion quality: Limited
locals {
all_secret_rotations = core::getresources("aws_secretsmanager_secret_rotation", {})
rotation_secret_ids = {
for rotation in local.all_secret_rotations :
core::try(rotation.secret_id, "") => true
}
}
resource_policy "aws_secretsmanager_secret" "secretsmanager_auto_rotation_enabled_check" {
locals {
secret_id = core::try(attrs.id, "")
has_rotation = core::try(local.rotation_secret_ids[local.secret_id], false)
}
enforce {
condition = local.has_rotation
error_message = "Secrets Manager secrets should have a matching aws_secretsmanager_secret_rotation resource"
}
}
examples/conversion/secretsmanager-auto-rotation-enabled-check/secretsmanager-auto-rotation-enabled-check.sentinel
# This policy requires resources of type `aws_secretsmanager_secret` should be configured for automatic rotation.
# Copyright IBM Corp. 2025, 2026
# SPDX-License-Identifier: BUSL-1.1
# Imports
import "tfconfig/v2" as tfconfig
import "tfresources" as tf
import "report" as report
import "collection" as collection
import "collection/maps" as maps
import "strings"
# Constants
const = {
"policy_name": "secretsmanager-auto-rotation-enabled-check",
"message": "Secrets Manager secrets should be configured for automatic rotation. Refer to https://docs.aws.amazon.com/securityhub/latest/userguide/secretsmanager-controls.html#secretsmanager-1 for more details.",
"resource_aws_secretsmanager_secret": "aws_secretsmanager_secret",
"resource_aws_secretsmanager_secret_rotation": "aws_secretsmanager_secret_rotation",
"kms_master_key_id": "kms_master_key_id",
"sqs_managed_sse_enabled": "sqs_managed_sse_enabled",
"module_prefix": "module.",
}
# Functions
get_referenced_resource_address = func(res, attr) {
references_list = maps.get(res, attr, [])
if references_list.references is empty or references_list.references is not defined {
return ""
}
referenced_address = references_list.references[1]
if strings.has_prefix(res.address, const.module_prefix) {
referenced_address = res.module_address + "." + referenced_address
}
return referenced_address
}
# Variables
secret_resources = tf.config(tfconfig.resources).type(const.resource_aws_secretsmanager_secret).resources
secret_rotation_complaint_resources = tf.config(tfconfig.resources).type(const.resource_aws_secretsmanager_secret_rotation).resources
secret_addresses = map secret_rotation_complaint_resources as _, res {
get_referenced_resource_address(res, "config.secret_id")
}
violations = filter secret_resources as _, res {
res.address not in secret_addresses
}
summary = {
"policy_name": const.policy_name,
"violations": map violations as _, v {
{
"address": v.address,
"module_address": v.module_address,
"message": const.message,
}
},
}
# Outputs
print(report.generate_policy_report(summary))
# Rules
main = rule {
violations is empty
}
examples/conversion/step-functions-state-machine-logging-enabled/README.md
Step Functions State Machine Logging Enabled
Source Sentinel Policy
step-functions-state-machine-logging-enabled.sentinel
Conversion Quality
Good
Why this is Good
This policy is still a single-resource planned-value check, but it relies on a nested block (logging_configuration) and an allowlist of valid levels. tfpolicy can express that clearly with core::try() and a small local allowlist.
Key translation notes
- Nested map access becomes direct block access through
attrs.logging_configuration[0].level - The allowed log levels carry over directly into the tfpolicy version
Limitations encountered
This relies on the provider exposing logging_configuration in the expected block/list shape. Otherwise, the enforcement intent maps cleanly.
examples/conversion/step-functions-state-machine-logging-enabled/step-functions-state-machine-logging-enabled.policy.hcl
# Copyright IBM Corp. 2025, 2026
# SPDX-License-Identifier: MPL-2.0
# Converted from HashiCorp PCI DSS Sentinel example: step-functions-state-machine-logging-enabled.sentinel
# Conversion quality: Good
resource_policy "aws_sfn_state_machine" "step_functions_state_machine_logging_enabled" {
locals {
logging_configuration = core::try(attrs.logging_configuration, [])
log_level = core::try(local.logging_configuration[0].level, "")
allowed_levels = ["ALL", "ERROR", "FATAL"]
}
enforce {
condition = core::contains(local.allowed_levels, local.log_level)
error_message = "Step Functions state machines must set logging_configuration.level to ALL, ERROR, or FATAL"
}
}
examples/conversion/step-functions-state-machine-logging-enabled/step-functions-state-machine-logging-enabled.sentinel
# This policy requires AWS Step Functions state machines to have logging configuration enabled with level set to "ALL", "ERROR", or "FATAL".
# Copyright IBM Corp. 2025, 2026
# SPDX-License-Identifier: BUSL-1.1
# Imports
import "tfplan/v2" as tfplan
import "tfresources" as tf
import "report" as report
import "collection" as collection
import "collection/maps" as maps
# Constants
const = {
"policy_name": "sfn-logging-enabled",
"message": "AWS Step Functions state machines must have logging enabled with level set to 'ALL', 'ERROR', or 'FATAL'. Refer to https://docs.aws.amazon.com/securityhub/latest/userguide/stepfunctions-controls.html#stepfunctions-1 for more details.",
"resource_aws_sfn": "aws_sfn_state_machine",
"logging_config": "logging_configuration",
"required_log_levels": ["ALL", "ERROR", "FATAL"],
}
# Variables
resources = tf.plan(tfplan.planned_values.resources).type(const.resource_aws_sfn).resources
violations = collection.reject(resources, func(res) {
logging_config = maps.get(res, "values." + const.logging_config, null)
if logging_config is null {
return false
}
log_level = maps.get(logging_config[0], "level", null)
if log_level is null {
return false
}
return log_level in const.required_log_levels
})
summary = {
"policy_name": const.policy_name,
"violations": map violations as _, v {
{
"address": v.address,
"module_address": v.module_address,
"message": const.message,
}
},
}
# Outputs
print(report.generate_policy_report(summary))
# Rules
main = rule {
violations is empty
}
references/tfpolicy-author.md
name: tfpolicy-author
description: Expert agent for authoring Terraform Policies — from natural-language requirements or Sentinel source. Covers the full workflow write new policies, convert existing Sentinel policies, parameterize with inputs, structure cross-resource checks, apply operation scoping, and produce remediation-focused error messages.
license: MPL-2.0
metadata:
copyright: Copyright IBM Corp. 2026
version: "0.2.0"
tfpolicy-author
Description
Expert agent for writing Terraform Policies — from either a natural-language requirement or an existing Sentinel .sentinel source. Covers the full authoring and conversion workflow: translate requirements or Sentinel logic into resource_policy, module_policy, or provider_policy HCL with correct cross-resource patterns, operation scoping, core:: functions, parameterized inputs, and remediation-focused error messages.
Note: This skill supersedes
sentinel-to-tfpolicy. All Sentinel conversion knowledge is now consolidated here to ensure consistent behaviour when generating policies from Sentinel sources.
Use When
- The user describes an enforcement rule and wants a
.policy.hclfile ("block public RDS", "require encryption", "deny instance types outside an allowlist"). - The user has a Sentinel
.sentinelfile (or snippet) and wants the Terraform Policy equivalent. - The user is migrating a Sentinel policy library to tfpolicy and needs per-policy assessment.
- The user is writing a new
resource_policy,module_policy, orprovider_policyblock. - The user is asking about tfpolicy language features —
filter,locals,enforce,input,operations,prior_attrs,core::*functions, list comprehensions, version constraints, time/date functions. - The user is asking how to structure a policy that depends on related resources via
core::getresources()orcore::getdatasource(). - The user is comparing Sentinel and tfpolicy capabilities ("can I express X in tfpolicy?").
Do not use this skill when:
- The user is writing or debugging a
.policytest.hcltest file — usetfpolicy-test(tfpolicy-test.md).
Capabilities
1. Write Policies from User Intent
Turn natural-language requirements into resource_policy, module_policy, or provider_policy blocks with appropriate filters, locals, enforce blocks, and remediation-focused error messages.
2. Convert Sentinel Policies to Terraform Policy
Translate Sentinel constructs into tfpolicy equivalents, flag non-convertible patterns with practical alternatives, produce idiomatic .policy.hcl from existing Sentinel sources, and apply a quality label to each conversion.
3. Apply Operation Scoping Correctly
Use operations = ["create", "update", "delete"] and prior_attrs.<name> to scope policies to the right plan actions and read pre-change state when relevant.
4. Parameterize with input Blocks
Replace hardcoded allowlists, version constraints, and thresholds with input blocks so policy sets can override values per environment.
5. Structure Cross-Resource Checks Safely
Use core::getresources() at the top level for plan-time joins, with value-based filters when the filter is a known literal or existing ID. Use inline core::getresources() inside resource_policy for apply-time parent+child lookups, when the filter depends on the current resource's own attribute (e.g. {bucket = attrs.id}). Consult the decision table (line 232) to choose the correct pattern; both top-level and inline are first-class options for their respective scenarios. Understand when cross-references will be unresolved at plan time.
6. Surface Runtime Pitfalls Up Front
Steer the user away from documented runtime hazards — meta.address is undefined in resource_policy, core::try() defaults can silently mask non-compliant resources, sets must be converted to lists for indexing, multi-line boolean expressions break the parser, etc.
Knowledge Base
Policy Types
resource_policy "<resource_type>" "<policy_name>" { }
module_policy "<module_pattern>" "<policy_name>" { }
provider_policy "<provider_pattern>" "<policy_name>" { }Required policy.required_providers Block
Every .policy.hcl file containing resource or provider policies must declare a top-level policy { required_providers { ... } } block. tfpolicy validate uses this block to resolve provider schemas for schema-aware validation, and validation fails when the block is omitted.
Use the same top-level policy scaffold shown in the Core Structure and Worked Example sections; only the provider source/version values should vary by policy.
Rules and limitations:
required_providersis mandatory for.policy.hclvalidation.- Validation is best effort for version ranges. When a range is declared, validation evaluates provider schemas at the lower and upper bounds of the range.
- Wildcard targets such as
resource_policy "*"are not schema-validated because they may match multiple resource types. required_providersdeclares provider source and version constraints for validation. This is distinct frommeta.versioninprovider_policy, which exposes the resolved provider version during policy evaluation.
Core Structure
policy {
required_providers {
# Declare provider source and version constraints for validation.
# Use version ranges that cover the provider versions deployed in your infrastructure.
# Example with AWS provider (adjust based on your environment):
aws = {
source = "hashicorp/aws"
version = ">= 5.0.0, < 7.0.0" # Covers AWS provider 5.x and 6.x
}
# Add other providers as needed (azurerm, google, etc.)
}
}
resource_policy "aws_ebs_volume" "encryption_check" {
# Optional: pre-filter resources before evaluation
filter = attrs.encrypted != null
# Optional: locals for readable logic
locals {
encrypted = core::try(attrs.encrypted, false)
}
# One or more enforce blocks
enforce {
condition = local.encrypted == true
error_message = "EBS volumes must have encryption enabled."
}
}Available Attribute Surfaces
| Surface | Available in | Notes |
|---|---|---|
attrs.* |
resource / module / provider | Planned values for the current target. Wrap optional fields in core::try(). |
prior_attrs.* |
resource_policy with operations ⊉ ["create"] |
Pre-change values. Use for delete and update scopes. |
meta.provider_type |
resource_policy | e.g. "aws". Useful for cross-provider wildcard rules. |
meta.tfe_workspace.tags["<name>"] |
resource_policy, module_policy, provider_policy | Workspace-scoped routing (env, team, etc.). Empty when evaluating a Stack or an untagged workspace. |
meta.tfe_stack.deployment_name / stack_name / deployment_group |
resource_policy, module_policy, provider_policy | Stack metadata for routing/exclusion workflows. Always present; fields are empty strings outside Stack evaluations. Available in .policytest.hcl mocks starting in 0.3.x. |
meta.address |
❌ | UNDEFINED in resource_policy real-plan evaluation. Never interpolate it into error_message. |
input.<name> |
all | Values from input {} blocks; overridable per policy set. |
Operation Scoping and prior_attrs
# Skip destroy
resource_policy "tfe_workspace" "require_tags" {
operations = ["create", "update"]
enforce {
condition = core::length(core::try(attrs.tag_names, [])) > 0
error_message = "Workspace must have at least one tag."
}
}
# Delete-gate
resource_policy "tfe_workspace" "deny_delete_without_tag" {
operations = ["delete"] # prior_attrs available when "create" not in operations
locals {
prior_tag_names = core::try(prior_attrs.tag_names, [])
}
enforce {
condition = core::contains(local.prior_tag_names, "delete")
error_message = "Add 'delete' tag before destroying the workspace."
}
}Rules:
operations = ["create", "update"]— fires on create/update, skips destroy.operations = ["delete"]— fires only on destroy;prior_attrsholds pre-change state.operations = ["update"]— fires only on updates;prior_attrsavailable.- Default (no
operations) = create and update (never destroy). prior_attrsis only accessible when"create"is NOT inoperations.
input Blocks — Parameterization
input "allowed_instance_types" {
type = list(string)
default = ["t3.micro", "t3.small", "t3.medium"]
}
resource_policy "aws_instance" "allowed_types" {
enforce {
condition = core::contains(input.allowed_instance_types, attrs.instance_type)
error_message = "Instance type '${attrs.instance_type}' is not in the allowed list."
}
}Policy sets can override input defaults without editing the policy file. Use input for: allowlists, blocklists, version constraints, numeric thresholds — values an operator may need to tune per environment.
Rule — input vs hardcoded locals:
- Use
input {}only for operator-tunable values: allowlists, blocklists, thresholds, time windows. If a value varies per environment or policy set, it belongs ininput. - Use
localsor inline literals for invariant enforcement constants — values that are part of the policy logic itself and should NOT be overridden (e.g. a fixed list of well-known dangerous ports defined by a security standard, a required protocol name). - Do NOT promote fixed enforcement constants to
inputunless the requirement explicitly says they are configurable.
core:: Functions — Common Idioms
Null safety:
core::try(attrs.field, default)— single layer; don't nest. 🔴 MANDATORY: always use the two-step pattern below when the attribute may be explicitlynull.Membership:
core::contains(list, value)— for lists. For string substring usecore::contains_substring.Strings:
core::startswith,core::endswith,core::contains_substring,core::regex(throws on no match — wrap incore::try),core::split(separator, string)(use withcore::parseint()for numeric decomposition — seeverified-syntax.mdSection 2 for full examples). ❌ Never use+for string concatenation —+is numeric addition only; using it with strings throwsError: Unsuitable value for left operand: a number is required. ✅ Use"${local.var}"string interpolation instead: e.g."table/${local.table_name}"not"table/" + local.table_name.Aggregates:
core::length(list_or_map). On tfpolicy 0.3.x+, usecore::alltrue(list)andcore::anytrue(list)for boolean collection checks; on tfpolicy 0.2.x, use filtered-count patterns instead. Seeverified-syntax.mdfor exact empty-list, unknown, and coercion behavior.Ranges:
core::range(limit)→[0, 1, …, limit-1];core::range(lower, upper)→[lower, lower+1, …, upper-1];core::range(lower, upper, step)→ step-incremented list fromlowerup to (but not including)upper. Works with hardcoded integer literals. ⚠️ With dynamicattrs.*integer values (e.g.attrs.from_port,attrs.to_port)core::range()silently returns an empty list in the policytest framework — prefer the count approach for port-range policies (seeverified-syntax.mdMistake 23).Time:
core::timestamp(),core::formatdate("EEEE", core::timestamp())(weekday, UTC),core::parseint(core::formatdate("HH", core::timestamp()), 10)(hour, UTC).Semver:
core::semverconstraint(version, "~> 4.67.0")— supports=,>=,<,~>, range,!=. Always wrap incore::try(..., false)to handle non-semver or unparseable version strings gracefully. ⚠️ Prefer this overcore::split+core::parseintfor all version range checks converted from Sentinel string comparisons — manual integer parsing fails silently for non-numeric version suffixes and null/empty inputs.🔴 NEVER place
core::semverconstraintdirectly in afilter =expression. Even with!= nulland!= ""guards in the same expression,semverconstraintis evaluated regardless of short-circuit ordering in thefiltercontext and throws a parse error when the version string is malformed (e.g. a non-semver string like"x.y") ornull/empty. Always move it intolocalsand wrap withcore::try(..., false):# ❌ Wrong — crashes when version string is non-semver, null, or empty: filter = core::try(attrs.version, null) != null && core::try(attrs.version, "") != "" && core::semverconstraint(core::try(attrs.version, "0.0"), "< 2.0") # ✅ Correct — filter guards null/empty only; semverconstraint lives in locals: filter = core::try(attrs.version, null) != null && core::try(attrs.version, "") != "" locals { version = core::try(attrs.version, "") # core::try wraps semverconstraint to safely handle non-semver strings → false is_old = core::try(core::semverconstraint(local.version, "< 2.0"), false) # When version is old, enforce the required attribute; when version >= 2.0, always compliant is_compliant = !local.is_old || local.required_attr_set }Null safety — two-step pattern (MANDATORY for any attribute that may be explicitly
null):core::try(attrs.field, default)triggers the fallback only when the attribute access throws an error (key absent). When an attribute is explicitly set tonull,core::tryreturnsnull— not the default. This is a silent pitfall:core::length(null)crashes withInvalid value for "collection" parameter;null == falseevaluates asnull(nottrue), causing enforce to trigger unexpectedly.Always use the explicit two-step pattern:
# Collection attribute (list/map) — safe pattern: field_raw = core::try(attrs.field, null) field = local.field_raw != null ? local.field_raw : [] # ✅ Safe to pass to: core::length(), core::contains(), for expressions # Scalar boolean attribute — safe pattern: flag_raw = core::try(attrs.flag, null) flag = local.flag_raw == null ? false : local.flag_raw # ✅ Safe to use in: condition = !local.flag, condition = local.flag == falseFor
filterexpressions that must exclude bothnulland empty-string values:filter = core::try(attrs.field, null) != null && core::try(attrs.field, "") != "". The two-step rule applies at every nesting level. When a scalar is accessed through a nested path (e.g.core::try(local.list[0].scalar_attr, default)), apply the same pattern: if the attribute may benull, usecore::try(..., null)and normalize explicitly.JSON:
core::jsondecode(string)— parses a JSON string into an object/list.core::jsonencode(value)— encodes a value as a JSON string. ❌json::unmarshaldoes not exist — usecore::jsondecodeinstead.Nested block attribute schema — object vs list: Terraform provider schemas define nested blocks as either a list of objects (
[{ ... }]) or a single object ({ ... }). Always check the provider schema before accessing nested attributes:- List block (e.g.
encryption_config = [{ provider = [{ key_arn = "..." }] }]): access via indexattrs.encryption_config[0].provider[0].key_arn. Usecore::try(attrs.field, [])andfield[0].subattr. - Object block (e.g.
redirect = { port = "443", protocol = "HTTPS" }): access directlyattrs.redirect.port. Usecore::try(attrs.redirect.port, ""). - ❌ Never call
core::length()on an object —core::lengthrequires a list, map, or tuple. Callingcore::length(attrs.redirect)whenredirectis an object crashes withcollection must be a list, a map or a tuple. To check presence of an object block, usecore::try(attrs.redirect, null) != nullinstead. - ❌ Never iterate over an object block with a
forexpression.for r in core::try(attrs.block, [])— whenattrs.blockis an object, this iterates over the object's scalar values, not the object as an element.core::try(r.sub_attr, "")on a string silently returns"". Use direct attribute access instead:core::try(attrs.block.sub_attr, ""). - When Sentinel mocks use
redirect = { ... }(object), the TFPolicy test mock and policy must treat it as an object. When Sentinel mocks useredirect = [{ ... }](list), use list indexing. Mismatching the shape causes either runtime crashes or silent wrong results.
- List block (e.g.
IAM Policy Checks
When enforcing IAM content rules (e.g. "no admin *:* allowed", "no wildcard actions"), you MUST cover all 4 inline policy resource types — not just aws_iam_policy.
🔴 Always write 4 resource_policy blocks for IAM content enforcement:
| Resource type | When it's used | policy attribute |
|---|---|---|
aws_iam_policy |
Standalone managed policy | JSON string — core::jsondecode(core::try(attrs.policy, "{}")) |
aws_iam_role_policy |
Inline policy attached to a role | JSON string — same pattern |
aws_iam_user_policy |
Inline policy attached to a user | JSON string — same pattern |
aws_iam_group_policy |
Inline policy attached to a group | JSON string — same pattern |
Rule: A policy that only checks aws_iam_policy misses inline policies on roles/users/groups. An admin could bypass it by using aws_iam_role_policy instead of aws_iam_policy.
Note on aws_iam_policy_document: This resource type requires careful distinction between two different use cases:
- As a Terraform
datablock (the common case in real Terraform configurations) —data_policydoes not exist in tfpolicy (Mistake 31). Do NOT writedata_policy "aws_iam_policy_document". In this scenario, the policy document content is consumed by one of the 4 inline/managed policy resource types above, and enforcement should target those resource types. - As a
resourcein.policytest.hclmocks and when the Sentinel source reads it viatfstate/v2—resource_policy "aws_iam_policy_document"is valid and directly targets the document'sstatementattribute (lowercaseactions, notAction). When a Sentinel policy readsaws_iam_policy_documentfromtfstate, the correct TFPolicy conversion isresource_policy "aws_iam_policy_document"— do NOT substitute inline/managed policy resource types.
Decision rule: Check the Sentinel import statement. If the Sentinel uses tfstate/v2 to read aws_iam_policy_document resources, convert to resource_policy "aws_iam_policy_document". If the Sentinel reads the consuming resource (aws_iam_role_policy, etc.), target those 4 types instead.
# ✅ CORRECT — define the check logic once, repeat for all 4 resource types
# (Each resource_policy block is independent; locals are block-scoped)
resource_policy "aws_iam_policy" "no_admin_privileges" {
locals {
statements = core::try(core::jsondecode(core::try(attrs.policy, "{}")).Statement, [])
admin_stmts = [for s in local.statements : s if core::lower(core::try(s.Effect, "Allow")) == "allow" && core::contains(core::try(s.Action, []), "*") && core::contains(core::try(s.Resource, []), "*")]
}
enforce {
condition = core::length(local.admin_stmts) == 0
error_message = "IAM policies must not grant full admin privileges (*:* on *)."
}
}
resource_policy "aws_iam_role_policy" "no_admin_privileges" {
locals {
statements = core::try(core::jsondecode(core::try(attrs.policy, "{}")).Statement, [])
admin_stmts = [for s in local.statements : s if core::lower(core::try(s.Effect, "Allow")) == "allow" && core::contains(core::try(s.Action, []), "*") && core::contains(core::try(s.Resource, []), "*")]
}
enforce {
condition = core::length(local.admin_stmts) == 0
error_message = "IAM role inline policies must not grant full admin privileges (*:* on *)."
}
}
resource_policy "aws_iam_user_policy" "no_admin_privileges" {
locals {
statements = core::try(core::jsondecode(core::try(attrs.policy, "{}")).Statement, [])
admin_stmts = [for s in local.statements : s if core::lower(core::try(s.Effect, "Allow")) == "allow" && core::contains(core::try(s.Action, []), "*") && core::contains(core::try(s.Resource, []), "*")]
}
enforce {
condition = core::length(local.admin_stmts) == 0
error_message = "IAM user inline policies must not grant full admin privileges (*:* on *)."
}
}
resource_policy "aws_iam_group_policy" "no_admin_privileges" {
locals {
statements = core::try(core::jsondecode(core::try(attrs.policy, "{}")).Statement, [])
admin_stmts = [for s in local.statements : s if core::lower(core::try(s.Effect, "Allow")) == "allow" && core::contains(core::try(s.Action, []), "*") && core::contains(core::try(s.Resource, []), "*")]
}
enforce {
condition = core::length(local.admin_stmts) == 0
error_message = "IAM group inline policies must not grant full admin privileges (*:* on *)."
}
}Cross-Resource Lookups
🛑 Self-check — run this BEFORE writing any resource_policy involving a companion/child resource:
"What is the enforcement goal?"
- "Every existing instance of
<child_type>must have correct attributes" (e.g., "everyaws_lb_listenermust use HTTPS") → anchorresource_policydirectly on the child type- "Every parent must have at least one compliant child" (e.g., "every S3 bucket must have a
public_access_blockwith all four flags set") → anchorresource_policyon the parent; a child-only policy silently misses parents with no child in the plan
Decision table — choose the correct pattern first:
| Enforcement goal | Who gets the resource_policy? |
Pattern |
|---|---|---|
"Every existing instance of <child_type> must have correct attributes" (e.g., "every aws_lb_listener must use HTTPS") |
Child type directly | Direct child resource_policy |
"Every parent must have at least one compliant child" (e.g., "every S3 bucket must have a public_access_block with all four flags set") |
Parent resource | Apply-time: inline core::getresources("child", {bucket = attrs.id}) inside resource_policy |
"Parent has a companion identified by a known literal / stable attribute" (e.g., versioning bucket name matches attrs.bucket) |
Parent resource | Plan-time: top-level core::getresources("companion", {}) + HCL for-loop filter inside resource_policy |
⚠️ Critical: Never write a resource_policy "child_type" to enforce "every parent must have a child" — if the child resource is absent entirely, the policy never fires for that parent and the violation is silently missed.
🔴 Named companion resources — ALWAYS use the parent as resource_policy anchor when the goal is presence enforcement:
The following Terraform resource types are companion-only — a parent resource can exist in a plan without them. NEVER anchor a standalone resource_policy on these types when checking for their presence:
❌ Never use as standalone resource_policy anchor (for presence) |
✅ Always anchor on | Lookup key |
|---|---|---|
aws_s3_bucket_public_access_block |
aws_s3_bucket |
{ bucket = attrs.id } (apply-time inline) |
aws_s3_bucket_acl |
aws_s3_bucket |
{ bucket = attrs.id } (apply-time inline) |
aws_s3_bucket_logging |
aws_s3_bucket |
{ bucket = attrs.id } (apply-time inline) |
aws_s3_bucket_server_side_encryption_configuration |
aws_s3_bucket |
{ bucket = attrs.id } (apply-time inline) |
aws_s3_bucket_versioning |
aws_s3_bucket |
plan-time top-level + v.bucket == attrs.bucket filter |
aws_s3_bucket_policy |
aws_s3_bucket |
{ bucket = attrs.id } (apply-time inline) — even for content enforcement (e.g., checking Principal: "*"), inspect the child's policy attr via core::jsondecode() inside the parent block |
This pattern applies to all resource families, not just S3. Non-S3 examples: when checking "every VPC has a flow log" → anchor on
aws_vpcwith inlinecore::getresources("aws_flow_log", {vpc_id = attrs.id}); when checking "every LB has at least one compliant listener" → anchor onaws_lbwith inlinecore::getresources("aws_lb_listener", {load_balancer_arn = attrs.arn}).
Why this matters: Even if the Sentinel source iterates over the companion type, TFPolicy must anchor on the parent. A Terraform plan can declare an
aws_s3_bucketwith NO companion resource — anchoring on the companion silently misses that bucket entirely.
⚠️ Parent-anchor means parent-ONLY: Once the decision table says "parent resource" is the anchor, generate resource_policy blocks exclusively on the parent type. Do NOT also generate standalone resource_policy blocks for companion types alongside the parent blocks. Adding companion blocks in parallel:
- still silently misses parents that have no companion
- double-reports violations
All enforcement logic — including every companion check — must live inside the parent resource_policy block, using inline or top-level lookups.
Plan-time pattern — use core::getresources() at the top level only when the filter value is a known constant (not derived from attrs.*). When the filter depends on the current resource's own attribute, use the inline pattern below instead. ❌ Top-level {} (empty filter) + for-loop or lookup map filtered by attrs.* inside resource_policy is the prohibited anti-pattern — see verified-syntax.md Mistake 13 for both variants.
⚠️ Exception — "companion absence is itself a violation": When the goal is to enforce that a companion resource exists AND has matching attributes (e.g. a parent resource must have a linked companion with a specific qualifying attribute set), the inline filter pattern produces false negatives: if the companion is absent or has a mismatching linking key, core::getresources() returns [], and !has_companion is true → a condition like !has_companion || check_attr == "VALUE" incorrectly passes. Use the top-level collect-then-filter pattern instead:
# Top-level: collect ALL companions globally, filter to those with the qualifying attribute,
# then check membership inside resource_policy.
locals {
all_companions = core::getresources("aws_companion_resource", {})
# Filter to companions that have the qualifying attribute set (e.g. non-empty elb)
qualifying_companions = [for c in local.all_companions : c
if core::try(c.qualifying_attr, null) != null && core::try(c.qualifying_attr, "") != ""]
# Collect the linking attribute values from qualifying companions only
companion_names = [for c in local.qualifying_companions : core::try(c.linking_attr, "")]
# Use qualifying companions (not all) to decide whether to evaluate
has_qualifying = core::length(local.qualifying_companions) > 0
}
resource_policy "aws_parent_resource" "example" {
# Only evaluate when qualifying companions exist in the plan
filter = local.has_qualifying
locals {
parent_name = core::try(attrs.name, "")
is_linked = core::length([for n in local.companion_names : n if n == local.parent_name]) > 0
# 🔴 Condition must be POSITIVE: both linked AND check_attr correct
# Do NOT use: !is_linked || check_attr == "EXPECTED" ← this passes when companion absent
is_compliant = local.is_linked && core::try(attrs.check_attr, "") == "EXPECTED"
}
enforce {
condition = local.is_compliant
error_message = "..."
}
}This pattern correctly detects:
- Companion absent entirely →
has_qualifying = false→filter = false→ parent skipped ✓ - Companion present but with no qualifying attribute →
qualifying_companions = []→has_qualifying = false→ parent skipped ✓ - Companion with wrong
linking_attrvalue →is_linked = false→is_compliant = false→ violation ✓ - Companion with correct linking and correct
check_attr→is_compliant = true→ passes ✓
⚠️ Choose filter scope carefully: filter = local.has_qualifying (qualifying companions only) skips all parents when no qualifying companions exist — this matches a Sentinel that returns early when no qualifying companions are found. If the Sentinel does NOT skip when non-qualifying companions exist (e.g. it evaluates parents even when only ALB/NLB attachments are present), use filter = core::length(local.all_companions) > 0 instead. With this broader filter, unlinked parents (is_linked = false) are evaluated and correctly fail is_linked && check_attr == VALUE.
🔴 Condition polarity rule: When using the top-level collect-then-filter pattern, always use a positive condition (is_linked && check_attr == VALUE), not a negative condition (!is_linked || check_attr == VALUE). The negative form silently passes any parent that is not linked — which is the opposite of the intended enforcement. With the positive form: unlinked parent → is_linked = false → is_compliant = false → violation (correct). With the negative form: unlinked parent → !false || any = true → is_compliant = true → passes (incorrect).
Apply-time pattern — when the filter value is the current resource's own attribute (e.g. bucket = attrs.id, or event_bus_name = attrs.name), use an inline core::getresources() call with the direct filter inside resource_policy. The filter cannot resolve at plan time (the attribute value is unknown until apply), but fully resolves once the resource is provisioned. The linking attribute may reference attrs.id, attrs.arn, or attrs.name — check the child resource's Terraform Registry docs to determine which one.
S3 cross-resource note: Always use
attrs.id(notattrs.bucket) when filtering S3 child resources such asaws_s3_bucket_public_access_block,aws_s3_bucket_acl,aws_s3_bucket_server_side_encryption_configuration, andaws_s3_bucket_policy. Terraform providers set the child resource's linking attribute (bucket) to the parent bucket's.id. Usingattrs.idensures the filter matches the actual value stored in the child resource's plan.
# NOTE: This policy contains a cross-resource reference that will not resolve during plan time,
# but the policy will run successfully during apply time.
resource_policy "aws_s3_bucket" "s3_block_public_access" {
locals {
public_access_block = core::getresources("aws_s3_bucket_public_access_block", {
bucket = attrs.id
})
block_public_acls = core::try(local.public_access_block[0].block_public_acls, false)
block_public_policy = core::try(local.public_access_block[0].block_public_policy, false)
ignore_public_acls = core::try(local.public_access_block[0].ignore_public_acls, false)
restrict_public_buckets = core::try(local.public_access_block[0].restrict_public_buckets, false)
}
enforce {
condition = local.block_public_acls && local.block_public_policy && local.ignore_public_acls && local.restrict_public_buckets
error_message = "S3 bucket does not have all public access block settings enabled."
}
}Error Message Rules
- ✅ Static strings:
"S3 buckets must enable versioning." - ✅ Safe interpolation:
"Instance type '${attrs.instance_type}' is not allowed." - ❌ Never interpolate
${meta.address}— it is UNDEFINED inresource_policyand crashes at runtime.tfpolicy testwill NOT catch this; only a realterraform planwill. - Use
error_messagefor all enforceable violations (condition can be false). - Use
info_messageonly in non-convertible stub blocks wherecondition = trueand no real enforcement is possible. Never useinfo_messagein a block that can actually fail a resource.
Comment Conventions — # LIMITATION: vs # NOTE:
These two markers have distinct meanings — do not interchange them:
# LIMITATION:— tfpolicy cannot fully express or enforce the requirement. Part of the original Sentinel logic had to be omitted or approximated. Always accompanies aSimplifyorNot convertiblequality label.- Example: "LIMITATION: The bucket policy IAM document check is non-convertible — tfpolicy does not expose reference metadata."
# NOTE:— The enforcement is complete but has a runtime caveat that does not reduce coverage.- Example: "NOTE: This policy contains a cross-resource reference that will not resolve during plan time, but will run successfully during apply time."
Output Structure Rules — Consistency-Critical
These rules eliminate the most common sources of non-deterministic output between runs:
Rule 1 — One resource_policy block per resource type.
Multiple checks on the same resource type MUST be combined into a single resource_policy block using separate locals and multiple enforce blocks. Never split checks into two resource_policy blocks for the same type.
# ✅ Correct — two checks, one block
resource_policy "aws_ecs_task_definition" "secure_networking" {
filter = core::try(attrs.network_mode, "") == "host"
locals {
containers = core::try(core::jsondecode(attrs.container_definitions), [])
non_privileged = [for c in local.containers : c if core::try(c.privileged, false) != true]
insecure_user = [for c in local.containers : c if core::try(c.user, "") == "" || core::try(c.user, "") == "root"]
}
enforcement_level = "advisory"
enforce {
condition = core::length(local.non_privileged) == core::length(local.containers)
error_message = "ECS task definition containers must not run as privileged."
}
enforce {
condition = core::length(local.insecure_user) == 0
error_message = "ECS task definition containers must define a non-root user."
}
}
# ❌ Wrong — same resource type split across two blocks
resource_policy "aws_ecs_task_definition" "check_privileged" { ... }
resource_policy "aws_ecs_task_definition" "check_user" { ... }Rule 2 — Non-convertible checks are comments, not stub blocks.
When a specific check cannot be converted (reference metadata, etc.), document it as a # LIMITATION: comment inside the existing resource_policy block — do NOT create a separate resource_policy block of the same type as a stub. A dedicated stub block (with condition = true) is only appropriate when the entire policy has no convertible checks at all.
Rule 3 — Per-resource enforcement for all checks.
Do NOT aggregate across all resources of a type at the plan level (e.g. "at least one trail in the whole plan is compliant = pass all"). Each resource_policy must evaluate each individual resource independently. tfpolicy's evaluation model is per-resource — plan-level aggregation via top-level core::getresources() to pass/fail based on a count across all resources is not idiomatic and produces non-deterministic results.
# ✅ Correct — each aws_cloudtrail resource evaluated independently
resource_policy "aws_cloudtrail" "s3_dataevents_enabled" {
locals { ... }
enforce {
condition = local.is_compliant
error_message = "This CloudTrail trail must log S3 data events."
}
}
# ❌ Wrong — plan-level aggregation ("at least one compliant trail")
locals {
all_trails = core::getresources("aws_cloudtrail", {})
num_compliant = core::length([for t in local.all_trails : t if ...])
any_compliant = local.num_compliant > 0
}
resource_policy "aws_cloudtrail" "s3_dataevents_enabled" {
enforce {
condition = local.any_compliant # Wrong: passes every trail if any one trail is compliant
error_message = "..."
}
}Sentinel → Terraform Policy Conversion
Use this section when the input is an existing Sentinel .sentinel file. Follow Steps 1–5 below; apply the authoring guidance above when generating the policy HCL.
Sentinel → Terraform Policy construct mapping
| Sentinel | Terraform Policy |
|---|---|
import "tfplan/v2" |
Native policy context — no import needed |
tfplan.resource_changes loops |
Usually one resource_policy per resource type; split multi-type Sentinel rules when needed |
filter tfplan.resource_changes |
Resource type in the policy declaration plus optional filter for attribute-based preconditions |
as address, rc |
attrs.* and meta.provider_type for the current resource. ⚠️ meta.address is UNDEFINED — do not use it. |
rc.change.after.<attr> |
attrs.<attr> |
rc.change.before.<attr> |
prior_attrs.<attr> — available when operations does NOT include "create" |
rc.change.actions is ["delete"] |
operations = ["delete"] — fires only on destroy |
rc.change.actions is not ["delete"] |
operations = ["create", "update"] — skips destroy |
param allowed_list default [...] |
input "allowed_list" { type = list(string); default = [...] } |
time.now.weekday_name |
core::formatdate("EEEE", core::timestamp()) — UTC weekday name |
time.now.hour |
core::parseint(core::formatdate("HH", core::timestamp()), 10) — UTC hour as int |
strings.has_prefix(s, p) |
core::startswith(s, p) — arg order: full string first, prefix second (same as Sentinel). Note: meta.version in provider_policy is the resolved version (e.g. "6.50.0"), NOT the constraint string. Sentinel's strings.has_prefix(p.version_constraint, ">") is non-convertible — tfpolicy does not expose the constraint string. Use core::semverconstraint(meta.version, ...) instead. |
strings.has_suffix(s, suffix) |
core::endswith(s, suffix) |
rc.provider_name |
meta.provider_type |
all/any expressions |
core::alltrue(list) / core::anytrue(list) on tfpolicy 0.3.0+. On < 0.3.0, use list comprehensions with filtered counts instead: core::length([for x in list : x if !x]) == 0 for "all true" and core::length([for x in list : x if x]) > 0 for "any true". |
else clause |
Multiple enforce blocks |
maps.get(obj, key, default) |
core::try(obj.key, default) |
collection.reject(items, predicate) |
List comprehension with if — [for item in items : item if !<predicate>] |
collection.reject(items, predicate) is empty |
🔴 ALL condition — every element satisfies the predicate. ⚠️ Do NOT convert this to an ANY condition (length([for item in items : item if <predicate>]) > 0) — that inverts the semantics. The correct pattern is: non_compliant = [for item in items : item if !<predicate>] / is_compliant = core::length(local.non_compliant) == 0. Example: collection.reject(log_opts, func(o) { o.enabled and o.log_type is "AUDIT_LOGS" }) is empty → non_compliant = [for o in local.log_opts : o if !(core::try(o.enabled, false) && core::try(o.log_type,"") == "AUDIT_LOGS")] / condition = core::length(local.log_opts) > 0 && core::length(local.non_compliant) == 0. When the predicate tests a boolean flag, verify the Sentinel default value and match it in core::try(attr, <same_default>). |
collection.filter(items, predicate) |
List comprehension with if — [for item in items : item if <predicate>] |
strings.split(sep, str) |
core::split(separator, string) — splits a string into a list of substrings at each occurrence of separator. Example: core::split("-", "80-443") → ["80","443"]. ⚠️ For version range checks, prefer core::semverconstraint() over core::split + core::parseint — see the Semver note in the core:: Functions section. |
When converting Sentinel
summary {}output orprint()statements, do not reproduce address-listing behavior. Terraform Policy diagnostics already identify the failing resource — prefer remediation-focused messages instead.
Sentinel features that ARE convertible
- Time-based rules —
core::timestamp()+core::formatdate()+core::parseint()cover Sentinel'stimeimport. All values are UTC; document that assumption in policy comments. paramblocks — direct equivalent:inputblocks withtypeanddefault.rc.change.beforefor update/delete —prior_attrsis available whenoperationsdoes NOT include"create".- Integer range checks — Sentinel policies that check whether all ports within
[from_port, to_port]are authorized CAN be converted. Use the count approach: filterauthorized_portsto those within the range and compare the count toto_port - from_port + 1. Do not usecore::range()with dynamicattrs.*values. Seeverified-syntax.mdMistake 23. tfconfig/v2reference count — each resource reference is stored twice in.references(once asresource.name, once asresource.name.id). When simplifying a reference-count check to a directcore::length(attrs.attribute)check, halve the threshold:references > 2→core::length(attrs.attribute) >= 2.
Plan-Time vs Apply-Time Policies
Terraform Policy can enforce controls at plan time (before terraform apply) or apply time (during terraform apply). Most policies are plan-time, but cross-resource lookups that depend on newly-created resource IDs only fully resolve at apply time.
| Scenario | Enforcement time | Quality label |
|---|---|---|
| Single-resource attribute checks | Plan time | Perfect / Good |
| Cross-resource lookup where the filter value is a known literal or existing resource ID | Plan time | Good |
Cross-resource lookup where the filter value is a newly-created resource ID (e.g. bucket = attrs.id for a bucket created in the same plan) |
Apply time | Good |
Reference metadata / graph traversal (res.config.attribute["references"]) |
Not convertible | — |
When a Sentinel policy uses cross-resource references with a value-based filter from a newly-created resource:
- Generate the policy using an inline
core::getresources()call with the direct filter insideresource_policy. - Add this note in the conversion report (not a limitation label): "This policy contains a cross-resource reference that will not resolve during plan time, but the policy will run successfully during apply time."
- Do not label this as Simplify or Not convertible — it is a valid Good conversion.
For cross-resource patterns, apply the registry check (Steps A and B) described in "Cross-Resource Lookups" above. The registry check fully determines the policy structure, regardless of whether the Sentinel's violations iterated the parent or the child type. Dependent child resource types must never have a standalone resource_policy block.
Cannot Convert (Explain the Alternative)
- Mocking/testing infrastructure (
import "tfconfig-functions") — tfpolicy uses.policytest.hcl. See the tfpolicy-test skill (tfpolicy-test.md). - Custom Sentinel imports — limited plugin support; use HTTP plugins or native functions if available.
- Sentinel simulator / built-in test framework — replace with
.policytest.hcltest files. - Cross-workspace data access — tfpolicy evaluates a single plan. Use workspace tags (
meta.tfe_workspace.tags) or external plugins. print()/ debug statements — no debug output mechanism; rely on conciseerror_message/info_messagetext only when it adds remediation context.- Stateful logic across evaluations — policies are stateless; use external systems via plugins if state is required.
rc.change.beforeoutside delete/update — for first-time creates there is no pre-state.- Cross-resource reference navigation via reference metadata (
res.config.attribute["references"],res.config.to) — tfpolicy does not expose which Terraform resource a value points to. When the Sentinel policy uses the resolved value of an attribute (not the reference path itself), convert usingcore::getresources()with a value-based filter — see "Plan-Time vs Apply-Time Policies" above. - Data source content inspection by address —
core::getdatasource()requires filter attributes and cannot query by Terraform address. - Complex resource-graph traversal via reference metadata — cannot traverse the Terraform resource graph by reference (e.g. "find all subnets that reference this VPC"). Only resolved attribute values are available. If the Sentinel policy traverses by resolved attribute value (e.g.
bucket = attrs.id), convert usingcore::getresources()with a value-based filter and mark as an apply-time policy — see "Plan-Time vs Apply-Time Policies" above.
⚠️ Partial reference dependence — do not skip the whole policy. Items 8 and 10 apply to the specific check that uses reference metadata, not the entire policy. If only some checks in a Sentinel policy rely on
res.config.attribute["references"]or graph traversal, convert the remaining checks as normal, apply the Simplify label to the overall policy, and document each skipped check in the report with: "This check was omitted — tfpolicy does not expose reference metadata (res.config.attribute["references"])." Only label the entire policy as Not convertible if its core enforcement logic is wholly dependent on reference metadata with no convertible remainder.
❌ Do not approximate reference-metadata checks with cross-resource JSON value matching. A common workaround is to retrieve all instances of a related resource via
core::getresources()and compare their serialized attribute values (e.g.attrs.policy == doc.json) as a proxy for "this resource references that data source." This is not a faithful conversion — it produces false negatives when the referenced resource is already deployed and absent from the current plan, and enforces a different semantic (value equality) than the original (structural reference). When the only check IS reference metadata, generate a stub policy instead:# <policy_name> — Non-Convertible (Reference Metadata) resource_policy "<resource_type>" "<policy_name>_stub" { enforce { condition = true info_message = "Automated enforcement not available: this policy requires reference metadata inspection which tfpolicy does not support. Manual compliance review required." } }
⚠️ Partial exception — direct content inspection via the parent bucket. The cross-resource approximation prohibition applies to comparing JSON across resources (e.g. fetching all
aws_iam_policy_documentoutputs and matching against a bucket policy). It does not prohibit inspecting a bucket's own policy content. When the Sentinel reference-metadata check is really enforcing content (e.g. "the bucket policy must not grant public read access"), convert it by anchoring onresource_policy "aws_s3_bucket", fetching the childaws_s3_bucket_policyviacore::getresources("aws_s3_bucket_policy", { bucket = attrs.id }), and inspecting itspolicyattribute viacore::jsondecode()inside the parent block. This follows the standard dependent-child pattern —aws_s3_bucket_policyalways requires a parent bucket. Label the overall policy Simplify (because the reference-path check is omitted) and add a# NOTE:that the content-based check achieves a similar security outcome.# ✅ Content-based alternative to reference-metadata check on bucket policies # NOTE: This policy contains a cross-resource reference that will not resolve during plan time, # but the policy will run successfully during apply time. resource_policy "aws_s3_bucket" "no_public_read_policy" { locals { bucket_policy = core::getresources("aws_s3_bucket_policy", { bucket = attrs.id }) policy_doc = core::try(core::jsondecode(core::try(local.bucket_policy[0].policy, "{}")), { Statement = [] }) statements_enriched = [for s in core::try(local.policy_doc.Statement, []) : { effect = core::lower(core::try(s.Effect, "Allow")), action = core::try(s.Action, []), principal_str = core::try(s.Principal, ""), principal_aws = core::try(core::try(s.Principal, {}).AWS, "") }] public_read_stmts = [for s in local.statements_enriched : s if s.effect == "allow" && (core::contains(s.action, "s3:GetObject") || core::contains(s.action, "s3:*") || core::contains(s.action, "*")) && (s.principal_str == "*" || s.principal_aws == "*")] } enforce { condition = core::length(local.public_read_stmts) == 0 error_message = "S3 bucket policy must not grant public read access (Principal: * with s3:GetObject or s3:*)." } }
Conversion quality labels
- Perfect — Same enforcement intent and behavior expressed directly in tfpolicy with no known semantic gap.
- Good — Preserves the important enforcement outcome using idiomatic tfpolicy structure (not a one-to-one translation).
- Simplify — Only part of the original Sentinel behavior can be reproduced; document the missing checks explicitly.
- Not convertible — tfpolicy lacks the runtime data or language features required for a safe translation.
Conversion Strategy
| Tier | Pattern | Examples | Approach |
|---|---|---|---|
| ✅ Easy | Single resource, direct attribute check | EBS encryption, RDS public access, ECS container insights, EKS audit logging, Lambda runtime, CloudTrail logging | Direct 1:1 conversion |
| ✅ Good | Cross-resource value-based lookup (resolved attribute IDs) | S3 + public-access-block, S3 + versioning, EventBridge + resource policy | Use inline core::getresources() with direct filter inside resource_policy; mark as apply-time policy if filter value is a newly-created resource ID |
| ⚠️ Simplify | Cross-resource logic with partial reference dependence | EC2 IMDSv2, security group coverage | Check explicit configuration only; document what is not checked; prefer known literal attributes over inferred relationships |
| ❌ Avoid | rc.change.before outside update/delete; reference-metadata navigation; data-source content inspection by address; complex graph traversal; mutable external state; strings.split() decomposition |
— | Recommend a redesign or treat as non-convertible |
Steps to Convert a Sentinel Policy
Step 1 — Parse the Sentinel Structure
Identify imports (tfplan, tfconfig, tfstate, custom), filter logic and resource selection, main and sub-rules, and enforcement level (advisory vs mandatory). Ask: What resources are being checked? Which attributes are validated? Does the policy depend on before/after diff? Are there cross-resource dependencies? Are data sources inspected? Does it rely on reference metadata or graph traversal?
Identify the enforcement target and the parent type: For any policy involving cross-resource dependencies, first perform the registry check (Steps A and B in "Cross-Resource Lookups" above) to identify which resource types are dependent children and which are parent types. Then use the Sentinel's violations expression to confirm the parent type — this is the type your resource_policy targets. Write resource_policy on the parent type. Every dependent child type is accessed exclusively via core::getresources() inside that parent block; all conditions on child resources are evaluated there, and all violations are reported on the parent. Do not write a standalone resource_policy on any resource type that the registry identifies as a dependent child — this rule holds regardless of whether the Sentinel's violations iterated the child or the parent, and regardless of whether the check is about a missing child or a misconfigured one.
Cross-resource policies — use the Terraform Registry to identify the parent, linking attribute, and filter value. For any child resource type involved in a cross-resource lookup, fetch its documentation at
https://raw.githubusercontent.com/hashicorp/terraform-provider-aws/main/website/docs/r/{resource_name_without_aws_prefix}.html.markdown. Look for a(Required)argument referencing a parent resource in "Argument Reference" and confirm with the usage examples (.idvs.arn). The required argument name is the linking attribute; use the correspondingattrs.idorattrs.arnin thecore::getresources()filter. This applies to all resource families — not just S3.
Do not over-constrain the enforcement condition beyond the Sentinel intent. When the Sentinel checks "at least one item in a collection satisfies condition X", the correct tfpolicy translation is core::length([for item in local.items : item if <condition>]) > 0. Do NOT translate this to "every item must satisfy X" (i.e. core::length([for item in local.items : item if !<condition>]) == 0) unless the Sentinel explicitly enforces ALL items. Over-constraining the condition creates false violations for valid configurations that the Sentinel would pass.
Distinguish enforcement intent from Sentinel implementation detail. Sentinel code often accesses a collection element by index (e.g. origins[0]) as a traversal shortcut rather than an intentional "only check the first item" rule. Before encoding an index access as a scope restriction, ask:
- Is the indexed access repeated for all resources in a filter loop? (If so, it is a loop artifact, not a one-item intent.)
- Does the policy name or comment indicate intent that applies to "all" items?
- Would checking only the first item leave a real security gap?
- Is this collection a structurally singleton block in the provider schema (e.g.
max_items = 1) — in which case[0]is intentional?
When the answer to any of the first three questions is "yes" and the fourth is "no", convert the check to iterate all items in the collection, not just [0]. Document the interpretation in the requirements as: "Sentinel source accesses collection[0]; interpreted as checking all items to preserve full enforcement intent."
Step 2 — Assess Convertibility
Check for non-convertible patterns above. Document what cannot be converted and assign a quality label.
Step 3 — Map to tfpolicy Constructs
Use the mapping table above. In practice, focus on (in order): matching resource scope, translating attribute access and null handling, replacing collection helpers with list comprehensions, and splitting compound logic into locals + multiple enforce blocks.
Step 4 — Generate the policy
Follow the authoring guidance in the Knowledge Base sections above. Apply the cross-resource decision table and companion-anchor rules.
Error-message rules:
- ✅ Static strings, or safe
${attrs.fieldname}interpolation. - ❌ Never interpolate
${meta.address}— it is UNDEFINED inresource_policyand throwsError: Unsupported attributeat runtime for every evaluated resource.tfpolicy testwill not catch this; only a realterraform plan --policies=run will.
Step 5 — Document the Conversion
Include the quality label, test success rate (if tests written), any limitations or simplifications made, behavioral differences from Sentinel, and references to related documentation.
tfpolicy-author.md(tfpolicy-author.md) — guided first-policy walkthrough.tfpolicy-author.md(tfpolicy-author.md) — reusable patterns (attribute checks, allowlists, cross-resource enforcement, etc.).verified-syntax.md(verified-syntax.md) — verified syntax tables, runtime limitations, common-mistake corrections. Source of truth — defer to this file when this SKILL.md disagrees.
Usage Instructions — Write a New Policy from User Intent
Step 1 — Clarify requirements
- Which resources / modules / providers to target.
- The specific condition to enforce.
- Whether create, update, and/or destroy should be in scope (
operations). - Whether any value should be tunable per policy set (→
inputblock). - The desired error message and whether
attrs.*interpolation is helpful.
Step 2 — Design the structure
- Choose the policy type (resource / module / provider).
- Decide whether a wildcard label (
"*") is appropriate. - Plan the
filterfor performance and to exclude resources where the attribute is meaningfully absent. Two cases:- Attribute absent = resource out of scope (e.g. no
aclblock set at all → resource doesn't configure ACLs → skip it): usefilter = core::try(attrs.field, null) != null. - Attribute absent = AWS provider default applies (e.g.
encryptedabsent → AWS defaults tofalse→ resource is still in scope and may violate the policy): do not filter on null. Usecore::try(attrs.field, <aws_provider_default>)in theconditioninstead so absent resources are evaluated against the effective default.
- Attribute absent = resource out of scope (e.g. no
- Move complex predicates into
localsfor readability.
Step 3 — Generate the policy
- Start
.policy.hclfiles containing resource or provider policies with a top-levelpolicy { required_providers { ... } }block. - Use provider sources and version constraints that match the resource types referenced by the policy.
- Run
tfpolicy validateafter authoring to confirm the policy parses and the referenced provider schemas can be resolved. - See Required
policy.required_providersBlock (#required-policyrequired_providers-block) for validation limitations such as version-range best-effort checks and wildcard-target behavior. - Wrap optional attributes in
core::try(). - Keep each boolean expression on a single line.
- Use multiple
enforceblocks when you want independent diagnostics. - Never interpolate
${meta.address}inerror_message.
Step 4 — Document the policy
- Header comment with description, resources checked, and any compliance reference.
- Note operation scope and any parameterization.
Worked Example
User request: "Ensure all S3 buckets have versioning enabled."
Include the top-level policy { required_providers { ... } } block shown in Core Structure (#core-structure).
# Ensure S3 Bucket Versioning is Enabled
#
# Enforces that all AWS S3 buckets have versioning enabled to protect
# against accidental deletion and enable recovery.
#
# Resources checked:
# - aws_s3_bucket with inline versioning configuration
# - aws_s3_bucket_versioning (standalone resource pattern)
resource_policy "aws_s3_bucket" "versioning_enabled" {
filter = attrs.versioning != null
locals {
versioning_enabled = core::try(attrs.versioning[0].enabled, false)
}
enforce {
condition = local.versioning_enabled == true
error_message = "S3 buckets must set versioning.enabled = true to protect against accidental deletion."
}
}
resource_policy "aws_s3_bucket_versioning" "versioning_enabled" {
locals {
versioning_status = core::try(attrs.versioning_configuration[0].status, "Disabled")
}
enforce {
condition = local.versioning_status == "Enabled"
error_message = "S3 bucket versioning resources must have status 'Enabled'. Current status: '${local.versioning_status}'."
}
}Best Practices
Policy Writing
- Use descriptive policy names.
- Add a comprehensive header comment with description, resources checked, and compliance references.
- Always use
core::try()for optional attributes. - Break down complex logic with
locals. - Provide actionable, remediation-focused error messages.
- Cover all variations of a resource family (e.g. AWS security groups:
aws_security_group,aws_security_group_rule,aws_vpc_security_group_ingress_rule,aws_default_security_group). - Use
filterto skip resources that don't apply (saves work and avoids false positives). - Cache
core::getresources()results in top-level locals when the filter is a known literal or an existing resource ID — this avoids O(N) overhead per resource. Exception: when the filter depends on the current resource's own attribute (e.g.{bucket = attrs.id},{event_bus_name = attrs.name}), the call cannot be pre-computed at top level becauseattrsis only available insideresource_policy— use the inline pattern instead (see item 15). ❌ Do NOT work around this by fetching all child resources at the top level with{}and building a lookup map — that is the same anti-pattern restructured. - Avoid
core::getdatasource()insideresource_policy— it calls provider APIs. - Build lookup maps once for O(1) matching when iterating many resources.
- Keep each boolean expression on a single line (HCL parser limitation in beta).
- Use clear variable names (
scanning_config, notsc). - Convert sets to lists before indexing:
[for item in set : item][0]. - Don't use
core::try()defaults to mask missing values that should fail the policy — usefilterinstead. - For cross-resource lookups where the filter value is the current resource's own attribute: use an inline
core::getresources()with the direct filter insideresource_policy. To find the correct linking attribute name and filter value, fetch the child resource's Terraform Registry documentation athttps://raw.githubusercontent.com/hashicorp/terraform-provider-aws/main/website/docs/r/{resource_name_without_aws_prefix}.html.markdownand look for the(Required)or(Optional)argument that references the parent resource. Check whether the usage examples assign it.id,.arn, or.name— useattrs.id,attrs.arn, orattrs.nameaccordingly. ⚠️ Some linking attributes are(Optional)in the schema (e.g.event_bus_nameonaws_cloudwatch_event_bus_policydefaults to the default bus) but still represent a parent-child link — treat them the same way. Always add this comment in the policy: "This policy contains a cross-resource reference that will not resolve during plan time, but the policy will run successfully during apply time." Do not use a top-level cache + for-loop for this pattern. - Cross-resource enforcement — registry check determines the structure unconditionally:
- First, verify every resource type via the Terraform Registry. For any resource type involved in a cross-resource check, fetch
https://raw.githubusercontent.com/hashicorp/terraform-provider-aws/main/website/docs/r/{resource_name_without_aws_prefix}.html.markdown. A resource is a dependent child if it has a(Required)argument whose description or usage examples reference another AWS resource by.id,.arn, or.name. The argument name is the linking attribute; the assignment in examples tells you whether to useattrs.id,attrs.arn, orattrs.name. The resource that the linking attribute points to is the parent type. - When the enforcement goal is to ensure every parent has a compliant child, the dependent child must NEVER have a standalone
resource_policyblock. Write theresource_policyblock on the parent type. Fetch the dependent child inside the parent block viacore::getresources("<child_type>", {<linking_attr> = attrs.id_or_arn_or_name}). Evaluate all attribute checks on those lookup results. Report all violations on the parent. When the goal is only to check every existing child's own attributes, a standaloneresource_policyon the child type is valid — see Self-check above. - Concrete examples:
aws_s3_bucket_public_access_block,aws_s3_bucket_policy,aws_s3_bucket_acl(all requirebucket) → never standalone for any enforcement goal; always fetched insideresource_policy "aws_s3_bucket".aws_lb_listener→ standaloneresource_policy "aws_lb_listener"is valid when checking every listener's own attributes (e.g., protocol, ssl_policy); useresource_policy "aws_lb"with inline lookup only when the goal is "every LB must have at least one compliant listener". - Always add this comment when using this pattern: "This policy contains a cross-resource reference that will not resolve during plan time, but the policy will run successfully during apply time."
- First, verify every resource type via the Terraform Registry. For any resource type involved in a cross-resource check, fetch
Communication
- Ask clarifying questions; don't assume requirements.
- Show sample passing and failing resources alongside the policy.
- Explain enforcement-level trade-offs.
- Offer simplifications when an exact rule isn't expressible.
See Also
tfpolicy-test(tfpolicy-test.md) — write.policytest.hclfiles to validate the policies authored here.../../examples/README.md(../examples/README.md) — side-by-side Sentinel +.policy.hclexamples with quality labels and per-example READMEs.verified-syntax.md(verified-syntax.md) — shared source-of-truth syntax reference.
Purpose: Quick reference for AI agents to start writing Terraform Policy (tfpolicy) Status: All behaviors verified during private beta (2026-02-19) Compatible with: Any AI system capable of reading markdown and generating HCL code
Quick Start for AI Agents
When a user asks you to write a Terraform Policy:
- Identify the policy type: resource_policy, module_policy, or provider_policy
- Use the correct structure: filter (optional), locals (optional), enforce (required)
- Remember: ALL built-in functions need
core::prefix - For versions: Always use
core::semverconstraint(), never direct comparison - Validate: Check examples in this guide for patterns
Table of Contents
- Critical Rules (#critical-rules)
- Policy Structure (#policy-structure)
- Policy Types (#policy-types)
- Core Functions Reference (#core-functions-reference)
- Semantic Versioning (#semantic-versioning)
See Also:
- Common Patterns (tfpolicy-author.md) - Common policy patterns and examples
- tfpolicy-author (tfpolicy-author.md) - Complete authoring reference for this sub-skill
Critical Rules
✅ Rule 1: ALL Functions Need core:: Prefix
ALWAYS use core:: prefix for built-in Terraform functions
# ✅ CORRECT
filter = core::try(attrs.encrypted, false) == true
is_valid = core::length([for b in local.checks : b if b]) > 0
message = "Allowed: ${core::join(", ", local.versions)}"
# ❌ WRONG - Will fail with "Unknown function" error
filter = try(attrs.encrypted, false) == true # Missing core:: prefix
# ❌ WRONG - missing core:: prefix (tfpolicy 0.3.0+ does have core::anytrue, but bare anytrue() is never valid)
# is_valid = anytrue(local.checks)
# ✅ CORRECT on tfpolicy 0.3.0+; on < 0.3.0 use core::length([for b in local.checks : b if b]) > 0 instead
# is_valid = core::anytrue(local.checks)✅ Rule 2: Use Semantic Versioning for ALL Version Comparisons
NEVER use direct comparison operators for versions
# ✅ CORRECT
condition = core::semverconstraint(meta.version, ">= 4.0.0, < 5.0.0")
# ❌ WRONG - Direct comparison doesn't work properly
condition = meta.version >= 4.0 && meta.version < 5.0✅ Rule 3: ALL Policy Types Support locals and filter
Don't avoid using locals or filter - they work in all policy types
# ✅ All three policy types support this structure
resource_policy "aws_s3_bucket" "example" {
filter = <condition> # ✅ Supported
locals { ... } # ✅ Supported
enforce { ... } # ✅ Required
}
module_policy "example" "check" {
filter = <condition> # ✅ Supported
locals { ... } # ✅ Supported
enforce { ... } # ✅ Required
}
provider_policy "aws" "check" {
filter = <condition> # ✅ Supported
locals { ... } # ✅ Supported
enforce { ... } # ✅ Required
}Note: Language servers during private beta may show false errors for locals in provider_policy. These are safe to ignore.
Policy Structure
Basic Template
<policy_type> "<target>" "<policy_name>" {
# Optional: Pre-filter resources/modules/providers
filter = <boolean_expression>
# Optional: Local variables for complex logic
locals {
variable_name = <expression>
}
# Required: One or more enforcement rules
enforce {
condition = <boolean_expression>
error_message = "<user-facing message>"
}
# Optional: Additional enforce blocks
enforce {
condition = <another_condition>
error_message = "<another message>"
}
}Execution Flow
- filter - Applied first, determines which resources/modules/providers to evaluate
- locals - Computed once per filtered item
- enforce - Each block evaluated; all must pass for policy to pass
Performance Best Practices
Critical for large configurations — choose the pattern based on what the filter depends on:
Top-level
core::getresources()only when the filter is a known literal/constant- Use when the filter value is a hardcoded string, a fixed ID, or another stable literal — not derived from
attrs.* - Executes once for the entire policy evaluation; result is reused by every
resource_policyblock - ❌ Do not use a top-level empty-filter call (
{}) and then filter byattrs.*insideresource_policy— that is the prohibited anti-pattern (O(N²) with silent correctness bugs)
locals { # OK — filter is a known literal, cached once for all resources all_buckets = core::getresources("aws_s3_bucket", {}) } resource_policy "aws_s3_bucket" "example" { locals { bucket_count = core::length(local.all_buckets) # Reuse cached value } }- Use when the filter value is a hardcoded string, a fixed ID, or another stable literal — not derived from
Inline
core::getresources()insideresource_policywhen the filter depends on the resource's own attribute- Use when "every parent must have at least one compliant child" and the linking key is
attrs.id,attrs.arn, orattrs.name - The filter value is unknown at plan time (it's the current resource's own attribute), so a top-level cache is impossible
- Executes once per evaluated resource (apply-time); the lookup fully resolves once the resource is provisioned
- This is not a performance compromise — it is the correct and required pattern for parent+child presence enforcement
# NOTE: This policy contains a cross-resource reference that will not resolve during # plan time, but the policy will run successfully during apply time. resource_policy "aws_s3_bucket" "s3_block_public_access" { locals { public_access_block = core::getresources("aws_s3_bucket_public_access_block", { bucket = attrs.id # filter depends on current resource — must be inline }) } enforce { condition = core::length(local.public_access_block) > 0 error_message = "S3 bucket must have a public access block resource." } }- Use when "every parent must have at least one compliant child" and the linking key is
Use
filterto reduce evaluation scope- Skip resources that don't need checking
- Significantly improves performance
See Advanced Patterns Guide (tfpolicy-author.md#8--performance-optimization-verified) for detailed performance guidance
Policy Types
1. resource_policy
Purpose: Validate Terraform resource configurations
resource_policy "aws_s3_bucket" "encryption_check" {
enforce {
condition = attrs.server_side_encryption_configuration != null
error_message = "S3 buckets must have encryption enabled"
}
}Available attributes:
attrs.<attribute_name>- Resource attributes from configurationmeta.provider_type- Provider type (e.g.,aws)meta.tfe_stack.deployment_name,meta.tfe_stack.stack_name,meta.tfe_stack.deployment_group- Stack metadata for stack-scoped routing/exclusion workflows (tfpolicy 0.3.x+)- ⚠️
meta.addressis UNDEFINED forresource_policyin real plan evaluation — do not use it
⚠️ Understanding Provider Schema (Blocks vs Attributes):
Terraform providers expose their raw schema, where some attributes are blocks that require special handling:
# ❌ Wrong - blocks cannot be accessed directly
attrs.server_side_encryption_configuration.rules
# ✅ Correct - blocks are lists, use [0] index
attrs.server_side_encryption_configuration[0].rulesCommon AWS provider blocks requiring [0] index:
default_tags[0].*(AWS provider configuration)assume_role[0].*(AWS provider configuration)versioning[0].enabled(S3 bucket)server_side_encryption_configuration[0].*(S3 bucket)metadata_options[0].*(EC2 instance)
Best practice: Always use core::length() checks before accessing blocks:
locals {
versioning_blocks = core::try(attrs.versioning, [])
versioning_enabled = core::length(local.versioning_blocks) > 0 ?
core::try(local.versioning_blocks[0].enabled, false) : false
}See Verified Syntax Reference (verified-syntax.md#4--critical-blocks-vs-attributes-schema-distinction) for complete details
Wildcards:
resource_policy "*" "all_resources" {
# Matches ALL resource types
}2. module_policy
Purpose: Validate Terraform module sources and versions
# Check all modules use approved registry (prefix-based using core::regex)
module_policy "*" "module_source_check" {
filter = meta.source != null
locals {
# Prefix match: does source start with the approved namespace?
# core::contains() only does exact full-string matching — use core::regex() for prefix checks
is_approved = core::try(core::regex("^app\\.terraform\\.io/myorg/", meta.source), null) != null
}
enforce {
condition = local.is_approved
error_message = "Modules must use an approved registry source. Current source: ${meta.source}"
}
}
# Check specific module version with semver
module_policy "app.terraform.io/myorg/vpc/aws" "vpc_version" {
locals {
has_version = meta.version != null
meets_minimum = core::semverconstraint(meta.version, ">= 1.0.0")
}
enforce {
condition = local.meets_minimum
error_message = "VPC module must be >= 1.0.0, got ${meta.version}"
}
}Available attributes:
meta.source- Module source (e.g.,app.terraform.io/org/module/provider)meta.version- Module version (works withcore::semverconstraint())meta.address- Module address (e.g.,module.vpc)
⚠️ Current Limitations (Private Beta):
- ❌
attrs.*(module inputs) NOT accessible yet - work in progress - ✅
meta.tfe_stackandmeta.tfe_workspace.tagsare available; Stack fields are empty outside Stack evaluations.
Targeting:
- Use full module source to target specific module:
module_policy "app.terraform.io/myorg/vpc/aws" - Use
"*"wildcard to match all modules:module_policy "*" - ❌ Substring matching does NOT work:
module_policy "vpc"won't match modules with "vpc" in source
3. provider_policy
Purpose: Validate provider versions and configurations
provider_policy "aws" "version_check" {
locals {
minimum_version = "4.0.0"
}
enforce {
condition = core::semverconstraint(meta.version, ">= ${local.minimum_version}")
error_message = "AWS provider must be >= ${local.minimum_version}, got ${meta.version}"
}
}Available attributes:
meta.source- Full provider source (e.g.,registry.terraform.io/hashicorp/aws)meta.version- Provider version (e.g.,4.67.0)meta.alias- Provider alias (if configured)attrs.*- Provider configuration attributes (region, profile, etc.)
Note:
meta.nameandmeta.typeare NOT confirmed available inreference/verified-syntax.md. Do not rely on them — usemeta.sourceto identify a provider andmeta.versionfor version checks.
Accessing provider configuration with attrs:
provider "aws" {
region = "us-west-2"
profile = "production"
}
provider_policy "aws" "region_check" {
locals {
aws_region = core::try(attrs.region, "")
allowed_regions = ["us-east-1", "us-west-2", "eu-west-1"]
}
enforce {
condition = core::contains(local.allowed_regions, local.aws_region)
error_message = "AWS provider must use approved region. Got: ${local.aws_region}"
}
}⚠️ Provider configuration blocks require [0] index:
# AWS provider blocks (need [0] index)
attrs.default_tags[0].tags
attrs.assume_role[0].role_arn
attrs.endpoints[0].s3Wildcards:
provider_policy "*" "all_providers" {
# Evaluates once per provider in configuration
}Core Functions Reference
⚠️ String Function Limitations
Terraform Policy has VERY LIMITED string functions:
# ✅ Get string length
core::length(string)
# Example: core::length(attrs.description) > 0
# ✅ Join list into string
core::join(separator, list)
# Example: core::join(", ", local.allowed_versions)✅ String functions available:
- ✅
core::startswith(string, prefix)- Returns bool; e.g.core::startswith(meta.version, ">")✅ - ✅
core::endswith(string, suffix)- Returns bool - ✅
core::contains_substring(string, substr)- Returns bool - ❌
core::contains(string, substring)- Does NOT work for strings (only lists!) - ✅
core::split(separator, string)- Splits string into list; e.g.core::split("-", "80-443")→["80", "443"]
✅ What IS Also Available — core::regex(pattern, string):
- Pattern and substring matching via
core::regex() - Important:
core::regex()throws on no match (does NOT return null) — always wrap withcore::try()
# Safe boolean pattern — use this idiom everywhere
locals {
# Substring check: does description contain "exception"?
has_exception = core::try(core::regex("NET-8 = exception", core::try(attrs.description, "")), null) != null
# Prefix check: does source start with approved namespace?
is_approved_source = core::try(core::regex("^app\\.terraform\\.io/myorg/", meta.source), null) != null
# Exact membership: still use core::contains() for lists
approved_types = ["gp3", "io1"]
is_approved_type = core::contains(local.approved_types, core::try(attrs.volume_type, ""))
}Note: For prefix/suffix checking, prefer core::startswith() / core::endswith() over core::regex(). Use core::split() with core::parseint() for numeric string decomposition (e.g. port-range strings like "80-443").
List Functions
# Check if list contains value
core::contains(list, value)
# Example: core::contains(["dev", "staging", "prod"], attrs.environment)
# Get collection or string length (works on strings, lists, sets, maps!)
core::length(list_or_string_or_map)
# Example: core::length(local.violations) == 0
# Example: core::length(attrs.description) > 0 # String length!
# Example: core::length(attrs.tags) > 0 # Map key count
# Get map keys as list
core::keys(map)
# Example: core::keys(attrs.tags)
# Example: core::contains(core::keys(attrs.tags), "Environment")
# Check if any element is true — tfpolicy 0.3.0+: core::anytrue(list_of_booleans)
# On < 0.3.0: core::length([for b in list_of_booleans : b if b]) > 0
# Check if all elements are true — tfpolicy 0.3.0+: core::alltrue(list_of_booleans)
# On < 0.3.0: core::length([for b in list_of_booleans : b if !b]) == 0Safe Access
# Try expression with fallback
core::try(expression, default_value)
# Example: core::try(attrs.encrypted, false)
# Example: core::try(meta.version, "0.0.0")⚠️ CRITICAL: Cannot Check Attribute Existence Without try()
Direct attribute access fails when attributes don't exist, even with null checks:
# ❌ WRONG - Crashes with "This object does not have an attribute named 'region'"
has_region = attrs.region != null
# ✅ CORRECT - Two-step safe access pattern
region_value = core::try(attrs.region, null)
has_region = local.region_value != nullWhy: Terraform Policy cannot test attribute existence before accessing (no "attr" in attrs syntax). Always use core::try() first, then check the result.
Semantic Versioning
# Compare version against constraint
core::semverconstraint(version, constraint_string)
# Example: core::semverconstraint(meta.version, ">= 4.0.0, < 5.0.0")Resource Queries
# Query related resources
core::getresources(resource_type, filter_map)
# filter_map is REQUIRED. Pass {} to match everything, or { attr = value }
# for equality filtering. Caveat: candidates with unknown target
# attributes at plan time (e.g. references to to-be-created resource IDs)
# are conservatively included regardless of the filter value.
# Example: core::getresources("aws_security_group_rule", {})⚠️ Filter caveat: core::getresources(type, { attr = value }) performs equality matching, but candidates whose target attribute is unknown at plan time (e.g. bucket = aws_s3_bucket.x.id for a resource being created in the same plan) are conservatively included. On first-time-create plans with cross-references you'll get every candidate back. Most reliable on update plans against existing infrastructure. See reference/verified-syntax.md.
CRITICAL: core::getresources() Attribute Access
Resources returned by core::getresources() have attributes at the top level (NOT through .attrs):
locals {
all_roles = core::getresources("aws_iam_role", {})
}
# ✅ CORRECT - Access attributes directly
role_names = [for role in local.all_roles : role.name]
filtered = [for role in local.all_roles : role if role.path == "/service/"]
# ❌ WRONG - Do NOT use .attrs
role_names = [for role in local.all_roles : role.attrs.name] # ERROR!Why: This is DIFFERENT from current resource context where you use attrs.name. Returned resources have a different structure.
Pattern for Cross-Resource Validation:
resource_policy "aws_iam_role" "check" {
locals {
role_attachments = core::getresources("aws_iam_role_policy_attachment", {
role = attrs.name # filter depends on current resource — must be inline
})
has_attachment = core::length(local.role_attachments) > 0
}
}Semantic Versioning
Constraint Operators
| Operator | Meaning | Example | Matches |
|---|---|---|---|
= |
Exact version | "= 4.67.0" |
4.67.0 only |
!= |
Not equal | "!= 4.50.0" |
Any except 4.50.0 |
> |
Greater than | "> 4.0.0" |
4.0.1, 4.1.0, 5.0.0, etc. |
>= |
Greater or equal | ">= 4.0.0" |
4.0.0, 4.0.1, 5.0.0, etc. |
< |
Less than | "< 5.0.0" |
4.99.99, 3.0.0, etc. |
<= |
Less or equal | "<= 5.0.0" |
5.0.0, 4.99.99, etc. |
~> |
Pessimistic (patch) | "~> 4.67.0" |
>= 4.67.0, < 4.68.0 |
~> |
Pessimistic (minor) | "~> 4.0" |
>= 4.0.0, < 5.0.0 |
Multiple Constraints (AND logic)
# Both constraints must be satisfied
core::semverconstraint(meta.version, ">= 4.0.0, < 5.0.0")
core::semverconstraint(meta.version, ">= 4.0.0, != 4.50.0")OR Logic
locals {
# Version 4.x OR 5.x allowed
version_ok = core::semverconstraint(meta.version, "~> 4.0") ||
core::semverconstraint(meta.version, "~> 5.0")
}Version Allowlist Pattern
locals {
allowed_versions = ["3.75.0", "3.80.0", "3.85.0"]
# Check if current version matches any allowed version
version_checks = [
for v in local.allowed_versions :
core::semverconstraint(meta.version, "= ${v}")
]
is_allowed = core::length([for b in local.version_checks : b if b]) > 0
}
enforce {
condition = local.is_allowed
error_message = "Version ${meta.version} not approved. Allowed: ${core::join(", ", local.allowed_versions)}"
}Next: Advanced Patterns & Best Practices (tfpolicy-author.md)
Purpose: Common patterns for writing Terraform policies Status: All patterns verified during private beta (Updated: 2026-02-24)
Pattern 1: Required Attribute
resource_policy "aws_s3_bucket" "require_encryption" {
enforce {
condition = attrs.server_side_encryption_configuration != null
error_message = "S3 buckets must have encryption configured"
}
}Pattern 2: Attribute Must Match Value
resource_policy "aws_ebs_volume" "encryption" {
enforce {
condition = core::try(attrs.encrypted, false) == true
error_message = "EBS volumes must be encrypted"
}
}Pattern 3: Allowlist Check
resource_policy "aws_instance" "instance_type" {
locals {
allowed_types = ["t3.micro", "t3.small", "t3.medium"]
is_allowed = core::contains(local.allowed_types, attrs.instance_type)
}
enforce {
condition = local.is_allowed
error_message = "Instance type ${attrs.instance_type} not allowed. Use: ${core::join(", ", local.allowed_types)}"
}
}Pattern 4: Tag Validation
resource_policy "*" "required_tags" {
filter = attrs.tags != null
locals {
required_tags = ["Environment", "Owner", "CostCenter"]
# Count tags that are MISSING; if zero, all required tags are present
missing_tags = [
for tag in local.required_tags :
tag if !core::contains(core::keys(attrs.tags), tag)
]
has_all_tags = core::length(local.missing_tags) == 0
}
enforce {
condition = local.has_all_tags
error_message = "Resources are missing required tags: ${core::join(", ", local.required_tags)}"
}
}Pattern 5: Module Source Restriction
module_policy "*" "approved_sources" {
filter = meta.source != null
locals {
# Exact allowlist: use core::contains() for explicit full-source matching.
# For prefix matching use core::startswith(), for pattern matching use core::regex().
approved_sources = [
"app.terraform.io/myorg/vpc/aws",
"app.terraform.io/myorg/database/aws",
"app.terraform.io/myorg/network/aws",
"registry.terraform.io/hashicorp/vpc",
"registry.terraform.io/hashicorp/s3-bucket"
]
is_approved = core::contains(local.approved_sources, meta.source)
}
enforce {
condition = local.is_approved
error_message = "Module source not approved: ${meta.source}. Must be one of the explicitly allowed modules."
}
}Note: core::contains() only supports exact full-string matching. For prefix or namespace matching, use core::regex():
# Prefix matching with core::regex() — matches any source under the approved namespace
locals {
is_approved_namespace = core::try(core::regex("^app\\.terraform\\.io/myorg/", meta.source), null) != null
}
enforce {
condition = local.is_approved_namespace
error_message = "Module source must be from app.terraform.io/myorg/ namespace. Got: ${meta.source}"
}Pattern 6: Provider Version Range
provider_policy "aws" "version_range" {
locals {
min_version = "4.0.0"
max_version = "5.0.0"
version_ok = core::semverconstraint(meta.version, ">= ${local.min_version}, < ${local.max_version}")
}
enforce {
condition = local.version_ok
error_message = "AWS provider version ${meta.version} outside allowed range: >= ${local.min_version}, < ${local.max_version}"
}
}Pattern 7: Conditional Enforcement
resource_policy "aws_s3_bucket" "conditional_encryption" {
locals {
# Only enforce encryption for production buckets
is_production = core::contains(core::keys(attrs.tags), "Environment") &&
attrs.tags["Environment"] == "production"
has_encryption = attrs.server_side_encryption_configuration != null
}
enforce {
# Skip check for non-production or enforce for production
condition = !local.is_production || local.has_encryption
error_message = "Production S3 buckets must have encryption enabled"
}
}Pattern 8: Multiple Checks with Detailed Errors
resource_policy "aws_security_group" "security_checks" {
locals {
# tfpolicy 0.3.0+: core::anytrue([for rule in core::try(attrs.ingress, []) : (rule.from_port == 22 && core::contains(core::try(rule.cidr_blocks, []), "0.0.0.0/0"))]) works too.
# On < 0.3.0, use core::length() instead:
# Filter to SSH rules from internet; if list is non-empty, there's a violation
ssh_from_internet = [
for rule in core::try(attrs.ingress, []) :
rule if (rule.from_port == 22 && core::contains(core::try(rule.cidr_blocks, []), "0.0.0.0/0"))
]
has_ssh_ingress = core::length(local.ssh_from_internet) > 0
# Check for proper description
has_description = attrs.description != null && core::length(attrs.description) > 0
}
enforce {
condition = !local.has_ssh_ingress
error_message = "Security groups must not allow SSH from the internet (0.0.0.0/0)"
}
enforce {
condition = local.has_description
error_message = "Security groups must have a description"
}
}Pattern 9: Provider Configuration Policies
Validate provider configuration attributes and blocks:
# Check provider region
provider_policy "aws" "approved_regions" {
locals {
aws_region = core::try(attrs.region, "")
allowed_regions = ["us-east-1", "us-west-2", "eu-west-1"]
}
enforce {
condition = core::contains(local.allowed_regions, local.aws_region)
error_message = "AWS provider must use approved region. Got: ${local.aws_region}"
}
}
# Check provider blocks (need [0] index)
provider_policy "aws" "enforce_default_tags" {
locals {
# Provider blocks are lists
default_tags = core::try(attrs.default_tags, [])
has_default_tags = core::length(local.default_tags) > 0
# Access block attributes with [0] index
tags = local.has_default_tags ?
core::try(local.default_tags[0].tags, {}) : {}
required_tags = ["Environment", "Owner", "CostCenter"]
tag_keys = core::keys(local.tags)
# tfpolicy 0.3.0+: core::alltrue([for tag in local.required_tags : core::contains(local.tag_keys, tag)]) works too.
# On < 0.3.0, use core::length() instead:
# Count required tags that are MISSING; if zero, all required tags are present
missing_required_tags = [
for tag in local.required_tags :
tag if !core::contains(local.tag_keys, tag)
]
has_all_required = core::length(local.missing_required_tags) == 0
}
enforce {
condition = local.has_default_tags
error_message = "AWS provider must configure default_tags block"
}
enforce {
condition = local.has_all_required
error_message = "AWS provider default_tags must include: ${core::join(", ", local.required_tags)}"
}
}
# Prevent hardcoded credentials
provider_policy "aws" "no_hardcoded_credentials" {
locals {
has_access_key = attrs.access_key != null
has_secret_key = attrs.secret_key != null
}
enforce {
condition = !local.has_access_key && !local.has_secret_key
error_message = "AWS provider must NOT use hardcoded credentials (access_key/secret_key). Use environment variables or IAM roles instead."
}
}Key points:
attrs.*provides access to provider configuration- Provider blocks (default_tags, assume_role) require
[0]index - Version checking uses
meta.versionwithcore::semverconstraint() - Security checks (no hardcoded credentials) are important
Pattern 10: Cross-Resource Enforcement
Enforce that one resource type has a corresponding companion resource:
aws_s3_bucket_server_side_encryption_configuration is a dependent child of aws_s3_bucket — its bucket argument always references attrs.id. The filter value is derived from attrs.*, so the top-level cache pattern is prohibited (Mistake 13 in verified-syntax.md). Always use the inline filter pattern for S3 companion resources.
# NOTE: This policy contains a cross-resource reference that will not resolve
# during plan time, but the policy will run successfully during apply time.
resource_policy "aws_s3_bucket" "require_encryption_config" {
locals {
sse_configs = core::getresources("aws_s3_bucket_server_side_encryption_configuration", {
bucket = attrs.id # filter derived from current resource's own attr — must be inline
})
has_sse_config = core::length(local.sse_configs) > 0
# rule is a Set — convert to list before indexing; guard first
sse_rules = local.has_sse_config ? core::try([for r in local.sse_configs[0].rule : r], []) : []
has_sse_rule = core::length(local.sse_rules) > 0
sse_apply_block = local.has_sse_rule ? core::try([for a in local.sse_rules[0].apply_server_side_encryption_by_default : a], []) : []
has_apply_block = core::length(local.sse_apply_block) > 0
sse_algorithm = local.has_apply_block ? core::try(local.sse_apply_block[0].sse_algorithm, "") : ""
}
enforcement_level = "advisory"
enforce {
condition = local.has_sse_config
error_message = "S3 bucket must have an aws_s3_bucket_server_side_encryption_configuration resource."
}
enforce {
condition = local.sse_algorithm == "aws:kms"
error_message = "S3 bucket encryption must use aws:kms (found: ${local.sse_algorithm != "" ? local.sse_algorithm : "(none configured)"})"
}
}Key points:
- The filter value (
attrs.id) is the current resource's own attribute — a top-level cache is impossible; use the inline call - Both checks (presence and KMS algorithm) live in one
resource_policyblock with multipleenforceblocks — never split checks on the same resource type into two blocks (SKILL.md Output Structure Rule 1) - Always include the apply-time
# NOTE:comment
Pattern 11: Resource Count Limits
Limit the total number of resources of a specific type:
locals {
all_nat_gateways = core::getresources("aws_nat_gateway", {})
nat_gateway_count = core::length(local.all_nat_gateways)
max_allowed = 3
}
resource_policy "aws_nat_gateway" "limit_count" {
enforce {
condition = local.nat_gateway_count <= local.max_allowed
error_message = "Maximum ${local.max_allowed} NAT gateways allowed (found: ${local.nat_gateway_count})"
}
}Key points:
- Policy runs for each resource but references global count
- All resources will fail if limit is exceeded
- Use top-level locals to count once
Pattern 12: Cross-Resource Attribute Validation
Validate that one resource's attribute matches another resource's attribute:
resource_policy "aws_subnet" "vpc_tag_match" {
filter = attrs.vpc_id != null
locals {
# NOTE: This policy contains a cross-resource reference that will not resolve
# during plan time, but the policy will run successfully during apply time.
matching_vpcs = core::getresources("aws_vpc", { id = attrs.vpc_id })
vpc = core::length(local.matching_vpcs) > 0 ? local.matching_vpcs[0] : null
vpc_env_tag = core::try(local.vpc.tags["Environment"], "")
subnet_env_tag = core::try(attrs.tags["Environment"], "")
tags_match = local.vpc_env_tag == local.subnet_env_tag
}
enforce {
condition = local.tags_match
error_message = "Subnet Environment tag (${local.subnet_env_tag}) must match VPC Environment tag (${local.vpc_env_tag})"
}
}Key points:
aws_vpc.idis the linking attribute referenced byattrs.vpc_id— this is an attrs.*-derived key so the top-level cache + map-index pattern is prohibited (Mistake 13 in verified-syntax.md); always use inlinecore::getresources- Use
core::try()for safe attribute access - Check both existence and value matching
Pattern 13: Sentinel Conversion - DMS Endpoint SSL Mode
Source policy: HashiCorp PCI DSS library - dms-endpoints-should-use-ssl.sentinel
Conversion quality: Perfect
resource_policy "aws_dms_endpoint" "require_ssl_mode" {
locals {
ssl_mode = core::try(attrs.ssl_mode, "")
valid_ssl_modes = ["require", "verify-ca", "verify-full"]
}
enforce {
condition = core::contains(local.valid_ssl_modes, local.ssl_mode)
error_message = "DMS endpoints must set ssl_mode to one of: require, verify-ca, verify-full"
}
}Why this converts cleanly:
- Single resource type
- Direct attribute check on planned values
- No cross-resource dependency or reference metadata
- Sentinel
collection.reject()becomes one focusedenforcecondition
Pattern 14: Sentinel Conversion - Elasticsearch HTTPS Required
Source policy: HashiCorp PCI DSS library - elasticsearch-https-required.sentinel
Conversion quality: Good
resource_policy "aws_elasticsearch_domain" "https_required" {
locals {
endpoint_options = core::try(attrs.domain_endpoint_options, [])
endpoint_options_present = core::length(local.endpoint_options) > 0
enforce_https = core::try(local.endpoint_options[0].enforce_https, false)
tls_security_policy = core::try(local.endpoint_options[0].tls_security_policy, "")
}
enforce {
condition = local.endpoint_options_present
error_message = "Elasticsearch domains must define domain_endpoint_options"
}
enforce {
condition = local.enforce_https == true
error_message = "Elasticsearch domains must set domain_endpoint_options.enforce_https = true"
}
enforce {
condition = local.tls_security_policy == "Policy-Min-TLS-1-2-PFS-2023-10"
error_message = "Elasticsearch domains must use tls_security_policy 'Policy-Min-TLS-1-2-PFS-2023-10'"
}
}Why this is Good instead of Perfect:
- The Sentinel policy uses helper functions and nested map lookups; tfpolicy rewrites that logic into direct block access with
core::try() - The enforcement intent is preserved, but the structure is idiomatic tfpolicy rather than one-to-one
Pattern 15: Sentinel Conversion - EventBridge Bus Must Have Attached Policy
Source policy: HashiCorp PCI DSS library - eventbridge-custom-event-bus-should-have-attached-policy.sentinel
Conversion quality: Limited
locals {
all_event_bus_policies = core::getresources("aws_cloudwatch_event_bus_policy", {})
event_bus_policy_map = {
for policy in local.all_event_bus_policies :
policy.event_bus_name => true
}
}
resource_policy "aws_cloudwatch_event_bus" "require_attached_policy" {
locals {
bus_name = core::try(attrs.name, "")
has_attached_policy = core::try(local.event_bus_policy_map[local.bus_name], false)
}
enforce {
condition = local.has_attached_policy
error_message = "EventBridge buses must have a matching aws_cloudwatch_event_bus_policy resource"
}
}Why this is only Limited:
- This relies on value matching through
core::getresources(), not Terraform graph/reference metadata - It works best when
event_bus_nameis explicit and already resolved - New resources with unresolved references can produce different behavior from Sentinel or fail to match on first creation
Previous: Quick Start Guide (tfpolicy-author.md) Back to: Main README (../README.md)
references/tfpolicy-test.md
name: tfpolicy-test
description: Expert agent for testing Terraform policies. Helps write and debug `.policytest.hcl` files, design resource mocks (`attrs` / `prior_attrs`), reason about runner behavior, and verify policy correctness before promotion to enforcement.
license: MPL-2.0
metadata:
copyright: Copyright IBM Corp. 2026
version: "0.1.0"
tfpolicy-test
Description
Expert agent for testing Terraform policies. Helps write .policytest.hcl files, design resource and module mocks, use expect_failure correctly, mock cross-resource lookups for core::getresources(), and reason about current tfpolicy test runner behavior.
Use When
- The user has an existing policy and wants to write or improve tests for it.
- The user is debugging a failing or unexpectedly-passing test, or asking why a mock behaves the way it does.
- The user is writing
.policytest.hclfiles,policytest { targets = [...] }blocks,resource {}/module {}mocks, or usingexpect_failure/skip. - The user is testing operation-aware policies and needs to mock
attrsand/orprior_attrsfor create/update/delete scenarios. - The user is asking how to mock cross-resource lookups (e.g.
aws_s3_bucket_versioningforcore::getresourcespatterns). - The user is investigating a runner caveat (mocks evaluated regardless of
operationsscope,expect_failurenot supported ondatablocks, etc.).
Do not use this skill when:
- The user is writing the policy itself rather than its test — use
tfpolicy-author(tfpolicy-author.md). - The user is converting a Sentinel test to a
.policytest.hcltest — start withtfpolicy-author(tfpolicy-author.md), then return here for test-side refinements.
Capabilities
1. Write .policytest.hcl Files from Existing Policies
Generate a focused test file that exercises the passing and failing paths of a policy, including expect_failure = true cases and any required prior_attrs mocks. For policies that use input blocks, generate separate test files per input scenario using inputs {} (plural) to override default values.
2. Design Mocks for Cross-Resource Lookups
Build the resource {} blocks needed for core::getresources() filters to match correctly (parent + child resources, skip = true on lookup-only resources, etc.).
3. Diagnose Runner Behavior
Explain why a mock fails, passes, or crashes. Cover the current caveats: expect_failure is rejected on data blocks, omitted attributes crash unless wrapped in core::try(), and replacement operations must be represented by separate create and delete policy blocks.
4. Recommend Test Organization
Decide when to split into multiple .policytest.hcl files (per-policy targeting) versus consolidating, and how to keep mocks aligned with the policy's actual evaluation target.
5. Generate Edge-Case Test Scenarios
For every generated test file, mandate test cases covering:
- Missing attribute — resource mock where the attribute is entirely omitted (most common real-world gap; crashes policies that don't use
core::try()) - Empty collection — resource with an empty list
[]or empty map{}where the policy expects a non-empty value - Boundary conditions — exact threshold values (e.g. port at limit, count at max)
- Null attribute — resource where the checked attribute is explicitly set to
null. ⚠️ This case must only be included when the rules below permit it — do not add it unconditionally.
Every generated .policytest.hcl must include at least one expect_failure = true resource that exercises a missing-attribute scenario.
Before finalizing each test case, verify polarity against the policy's condition. For each expect_failure = true case, confirm the condition evaluates to false for that mock. For each pass case (no expect_failure), confirm it evaluates to true. A fail case the policy actually passes produces Missing expected failure; a pass case the policy actually fails produces an unexpected violation.
🔴 Null test case decision rules:
Key fact:
core::try(attrs.field, default)triggers the fallback only when the attribute key is absent. When a mock explicitly setsattrs.field = null,core::tryreturnsnull— not the default. This asymmetry means anullcase and an omitted-attribute case exercise different code paths.
For every attribute you are about to set to null in a test mock — regardless of attribute name, nesting level, or resource type — apply this check before adding the case:
- Two-step pattern (
raw = core::try(attrs.field, null)thenval = raw != null ? raw : []) —nullis explicitly normalized to a safe default. Add apasscase (noexpect_failure) with the attribute set tonullto verify this normalization works. - Explicit non-compliance check (
condition = val != null && val != "") —nullis intentionally treated as non-compliant. Add afailcase (expect_failure = true) with the attribute set tonull. - Single-step only (
val = core::try(attrs.field, [])) without an explicit null guard —nullis not normalized and will crash downstream expressions (for val in null,core::length(null)). Do NOT add anullcase. Fix the policy to use the two-step pattern instead.
Never add fail_*_null when the policy treats null the same as the safe default (false / []). A null case that the policy silently normalizes to compliant produces a Missing expected failure error — caused by the test itself, not a real policy bug.
The three rules above apply at every nesting level. For a scalar accessed as core::try(local.list[0].attr, default), apply the same single-step / two-step determination at that specific access point.
6. Mock Cross-Resource Lookups for core::getresources()
When the policy under test uses core::getresources(resource_type, filter), the test runner resolves the lookup against the resources declared in the same .policytest.hcl file. For the lookup to return the expected results:
- Filter attribute values must match exactly. The companion resource mock must declare the linking attribute with the exact value that equals
attrs.<linking_attr>of the parent resource at evaluation time. Resource names in the mock do not affect matching — only attribute values do. - Use
skip = trueon companion resources. Resources that should be visible tocore::getresources()but must not be evaluated directly by the policy should be declared withskip = true. Without this, the runner evaluates them as standalone resources, which may produce unexpected results. - Test the no-match case explicitly. Include a test case where the companion resource is absent or has a non-matching filter attribute value. In this case
core::getresources()returns an empty list — verify that the policy handles this as intended (e.g. treats the parent as non-compliant if a companion is required, or compliant if companion is optional). - Test the match case explicitly. Include a test case where the companion resource is present with matching attributes to confirm the lookup resolves correctly.
filteron the policy affects ALL resources in the test file when companions are present. When the policy uses a top-levelcore::getresources()-basedfilter(e.g.filter = core::length(local.all_companions) > 0), every parent resource in the test file is evaluated as soon as any companion resource is declared in that file. An unlinked parent (is_linked = false) then failsis_linked && check_attr == VALUE→ violation — even ifcheck_attrhas the correct value. Do NOT include a "pass because not linked" case in the same file as companion resources. Omit it entirely; the meaningful cases are: (a) linked + correct attr → pass, (b) linked + wrong attr → fail. If the policy usesfilter = local.has_qualifying(qualifying companions only), unlinked parents are not evaluated when no qualifying companions exist — but they still get evaluated when qualifying companions are present, so the same rule applies: no "unlinked pass" case in a file that also declares companion resources.
7. Enforce Explicit policytest { targets } Blocks
Best practice: every generated .policytest.hcl should include an explicit policytest { targets = ["<policy-file>.policy.hcl"] } block when multiple policies exist in the same directory. Without it, tfpolicy test evaluates the mock against every policy in the directory — causing a test written for one policy to run against a different policy, producing misleading pass/fail results.
8. Verify Default Values Match the Source Intent
When generating test cases for policies that use core::try(attr, default), confirm that the default value in the policy matches the intended behavior for absent or null attributes. A wrong default silently passes resources that should fail. For each boolean flag or enum attribute, add a comment in the test explaining the expected behavior when the attribute is omitted versus explicitly set to null.
Knowledge Base
The bulk of this skill is the testing guide:
Cross-cutting facts shared with the sibling skills live in:
verified-syntax.md(verified-syntax.md) — verified Terraform Policy syntax, function names, runtime limitations. Anything in conflict with the testing guide should defer to this file.
See Also
tfpolicy-author(tfpolicy-author.md) — write the policy under test.tfpolicy-author(tfpolicy-author.md) — migrate Sentinel tests alongside the policies.
Table of Contents
- Testing Basics (#testing-basics)
- Resource Policy Testing (#resource-policy-testing)
- Module Policy Testing (#module-policy-testing)
- Provider Policy Testing (#provider-policy-testing)
- Advanced Techniques (#advanced-techniques)
- Best Practices (#best-practices)
Testing Basics
Test File Structure
# tfpolicy 0.3.0+: the same policy { required_providers { ... } } block already
# required in the target .policy.hcl is what tfpolicy test uses to resolve
# provider schemas for preflighting the mocks below. .policytest.hcl itself
# does NOT declare its own required_providers block.
policytest {
targets = ["policy-file-1.policy.hcl", "policy-file-2.policy.hcl"]
}
# Mock resources with expected outcomes
resource "aws_s3_bucket" "passing_bucket" {
attrs = {
bucket = "my-secure-bucket"
versioning = [{ enabled = true }]
}
}
resource "aws_s3_bucket" "failing_bucket" {
expect_failure = true
attrs = {
bucket = "my-insecure-bucket"
versioning = [{ enabled = false }]
}
}Full test file structure with all supported blocks:
policytest {
targets = ["policy-file.policy.hcl"]
}
# Override input defaults for this test file
inputs {
port = 90
}
resource "aws_security_group_rule" "should_fail_port_90" {
expect_failure = true
attrs = {
type = "ingress"
from_port = 80
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
}Key Points:
required_providerslives only in.policy.hcl, not.policytest.hcl. There is nopolicytest { required_providers { ... } }block. On tfpolicy 0.3.0+, the existing top-levelpolicy { required_providers { ... } }block in the targeted.policy.hclfile (see tfpolicy-author (tfpolicy-author.md#required-policyrequired_providers-block)) is reused bytfpolicy testto resolve provider schemas and preflight theattrs/prior_attrsvalues declared in this test file — you do not declare it a second time in the test file itself.- Best practice: include
policytest { targets = ["<policy-file>.policy.hcl"] }when multiple policies exist in the same directory. Without it,tfpolicy testevaluates the mock against every policy in the directory — a test resource intended for one policy will silently run against others, producing misleading results.expect_failure = trueon such a resource can pass for the wrong reason. expect_failure = trueapplies to ALL policies evaluating the resourceexpect_failureis ONLY valid onresource {}blocks — using it ondata {}blocks causesUnsupported argumenterror
⚠️ Critical:
tfpolicy testdoes NOT evaluateerror_message
tfpolicy testonly evaluates theconditionexpression. It never evaluates or interpolates theerror_messagestring. This means:
- A policy with
error_message = "Failed: ${meta.address}"will pass alltfpolicy testruns even thoughmeta.addressis UNDEFINED and will crash every resource at runtime.- Only
terraform plan --policies=evaluateserror_message— always validate generated policies against a real Terraform plan to catch this class of bug.
- CRITICAL: Omitted attributes cause evaluation errors if accessed directly. Always use
core::try()to handle missing attributes
Test Execution Behavior
- Multiple policies evaluate same resources - All matching policies run against all matching test resources
- Tests continue on failure - All tests run to completion, not stopping at first failure
- Exit codes for CI/CD:
- Exit 0: All tests pass (including expected failures)
- Exit 1: Unexpected failures or errors
Schema preflight validation (tfpolicy 0.3.0+): tfpolicy test now reuses the same provider-resolution path as tfpolicy validate. Before any test executes, provider, resource, and data-source policy inputs and every attrs/prior_attrs value in every test file — including mocks belonging to skip = true cases — are preflighted against the provider schemas resolved from the target .policy.hcl's top-level policy { required_providers { ... } } block. The same schemas validate arguments used by core::getdatasource() and core::getresources(). A mocked attribute that doesn't exist in the schema, or has the wrong type, fails preflight with a diagnostic pointing at the offending value, and no tests in that run execute — this is a hard stop, not a per-case warning. Fixtures that conform to the schema run unchanged, and the exact preflighted values (not a re-parsed copy) are what policies evaluate.
Pre-0.3.0 schema-verification caveat (superseded by preflight validation above on 0.3.0+):
tfpolicy validate --policies=...performs provider schema acquisition and validates resource types against the providers declared inpolicy.required_providers.tfpolicy test --policies=... --tests=...can still emit:Warning: Resource types not verified against provider schemaseven whenvalidatesucceeded separately.- Interpret this as a test-runner limitation/caveat, not necessarily a policy failure. The test runner can execute mocks structurally while warning that resource-type verification was not enforced in that execution path.
- Do not rely on
tfpolicy testalone to catch misspelled or unknown resource types. Always runtfpolicy validateas a separate step before or alongside tests. - In current behavior, this warning may still produce a non-zero process exit code from
tfpolicy test, even when all individual test cases showpass.
CI/CD Usage:
tfpolicy validate --policies=./policies
tfpolicy test --policies=./policies --tests=./tests
if [ $? -eq 0 ]; then echo "Passed"; else echo "Failed"; exit 1; fiResource Policy Testing
Basic Syntax
resource "resource_type" "test_name" {
expect_failure = true/false # Optional — ONLY valid on resource blocks, NOT data blocks
skip = true/false # Optional
attrs = {
# All resource attributes
}
}⚠️
expect_failureis NOT supported ondata {}blocks. Using it on a data block causesUnsupported argument "expect_failure". Only mockresource {}blocks support this attribute.
Every resource {} Mock Must Declare a Non-Empty State Block (tfpolicy 0.3.0+)
On tfpolicy 0.3.0+: every mock resource {} block must declare attrs or prior_attrs. If both evaluate to empty for a given resource block, tfpolicy skips that test case because no operation can be inferred. Use null for "attribute not set", e.g. attrs = { instance_type = null }, not an empty block. meta.operation alone is not a substitute — it still requires an accompanying state block. On tfpolicy < 0.3.0, meta-only fixtures are still accepted but silently no-op (see below).
Scope: resource {} blocks only. This requirement applies exclusively to mock resource {} blocks (resource_policy tests). provider {} and module {} mocks are unaffected — meta-only fixtures (e.g. meta = { source = "...", version = "..." } with no attrs) continue to work as before for module_policy and provider_policy tests, since those policy types don't infer a create/update/delete operation from resource state the way resource_policy does.
An empty-state mock reports skipped with a warning rather than pass, and expect_failure = true fails on a skipped case because a skip asserts nothing.
Upgrading older fixtures: find resource {} blocks with neither attrs nor prior_attrs, or only empty {} ones, and add at least one attribute (null where you mean "unset").
Omitted Attributes Behavior
CRITICAL: Attributes accessed by policies MUST be provided in test mocks or wrapped with core::try().
Direct Access (Causes Error):
# Policy
resource_policy "aws_ebs_volume" "check" {
enforce {
condition = attrs.encrypted == true # Direct access
}
}
# Test - ERROR if encrypted is omitted
resource "aws_ebs_volume" "test" {
attrs = {
size = 100
# encrypted omitted - causes "This object does not have an attribute named 'encrypted'"
}
}Safe Access with core::try():
# Policy
resource_policy "aws_ebs_volume" "check" {
locals {
encrypted = core::try(attrs.encrypted, false) # Safe access
}
enforce {
condition = local.encrypted == true
}
}
# Test - Works even if encrypted is omitted
resource "aws_ebs_volume" "test" {
attrs = {
size = 100
# encrypted omitted - core::try() returns false (default value)
}
}Rule: Only attributes NOT accessed by the policy can be safely omitted. All accessed attributes must either:
- Be provided in the mock's
attrs = {}block, OR - Be accessed via
core::try()in the policy
Testing Operation-Aware Policies
Policies can scope themselves to specific plan operations via operations = ["create", "update", "delete"]. Test mocks support a matching prior_attrs = { ... } block alongside attrs = { ... }, so create, update, and delete-gate policies are all fully testable.
Mock shape per operation:
| Operation being tested | Provide attrs |
Provide prior_attrs |
|---|---|---|
create |
✅ planned values | — |
update |
✅ planned values | ✅ pre-change values |
delete |
— | ✅ pre-change values |
Create / update policy (planned values only):
# Policy
resource_policy "tfe_workspace" "require_project" {
operations = ["create", "update"]
enforce {
condition = core::try(attrs.project_id, "") != ""
error_message = "tfe_workspace must have project_id set."
}
}# Test
policytest { targets = ["workspace-require-project.policy.hcl"] }
resource "tfe_workspace" "with_project" {
attrs = { project_id = "prj-123" }
}
resource "tfe_workspace" "missing_project" {
expect_failure = true
attrs = {}
}Update policy (reads both attrs and prior_attrs):
# Policy — block downgrades
resource_policy "tfe_workspace" "no_downgrade" {
operations = ["update"]
enforce {
condition = core::try(attrs.terraform_version, "") == core::try(prior_attrs.terraform_version, "")
|| core::try(attrs.terraform_version, "") > core::try(prior_attrs.terraform_version, "")
error_message = "terraform_version downgrade is not allowed."
}
}# Test
policytest { targets = ["workspace-no-downgrade.policy.hcl"] }
resource "tfe_workspace" "upgrade_ok" {
attrs = { terraform_version = "1.10.0" }
prior_attrs = { terraform_version = "1.9.0" }
}
resource "tfe_workspace" "downgrade_blocked" {
expect_failure = true
attrs = { terraform_version = "1.5.0" }
prior_attrs = { terraform_version = "1.9.0" }
}Delete-gate policy (pre-change state only):
# Policy
resource_policy "tfe_workspace" "deny_delete_without_tag" {
operations = ["delete"]
locals {
prior_tag_names = core::try(prior_attrs.tag_names, [])
}
enforce {
condition = core::contains(local.prior_tag_names, "delete")
error_message = "Add 'delete' tag before destroying a workspace."
}
}# Test
policytest { targets = ["workspace-deny-delete-without-tag.policy.hcl"] }
resource "tfe_workspace" "has_delete_tag" {
prior_attrs = { tag_names = ["delete", "prod"] }
}
resource "tfe_workspace" "missing_delete_tag" {
expect_failure = true
prior_attrs = { tag_names = ["prod"] }
}Note: The runner currently evaluates each mock against every policy listed in
targetsregardless of the policy'soperationsscope. Keep your.policytest.hclfile targeted at a single policy (or a set of policies that share the same operation scope), and only supply theattrs/prior_attrsfields that policy actually reads.
Provider Schema Awareness
The structure of attrs = {} depends on provider schema. Consult provider docs to determine if attributes are blocks or direct values.
tfpolicy 0.3.0+: this is no longer just guidance —
tfpolicy testpreflights everyattrs/prior_attrsvalue against the schemas resolved fromrequired_providersbefore any test runs. An attribute that doesn't exist in the schema, or has the wrong shape/type (e.g. a block written as a map instead of a list-of-maps), fails preflight with a diagnostic and blocks the entire test run, not just the offending case.
Example - Blocks vs Attributes:
resource "aws_instance" "test" {
attrs = {
instance_type = "t2.micro" # Direct attribute
# Block (requires array of maps)
default_tags = [{
tags = {
Environment = "Production"
}
}]
}
}In test files: Use = for blocks (not {} syntax used in policy files)
Cross-Resource References
Reference other test resources within the SAME file using resource_type.name.attrs.attribute:
resource "aws_security_group" "app_sg" {
attrs = {
name = "app-security-group"
}
}
resource "aws_instance" "app_server" {
attrs = {
vpc_security_group_ids = [aws_security_group.app_sg.attrs.name]
}
}Limitation: References cannot span across test files.
The skip Attribute
Resources with skip = true:
- Are added to resource graph
- Are NOT evaluated by policies
- CAN be referenced by other resources
- ARE included in
core::getresources()results
resource "aws_ebs_volume" "available_for_reference" {
skip = true
attrs = {
volume_id = "vol-12345"
}
}
resource "aws_instance" "server" {
attrs = {
ebs_block_device = [{
volume_id = aws_ebs_volume.available_for_reference.attrs.volume_id
}]
}
}Use skip only when: Resource is referenced or needed in getresources() counts.
Testing Filters
If a resource doesn't match the filter, it's NOT evaluated (test passes):
# Policy with filter
resource_policy "aws_s3_bucket" {
filter = attrs.bucket_prefix == "secure-"
enforce {
condition = attrs.versioning[0].enabled == true
}
}
# Test - doesn't match filter, so passes
resource "aws_s3_bucket" "filtered_out" {
attrs = {
bucket_prefix = "public-" # Doesn't match filter
versioning = [{ enabled = false }]
}
}Best Practice: Test both resources that match and don't match the filter.
Resource Policy Meta Attributes
IMPORTANT: Meta attributes for resource_policy behave differently in mock tests vs real terraform plan evaluation.
Available Meta Attributes by Evaluation Mode:
| Meta Attribute | Mock Tests (tfpolicy test) |
Real Plans (terraform plan --policies=) |
|---|---|---|
meta.provider_type |
❌ UNDEFINED | ✅ Available (e.g., "aws", "azurerm") |
meta.tfe_stack.deployment_name |
✅ Available in tfpolicy 0.3.x+ mocks | ✅ Always present; empty outside Stack evaluations |
meta.tfe_stack.stack_name |
✅ Available in tfpolicy 0.3.x+ mocks | ✅ Always present; empty outside Stack evaluations |
meta.tfe_stack.deployment_group |
✅ Available in tfpolicy 0.3.x+ mocks | ✅ Always present; empty outside Stack evaluations |
meta.tfe_workspace.tags |
✅ Available in tfpolicy 0.3.x+ mocks | ✅ Available for workspace evaluations; empty for Stacks or untagged workspaces |
meta.type |
❌ UNDEFINED | ❌ UNDEFINED |
meta.address |
❌ UNDEFINED | ❌ UNDEFINED |
Example:
# Policy using meta.provider_type
resource_policy "aws_ebs_volume" "check_provider" {
enforce {
condition = core::try(meta.provider_type, "UNDEFINED") == "aws"
error_message = "Provider type: ${core::try(meta.provider_type, "UNDEFINED")}"
}
}Test behavior:
- With
tfpolicy test:meta.provider_typereturns UNDEFINED (test may fail) - With
terraform plan --policies=:meta.provider_typereturns "aws" (test passes)
Best Practice: When using meta.provider_type in policies, always wrap with core::try() and note that mock tests cannot fully validate this behavior. Test with real terraform plans for complete validation.
Stack and workspace metadata in tests (tfpolicy 0.3.x+): metadata can be mocked with meta.tfe_stack.* and meta.tfe_workspace.tags for resource, provider, and module policies. Stack fields remain empty outside Stack evaluations, so use them for stack-scoped exclusion workflows:
resource "null_resource" "excluded_deployment" {
attrs = {
triggers = null
}
meta = {
tfe_stack = {
deployment_name = "excluded-deployment"
stack_name = "payments"
deployment_group = "nonprod"
}
}
}Module Policy Testing
Module Test Syntax
module "source" "test_name" {
expect_failure = true/false # Optional
meta = {
source = "registry.terraform.io/namespace/name"
address = "module.name"
version = "1.0.0"
}
}Available meta attributes:
source- Module sourceaddress- Module address (e.g., "module.database")version- Module version
Note: Modules use meta only (no attrs)
Example: Module Source Allowlist
Policy:
locals {
allowed_sources = [
"registry.terraform.io/hashicorp/aws",
"registry.terraform.io/terraform-aws-modules/vpc/aws"
]
}
module_policy "*" "approved_sources" {
filter = meta.source != null
enforce {
condition = core::contains(local.allowed_sources, meta.source)
error_message = "Unauthorized module source: ${meta.source}"
}
}Test:
# Passing
module "registry.terraform.io/hashicorp/aws" "approved" {
meta = {
source = "registry.terraform.io/hashicorp/aws"
address = "module.database"
version = "1.0.0"
}
}
# Failing
module "registry.terraform.io/acme-corp/database" "unauthorized" {
expect_failure = true
meta = {
source = "registry.terraform.io/acme-corp/database"
address = "module.db"
version = "2.0.0"
}
}Example: Module Version Enforcement
Policy:
module_policy "registry.terraform.io/hashicorp/aws" "version_check" {
filter = meta.source == "registry.terraform.io/hashicorp/aws"
enforce {
condition = core::semverconstraint(meta.version, ">= 4.0.0")
error_message = "Module must use version >= 4.0.0, found ${meta.version}"
}
}Provider Policy Testing
Provider Test Syntax
provider "type" "test_name" {
expect_failure = true/false # Optional
meta = {
source = "registry.terraform.io/namespace/name"
}
}Available meta attributes:
source- Provider source (e.g., "registry.terraform.io/hashicorp/aws")
Note: Provider type (e.g., "aws") goes in block declaration, not meta.
Example: Provider Source Allowlist
Policy:
locals {
allowed_provider_sources = [
"registry.terraform.io/hashicorp/aws",
"registry.terraform.io/hashicorp/azurerm"
]
}
provider_policy "aws" {
enforce {
condition = core::contains(local.allowed_provider_sources, meta.source)
error_message = "Provider source '${meta.source}' is not approved"
}
}Test:
# Passing
provider "aws" "official" {
meta = {
source = "registry.terraform.io/hashicorp/aws"
}
}
# Failing
provider "aws" "unofficial" {
expect_failure = true
meta = {
source = "registry.terraform.io/acme-corp/aws"
}
}Common Provider Patterns
- Official providers only: Check
meta.source == "registry.terraform.io/hashicorp/aws" - Version constraints: Use
core::semverconstraint(meta.version, ">= 4.0.0, < 6.0.0") - Allowlist by type: Create separate provider_policy for each allowed type
Advanced Techniques
Data Source Mocking
data "aws_ami" "ubuntu" {
attrs = {
id = "ami-12345"
name = "ubuntu-20.04"
}
}
# Policy can reference it
resource_policy "aws_instance" {
enforce {
condition = attrs.ami == data.aws_ami.ubuntu.attrs.id
}
}Workspace Context Limitations
❌ Not Available: terraform.workspace or workspace context
Valid traversal roots:
input- Input variableslocal- Local variablesattrs- Resource/data source attributesmeta- Metadata
Workarounds:
- Use resource tags for environment-based logic
- Separate policy sets per environment in HCP Terraform
- CI/CD-level enforcement based on workspace name
- Tag-based validation
Testing Collections
resource "aws_security_group" "multiple_rules" {
attrs = {
ingress = [
{
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["10.0.0.0/8"]
},
{
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
]
}
}Testing Null/Missing Attributes
Only works if policy uses core::try():
# Policy must use core::try() to handle missing attributes
resource_policy "aws_s3_bucket" "check" {
locals {
encryption = core::try(attrs.server_side_encryption_configuration, null)
}
enforce {
condition = local.encryption != null
error_message = "Encryption required"
}
}
# Test - omitted attribute handled by core::try()
resource "aws_s3_bucket" "no_encryption" {
expect_failure = true
attrs = {
bucket = "my-bucket"
# server_side_encryption_configuration omitted - handled by core::try()
}
}Without core::try(), omitting accessed attributes causes evaluation errors.
Testing Policies with input Blocks
Policies that use input blocks can have their input values overridden per test file using an inputs {} block (plural). This allows you to test the policy behaviour under different configurations without changing the policy itself.
⚠️ The block is
inputs {}(plural) — usinginput {}(singular) throwsUnsupported block typeerror.
# Policy (test.policy.hcl)
input "port" {
type = number
default = 22
}
resource_policy "aws_security_group_rule" "no_open_ingress" {
filter = core::try(attrs.type, "") == "ingress"
locals {
covers_port = core::try(attrs.from_port <= input.port && attrs.to_port >= input.port, false)
}
enforce {
condition = !local.covers_port
error_message = "Ingress rule covers restricted port ${input.port}."
}
}# Test file 1: test with default port (22)
policytest {
targets = ["test.policy.hcl"]
}
# No inputs block — uses input.port default = 22
# PASS: port range 80-443 does not cover default port 22
resource "aws_security_group_rule" "pass_default_port" {
attrs = {
type = "ingress"
from_port = 80
to_port = 443
protocol = "tcp"
}
}
# FAIL: port range 1-1024 covers default port 22
resource "aws_security_group_rule" "fail_default_port" {
expect_failure = true
attrs = {
type = "ingress"
from_port = 1
to_port = 1024
protocol = "tcp"
}
}# Test file 2: test with custom port (90)
policytest {
targets = ["test.policy.hcl"]
}
inputs {
port = 90 # override default of 22
}
# FAIL: port range 80-443 DOES cover custom port 90
resource "aws_security_group_rule" "fail_custom_port" {
expect_failure = true
attrs = {
type = "ingress"
from_port = 80
to_port = 443
protocol = "tcp"
}
}Key points:
- Each test file can have its own
inputs {}block with different values — use separate.policytest.hclfiles per input scenario - When no
inputs {}block is present, the policy'sdefaultvalues are used - Always add a comment to each test file stating which input values it assumes — prevents confusion when the same resource mock produces different results under different inputs
- Note: input values can only be overridden at policy-set level in HCP Terraform for live enforcement —
inputs {}in test files is for test-time validation only
Every generated .policytest.hcl must include test cases for the following scenarios. Missing any of these is a test coverage gap:
| Scenario | Mock pattern | Why it matters |
|---|---|---|
| Missing attribute (omitted entirely) | attrs = { bucket = "x" } — target attribute not present |
Crashes policies that don't use core::try(); most common real-world gap |
| Null attribute | attrs = { ..., field = null } |
Tests core::try() default handling |
| Empty list | attrs = { ..., items = [] } |
Policies expecting non-empty collections must handle [] |
| Empty string | attrs = { ..., value = "" } |
String-check policies must not treat "" as compliant |
| Boundary value | Exact threshold (e.g. port = 443, count = max_allowed) | Off-by-one errors in range/count checks |
# ✅ Missing attribute — must fail (tests core::try() default)
resource "aws_s3_bucket" "missing_encryption" {
expect_failure = true
attrs = {
bucket = "test-bucket"
# server_side_encryption_configuration intentionally omitted
}
}
# ✅ Empty list — must fail
resource "aws_s3_bucket" "empty_encryption_rules" {
expect_failure = true
attrs = {
bucket = "test-bucket"
server_side_encryption_configuration = []
}
}Best Practices
General Guidelines
- Use descriptive names:
encrypted_volume_passesnottest1 - Organize by scenario: Group passing/failing tests with comments
- Test edge cases: Always include missing-attribute, null, empty-collection, and boundary-value scenarios — see Mandatory Edge-Case Checklist (#mandatory-edge-case-checklist) above
- Consult provider schemas: Match provider's block/attribute structure
Testing Strategy
- Separate concerns: One test file per policy file
- Use skip strategically: Only when resource is referenced or in getresources() counts
- Test both sides of filters: Resources that match and don't match
- Document complex references: Add comments explaining relationships
File Organization
policies/
├── cis-4.1-deny-public-ssh.policy.hcl
├── cis-4.1-deny-public-ssh.policytest.hcl
├── cis-4.2-deny-public-rdp.policy.hcl
└── cis-4.2-deny-public-rdp.policytest.hclQuick Reference Table
| Policy Type | Test Block | Available Attributes |
|---|---|---|
| resource_policy | resource "type" "name" { attrs = {...} } |
attrs.*, meta.provider_type (real plans only) |
| module_policy | module "source" "name" { meta = {...} } |
meta.source, meta.address, meta.version |
| provider_policy | provider "type" "name" { meta = {...} } |
meta.source |
| data source | data "type" "name" { attrs = {...} } |
attrs.* |
Common Features
| Feature | Syntax | Scope |
|---|---|---|
| Test target | policytest { targets = ["file.policy.hcl"] } |
Optional (best practice when multiple policies in dir) |
| Override input values | inputs { key = value } |
Per test file — uses policy default if omitted |
| Expected failure | expect_failure = true |
All policies |
| Skip evaluation | skip = true |
Resources only |
| Cross-resource ref | resource_type.name.attrs.attribute |
Same file only |
| Omitted attributes | Don't specify in attrs = {} |
Causes error unless policy uses core::try() |
Advanced Testing Patterns (Real-World Learnings)
Cross-Resource Lookup Pattern
Problem: Need to enforce that every S3 bucket has a corresponding encryption configuration.
Solution: Evaluate buckets, look up encryption configs via core::getresources().
# Top-level: Get all encryption configs once
locals {
all_encryption_configs = core::getresources("aws_s3_bucket_server_side_encryption_configuration", {})
}
# Resource-level: Find matching config for each bucket
resource_policy "aws_s3_bucket" "require_encryption" {
locals {
matching_configs = [
for config in local.all_encryption_configs :
config if config.bucket == attrs.bucket
]
}
enforce {
condition = core::try(local.matching_configs[0], null) != null
error_message = "Bucket must have encryption config"
}
}Test Structure:
# Evaluated resource - NO skip
resource "aws_s3_bucket" "test" {
attrs = { bucket = "test" }
}
# Looked-up resource - YES skip (but still visible to core::getresources)
resource "aws_s3_bucket_server_side_encryption_configuration" "config" {
skip = true
attrs = {
bucket = aws_s3_bucket.test.bucket
rule = [{ ... }] # Must be array!
}
}Key Points:
- ✅ Resources with
skip = trueARE visible tocore::getresources() - ✅ Use top-level
localsforcore::getresources()(performance) - ✅ Always evaluate the resource that must exist, look up optional ones
The Two-Check Pattern
Problem: Need to check two related attributes to determine compliance.
Example: S3 must use customer-managed KMS keys (not AWS-managed or AES256).
AWS encryption types:
- SSE-S3 (AES256) - S3-managed keys ❌
- SSE-KMS without key ID - AWS-managed "aws/s3" key ❌
- SSE-KMS with key ID - Customer-managed key ✅
Why one check fails:
# ❌ Only checks algorithm
condition = attrs.sse_algorithm == "aws:kms"
# PASSES even without kms_master_key_id (uses AWS-managed key!)
# ❌ Only checks key ID
condition = attrs.kms_master_key_id != ""
# PASSES even with "AES256" algorithm (not using KMS!)Correct: Check both
locals {
sse_algorithm = core::try(attrs.encryption[0].sse_algorithm, "")
kms_key_id = core::try(attrs.encryption[0].kms_master_key_id, "")
}
enforce {
condition = local.sse_algorithm == "aws:kms" && local.kms_key_id != ""
error_message = "Must use customer-managed KMS. Found algorithm: '${local.sse_algorithm}', key specified: ${local.kms_key_id != ""}"
}Test File Size Limitation
Discovery: With two-policy approach (one policy for buckets, another for encryption configs), test files fail when they contain 5+ buckets.
Workaround 1: Use single-policy approach (no limit observed)
# ✅ One policy evaluates buckets, looks up configs
resource_policy "aws_s3_bucket" "require_encryption" {
# Can test 6+ buckets in single file
}Workaround 2: Split tests across multiple files (max 4 buckets each)
tests/
├── test-scenario-1.policytest.hcl # 4 buckets
├── test-scenario-2.policytest.hcl # 4 buckets
└── test-scenario-3.policytest.hcl # 4 bucketsCommon Test Mistakes
Mistake: Wrong resource has skip or expect_failure
# ❌ WRONG - Policy evaluates buckets but test skips them
resource "aws_s3_bucket" "test" {
skip = true # Policy can't evaluate this!
}
resource "aws_s3_bucket_server_side_encryption_configuration" "config" {
expect_failure = true # Policy doesn't evaluate this!
}
# ✅ CORRECT - Match policy evaluation target
resource "aws_s3_bucket" "test" {
expect_failure = true # Policy evaluates buckets
}
resource "aws_s3_bucket_server_side_encryption_configuration" "config" {
skip = true # Policy looks this up via core::getresources()
}Mistake: Using objects instead of arrays
# ❌ WRONG
attrs = {
rule = { key = "value" } # Object
}
# ✅ CORRECT
attrs = {
rule = [{ key = "value" }] # Array
}Reason: Terraform resources use arrays. Policies access attrs.rule[0].
Known Policytest Framework Limitations
These are behaviors where tfpolicy test passes silently but a real terraform plan --policies= run fails or behaves differently. Always verify port-range and integer-arithmetic policies against a real plan.
core::range() with dynamic integer attributes returns empty in policytest
core::range(start, end) works correctly when called with hardcoded integer literals. However, when start or end come from mocked attrs.* integer values (e.g. attrs.from_port, attrs.to_port), the policytest framework treats those values as unknown/unevaluated at test time and core::range() silently returns an empty list [].
Impact: A policy that uses core::range() with dynamic port attributes will appear to pass all tests — including expect_failure cases — because the range is always empty. The bug only surfaces against a real plan.
Note:
core::alltrue()andcore::anytrue()are available starting in tfpolicy 0.3.x (see verified-syntax (verified-syntax.md#corealltruelist-and-coreanytruelist--tfpolicy-030-only)), but they do not fix thecore::range()limitation below:core::range()still returns[]for dynamicattrs.*bounds in policytest, so the count approach remains the correct fix.
# ❌ WRONG — core::range() + core::alltrue()/core::anytrue() — still problematic even on 0.3.0+
locals {
ports_in_range = core::range(core::try(attrs.from_port, 0), core::try(attrs.to_port, 0) + 1)
# ↑ returns [] in policytest because attrs.from_port/to_port are unknown at test time
all_authorized = core::alltrue([for p in local.ports_in_range : core::contains(local.authorized_ports, p)])
# ↑ core::alltrue vacuously returns true over an empty list — masks the real range bug either way
}Fix: Use the count approach instead — it works correctly with dynamic attrs.* values in both policytest and real plans:
# ✅ Count approach — consistent in policytest and real plan evaluation
locals {
authorized_ports = [80, 443]
from_port = core::try(attrs.from_port, 0)
to_port = core::try(attrs.to_port, 0)
authorized_in_range = [for p in local.authorized_ports : p if p >= local.from_port && p <= local.to_port]
all_ports_authorized = core::length(local.authorized_in_range) == (local.to_port - local.from_port + 1)
}See verified-syntax.md Mistake 23 for the full pattern.
core::getresources() sees ALL resources in the test file — isolate conflicting scenarios into separate files
In tfpolicy test, when a policy calls core::getresources("some_type", filter), the lookup searches all mock resources of that type in the entire test file — including resources marked expect_failure = true and resources marked skip = true.
Impact: A test scenario that requires core::getresources() to return zero results (or no compliant results) will silently produce the wrong outcome if any other scenario in the same file defines a resource of that type that satisfies the filter.
# ❌ PROBLEMATIC — both scenarios in the same test file
# "fail_no_defaults" incorrectly passes because core::getresources() picks up
# the compliant_defaults resource from the other scenario.
resource "aws_ec2_instance_metadata_defaults" "compliant_defaults" {
skip = true # skip = true is still visible to core::getresources()!
attrs = { http_tokens = "required" }
}
resource "aws_instance" "pass_with_defaults" {
attrs = { instance_type = "t3.micro" }
}
resource "aws_instance" "fail_no_defaults" {
expect_failure = true
attrs = { instance_type = "t3.micro" }
# WRONG: core::getresources("aws_ec2_instance_metadata_defaults", ...) still sees
# "compliant_defaults" above → policy evaluates as compliant → expect_failure passes incorrectly.
}Fix: Place scenarios with conflicting core::getresources() context into separate .policytest.hcl files. Each file is an independent resource graph.
# ✅ File 1: test-with-compliant-defaults.policytest.hcl
resource "aws_ec2_instance_metadata_defaults" "compliant_defaults" {
skip = true
attrs = { http_tokens = "required" }
}
resource "aws_instance" "pass_with_defaults" {
attrs = { instance_type = "t3.micro" }
}
# ✅ File 2: test-no-defaults.policytest.hcl
# No aws_ec2_instance_metadata_defaults defined — core::getresources() returns empty list.
resource "aws_instance" "fail_no_defaults" {
expect_failure = true
attrs = { instance_type = "t3.micro" }
}Rule: Whenever a test scenario relies on core::getresources() returning zero results (or no compliant results) for a given type, that scenario must be in its own .policytest.hcl file, completely isolated from any scenario that defines resources of that same type.
Related
- Verified Syntax Reference (verified-syntax.md) | tfpolicy-author skill (tfpolicy-author.md) | tfpolicy-test skill (tfpolicy-test.md)
references/verified-syntax.md
Terraform Policy - Verified Syntax Reference
Shared reference used by all sibling skills in
references/: tfpolicy-author (tfpolicy-author.md) | tfpolicy-test (tfpolicy-test.md)
Last Updated: 2026-02-24 Status: All patterns user-verified during private beta Purpose: Source-of-truth quick reference. Sub-skills link here rather than duplicating facts.
Critical Rules
1. ✅ Semantic Versioning (VERIFIED)
Rule: Use core::semverconstraint() for ALL version comparisons
# ✅ Correct
condition = core::semverconstraint(meta.version, ">= 4.0.0, < 5.0.0")
# ❌ Wrong
condition = meta.version >= 4.0 # Don't use direct comparisonConstraint syntax:
"= 4.67.0"- Exact version">= 4.0.0"- Minimum"< 5.0.0"- Maximum"~> 4.67.0"- Pessimistic patch (>= 4.67.0, < 4.68.0)"~> 4.0"- Pessimistic minor (>= 4.0.0, < 5.0.0)">= 4.0.0, < 5.0.0"- Multiple constraints (AND)"!= 4.50.0"- Exclude version
2. ✅ Core Function Prefix (VERIFIED)
Rule: ALL built-in Terraform functions require core:: prefix
# ✅ Correct
filter = core::try(attrs.encrypted, false) == true
is_allowed = core::length([for v in local.version_checks : v if v]) > 0
error_message = "Allowed: ${core::join(", ", local.allowed_versions)}"
# ❌ Wrong
filter = try(attrs.encrypted, false) == true # Missing core:: prefixCommon functions:
core::try(expr, default)- Safe access with fallbackcore::contains(list, value)- List membership (lists only, NOT strings!)core::length(list_or_string)- List/string/map length (✅ works on all!)core::keys(map)- Get map keys as list (✅ requires core:: prefix)core::join(separator, list)- Join list elementscore::semverconstraint(version, constraint)- Version comparisoncore::getresources(type, filter_map)- Query related resourcescore::alltrue(list)/core::anytrue(list)- Boolean collection helpers, available starting in tfpolicy 0.3.0. Seecore::alltrue()andcore::anytrue()(#corealltruelist-and-coreanytruelist--tfpolicy-030-only) for exact empty-list, unknown, and coercion behavior.
⚠️ IMPORTANT: core::getresources() filter behavior with unknown attribute values.
- The
filter_mapargument is required by the function signature (omitting it → "Not enough function arguments"). Passing{}matches everything; passing{ attr = value }performs equality matching. - Caveat: when the target attribute on a candidate resource is unknown at plan time (e.g.
bucket = aws_s3_bucket.x.idwhereaws_s3_bucket.xis being created in the same plan), the equality comparison evaluates to unknown and the engine conservatively includes that candidate in the result set. The filter is not "ignored" — but it cannot narrow results past any candidate whose target attribute is computed. - Verified on terraform 1.15.0-policy20261105 / tfpolicy 0.0.2-beta20260513 / tfpolicy-plugin 0.0.2-beta20260422.
- Production impact: any cross-resource policy on a first-time-create plan (where related resources reference each other via
.id) will see every candidate, not the matching one. Update plans against existing infrastructure (where target attributes are already known) filter correctly. - Pattern choice — constant filter vs.
attrs.*filter:- When the filter value is a known constant AND no secondary
attrs.*filtering is needed insideresource_policy: you MAY callcore::getresources()once in a top-levellocalsblock. A valid example is a truly account-level resource with no per-parent link:# ✅ CORRECT — aws_s3_account_public_access_block is account-level; no per-bucket link locals { account_pab = core::getresources("aws_s3_account_public_access_block", {}) } resource_policy "aws_s3_bucket" "account_block_required" { locals { pab = core::length(local.account_pab) > 0 ? local.account_pab[0] : null block_public_acls = local.pab != null ? core::try(local.pab.block_public_acls, false) : false } } - When the filter value comes from
attrs.*(e.g.attrs.id,attrs.name,attrs.arn) OR when any secondary filtering insideresource_policyis byattrs.*: use an inlinecore::getresources()call with the specific per-resource filter insideresource_policy. The top-level cache with a{}empty filter plus HCL-sideattrs.*filtering is the wrong pattern — see Mistake 13 CRITICAL note below.# ✅ CORRECT — filter value "table/${attrs.name}" comes from attrs.* — must be inline resource_policy "aws_dynamodb_table" "autoscaling_required" { locals { table_resource_id = "table/${attrs.name}" scaling_targets = core::getresources("aws_appautoscaling_target", { resource_id = local.table_resource_id }) } } # ❌ WRONG — top-level {} cache + HCL filter by attrs.* is the anti-pattern # locals { all_targets = core::getresources("aws_appautoscaling_target", {}) } # resource_policy "aws_dynamodb_table" { locals { filtered = [for t in local.all_targets : t if t.resource_id == "table/${attrs.name}"] } } ⚠️ DynamoDB autoscaling is ALWAYS the inline pattern (Pattern B). Even pre-filtering at the top level by a constant
scalable_dimensiondoes not make it Pattern A — the secondaryresource_id == "table/${attrs.name}"filter insideresource_policyis still derived fromattrs.name, so the correct approach is an inlinecore::getresources()call filtered byresource_id. Pre-filtering byscalable_dimensionat the top level forces you to add theattrs.*-derivedresource_idfilter insideresource_policy, which is the anti-pattern. Use the inline call and apply the constantscalable_dimensioncheck as a simple HCL filter after the inline fetch.
- When the filter value is a known constant AND no secondary
- ⚠️ When the filter value is derived from
attrs.*and that attribute is unknown at plan time (e.g.bucket = aws_s3_bucket.x.idfor a newly-created resource), the equality comparison evaluates to unknown — the engine conservatively includes that candidate in the result set, so first-time-create plans with cross-references are unreliable. Most reliable on updates and existing infrastructure where target values are known. ⛔ All dependent child resources — always use the inline filter pattern, NOT the top-level
{}cache. This applies toaws_s3_bucket_public_access_block,aws_s3_bucket_acl,aws_s3_bucket_server_side_encryption_configuration,aws_s3_bucket_policy,aws_appautoscaling_target,aws_appautoscaling_policy, and any resource type that has a(Required)or(Optional)argument referencing a parent resource. Callcore::getresources()inline inside the parentresource_policywith the specific filter (e.g.{bucket = attrs.id},{resource_id = local.table_resource_id}). Do NOT use the top-level cache{}+ HCL for-loop pattern for these types. See Mistake 13.
CRITICAL: core::getresources() Attribute Access:
- Resources returned have attributes at top level (NOT through
.attrs) - Example:
resource.name✅ NOTresource.attrs.name❌ - This is DIFFERENT from current resource context where you use
attrs.name - Pattern: For dependent child resources (any type with a
(Required)or(Optional)argument referencing a parent by.id,.arn, or.name): use inlinecore::getresources()insideresource_policywith the specific per-parent filter; access returned attributes directly at top level (NOT through.attrs). For truly independent/account-level resources: cache in top-levellocalswith a constant filter (if any), and access returned attributes directly.
✅ String functions available:
- ✅
core::startswith(string, prefix)- Returns bool. Arg order: full string first, prefix second (same as Sentinel'sstrings.has_prefix). e.g.core::startswith(meta.version, ">")✅ - ✅
core::endswith(string, suffix)- Returns bool - ✅
core::contains_substring(string, substr)- Returns bool - ✅
core::regex(pattern, string)- Pattern/substring matching. Throws on no match — wrap withcore::try():core::try(core::regex("pattern", string), null) != null - ✅
core::split(separator, string)- Splits a string into a list of substrings at each occurrence ofseparator. Example:core::split("-", "1-100")returns["1", "100"];core::split("-", "22")returns["22"]. Use withcore::parseint()to parse port ranges like"start-end"without regex:
Note: ternary short-circuits, so# ✅ Parsing a port range "start-end" using core::split (preferred over regex) port_parts = core::split("-", local.dest_port_range) range_start = core::length(local.port_parts) == 2 ? core::try(core::parseint(local.port_parts[0], 10), -1) : -1 range_end = core::length(local.port_parts) == 2 ? core::try(core::parseint(local.port_parts[1], 10), -1) : -1 # Range covers port 22 if start < 22 AND end > 22 (exclusive, matching Sentinel logic) is_range_ssh = local.range_start < 22 && local.range_end > 22core::parseintis only called whencore::length == 2. When the port string is not a range (e.g."22"or"*"),range_startandrange_enddefault to-1, making the range check false without any index-out-of-bounds risk. - ❌ Substring matching via
core::contains()— only works for lists, NOT strings# Prefix check using core::startswith() starts_with_open = core::startswith(local.version_value, ">") # Substring check using core::regex() has_exception = core::try(core::regex("exception", core::try(attrs.description, "")), null) != null
policy.required_providers is mandatory for resource and provider policy validation: A .policy.hcl file containing resource or provider policies must declare a top-level policy { required_providers { ... } } block. tfpolicy validate uses this block to resolve provider schemas for schema-aware validation, and validation fails when the block is omitted. See tfpolicy-author.md for concrete authoring examples.
Starting in tfpolicy 0.3.0, tfpolicy test reuses this same .policy.hcl declaration to validate provider, resource, and data-source policies, plus arguments used by core::getdatasource() and core::getresources(). It checks mocked values, including skip = true resource mocks, against the lowest and highest matching provider versions before evaluation. .policytest.hcl does not declare its own required_providers block — there is no policytest { required_providers { ... } } construct. See tfpolicy-test.md for details.
Validation limitations:
- Validation is best effort for version ranges. When a range is declared, provider schemas at the lower and upper bounds of the range are evaluated.
- Wildcard targets such as
resource_policy "*"are not schema-validated because they may match multiple resource types.
Provider version constraint checks: In provider_policy, meta.version is the resolved provider version (e.g. "6.50.0"), not the constraint string from required_providers. There is no tfpolicy surface that exposes the constraint string.
⚠️
providers-require-version-style Sentinel policies that checkstrings.has_prefix(p.version_constraint, ">")inspect the version constraint format string, which tfpolicy does not expose. This check is non-convertible. The closest tfpolicy equivalent enforces that the resolved provider version is within an approved range:
# Sentinel: strings.has_prefix(p.version_constraint, ">") → non-convertible
# TFPolicy nearest equivalent — enforce resolved version range instead:
provider_policy "*" "provider_version_range" {
enforce {
condition = core::semverconstraint(meta.version, ">= 4.0.0, < 5.0.0")
error_message = "Provider version '${meta.version}' must satisfy '>= 4.0.0, < 5.0.0'. Pin the provider to a tested version range to prevent major version upgrades."
}
}⚠️ Arg order:
core::startswith(string, prefix)— the full string is the first argument, the prefix is the second. Do not reverse them.
✅ JSON functions available:
- ✅
core::jsondecode(string)— parses a JSON string into an HCL object/list. Use when an attribute contains a serialised JSON value (e.g. an inline IAM policy document). - ✅
core::jsonencode(value)— encodes an HCL object/list as a JSON string. - ❌
json::unmarshal— does not exist. There is nojson::namespace. Always usecore::jsondecodeinstead.locals { policy_doc = core::try(core::jsondecode(core::try(attrs.policy, "{}")), {}) statements = core::try(local.policy_doc.Statement, []) }
3. ✅ Policy Type Features (VERIFIED)
Rule: ALL policy types support the same features
| Feature | resource_policy | module_policy | provider_policy |
|---|---|---|---|
locals {} |
✅ Yes | ✅ Yes | ✅ Yes |
filter clause |
✅ Yes | ✅ Yes | ✅ Yes |
Multiple enforce |
✅ Yes | ✅ Yes | ✅ Yes |
IMPORTANT: Language server shows FALSE ERRORS for locals in provider_policy
- Error: "No declaration found for local.variable"
- These are safe to ignore - syntax is valid
- Runtime evaluation works correctly
4. ✅ CRITICAL: Blocks vs Attributes Schema Distinction
Rule: Provider schema representation determines how you access nested configuration
Blocks vs Attributes:
- BLOCKS → Represented as lists of maps (even if only one allowed)
- Require array indexing:
attrs.block_name[0].field - Examples:
default_tags,assume_role,lifecycle
- Require array indexing:
- ATTRIBUTES → Represented as direct values (maps, strings, numbers, etc.)
- Direct access:
attrs.attribute_name - Examples:
region,tags,instance_type
- Direct access:
Examples:
# ✅ BLOCK access (default_tags in AWS provider)
# Schema: default_tags = [ { tags = { "Env" = "Dev" } } ]
default_tags_map = core::try(attrs.default_tags[0].tags, {}) # Need [0] index
# ✅ ATTRIBUTE access (tags on resources)
# Schema: tags = { "Env" = "Dev" }
has_tags = attrs.tags != null && core::length(attrs.tags) > 0 # Direct access, no [0]
# Note: core::length() works directly on maps, no need for core::keys()How to determine Block vs Attribute:
- Check provider schema documentation
- Use
terraform consoleto inspect structure - If accessing nested field fails without
[0], it's a Block
5. ✅ Input Blocks — Runtime Parameterization (VERIFIED)
Rule: Use input blocks instead of hardcoded locals for values that vary per policy set. Never claim Sentinel param cannot be replicated — input is the direct equivalent.
# ✅ Correct — parameterized, overridable per policy set
input "allowed_providers" {
type = list(string)
default = ["registry.terraform.io/hashicorp/aws"]
}
provider_policy "*" "providers_allowlist" {
enforce {
condition = core::contains(input.allowed_providers, meta.source)
error_message = "Provider '${meta.source}' not allowed. Permitted: ${core::join(", ", input.allowed_providers)}."
}
}
# ❌ Wrong — hardcoding what should be configurable
locals {
allowed_providers = ["registry.terraform.io/hashicorp/aws"] # Never override without editing policy file
}Supported types: string, number, bool, list(string), list(number), map(string)
Override mechanism: .tfpolicy.metadata.json at policy-set scope, or per-evaluation overrides.
Use for: allowlists, blocklists, version constraints, limits, thresholds — any value the operator may want to tune.
6. ✅ Operations Scoping + prior_attrs (VERIFIED)
Rule: Use operations = [...] to restrict when a policy fires. Use prior_attrs to read pre-change state on delete/update.
# ✅ Fires only on create and update — never on destroy
resource_policy "tfe_workspace" "require_project" {
operations = ["create", "update"]
enforce {
condition = core::try(attrs.project_id, "") != ""
error_message = "tfe_workspace must have project_id set."
}
}
# ✅ Delete-gate: check prior state before workspace is destroyed
resource_policy "tfe_workspace" "deny_delete_without_tag" {
operations = ["delete"] # prior_attrs is available when "create" is NOT in operations
locals {
prior_tag_names = core::try(prior_attrs.tag_names, [])
had_delete_tag = core::contains(local.prior_tag_names, "delete")
}
enforce {
condition = local.had_delete_tag
error_message = "Add 'delete' tag before destroying a workspace."
}
}Key rules:
operations = ["create", "update"]— skips destroy; equivalent to Sentinelrc.change.actions is not ["delete"]operations = ["delete"]— fires only on destroy;prior_attrsholds the before-stateprior_attrsis only available when"create"is NOT inoperations- Default (no
operations) = fires on create and update - A policy cannot list both
"create"and"delete"inoperations. Replacement plans are evaluated as separate delete and create operations, so split policies targeting both operations into separateresource_policyblocks.
7. ✅ Time Functions (VERIFIED)
Rule: core::timestamp(), core::formatdate(), and core::parseint() exist. Never generate placeholder policies claiming time functions are unavailable.
# ✅ Day-of-week restriction
input "restricted_weekdays_utc" {
type = list(string)
default = ["Friday", "Saturday", "Sunday"]
}
resource_policy "tfe_workspace" "deny_apply_day_of_week" {
locals {
current_weekday = core::formatdate("EEEE", core::timestamp()) # "Monday", "Friday", etc.
is_restricted = core::contains(input.restricted_weekdays_utc, local.current_weekday)
}
enforce {
condition = !local.is_restricted
error_message = "Apply blocked on ${local.current_weekday} (UTC). Restricted days: ${core::join(", ", input.restricted_weekdays_utc)}."
}
}
# ✅ Hour-of-day restriction
input "restricted_hours_utc" {
type = list(number)
default = [8, 9, 10, 11, 12]
}
resource_policy "tfe_workspace" "deny_apply_hour_of_day" {
locals {
current_hour = core::parseint(core::formatdate("HH", core::timestamp()), 10)
is_restricted = core::contains(input.restricted_hours_utc, local.current_hour)
}
enforce {
condition = !local.is_restricted
error_message = "Apply blocked during hour ${local.current_hour} UTC."
}
}Available time functions:
core::timestamp()— current UTC time as RFC3339 stringcore::formatdate(spec, timestamp)— format specifiers:"EEEE"(weekday name),"HH"(hour 00-23),"DD"(day),"MM"(month),"YYYY"(year)core::timeadd(timestamp, duration)— add duration (e.g."24h","-1h")core::timecmp(ts1, ts2)— compare two timestampscore::parseint(string, base)— parse string to integer (e.g.core::parseint("08", 10)→8)- Note: All times are UTC. Express restricted windows in UTC.
8. ✅ Naming Conventions (VERIFIED)
resource_policy block names — use snake_case derived from the enforcement requirement:
# ✅ Correct — descriptive snake_case
resource_policy "aws_s3_bucket" "versioning_required" { }
resource_policy "aws_iam_policy" "no_admin_privileges" { }
resource_policy "aws_ecs_task_definition" "secure_networking_mode_and_user" { }
# ❌ Wrong — generic names that don't describe the check
resource_policy "aws_s3_bucket" "check" { }
resource_policy "aws_s3_bucket" "all_checks" { }
resource_policy "aws_s3_bucket" "policy" { }Rules:
- Use
snake_case(underscores, lowercase). Never use kebab-case (hyphens) in block names. - Name should reflect the enforcement requirement, not the resource type (the type is already in the first argument).
- When a single resource type has multiple enforce blocks (the correct pattern), choose one name that describes the combined requirement rather than the individual checks.
- For IAM 4-type rules, use the same
policy_nameacross all 4 blocks (e.g.no_admin_privileges) — the resource type in the first argument differentiates them.
9. ✅ core::try() Default Type Selection (VERIFIED)
Choose the default value based on the semantic type of the attribute — do NOT mix defaults for the same attribute across related checks:
| Attribute type | Correct default | Rationale |
|---|---|---|
| Boolean | false |
Absent boolean = permissive default (fails the check correctly) |
| Required string | "" |
Absent string = empty = fails non-empty checks correctly |
| List / set | [] |
Absent list = empty = length checks correctly return 0 |
| Map / object | {} |
Absent map = empty = key lookups return null |
| "Must detect unset separately from empty" | null |
When "" and null must be treated differently by the condition |
# ✅ Correct defaults by type
encrypted = core::try(attrs.encrypted, false) # boolean
bucket_name = core::try(attrs.bucket, "") # string
tag_names = core::try(attrs.tag_names, []) # list
tags = core::try(attrs.tags, {}) # map
optional_config = core::try(attrs.config, null) != null # detect presence⚠️ Using the wrong default silently masks violations:
core::try(attrs.encrypted, true)— absent = treated as encrypted = violation missedcore::try(attrs.tag_names, ["compliant"])— absent = treated as tagged = violation missed
Common Mistakes to Avoid
❌ Mistake 1: Direct Version Comparison
# Wrong
condition = meta.version < 5.0Fix: Use core::semverconstraint(meta.version, "< 5.0.0")
❌ Mistake 2: Direct Attribute Access Without try()
# Wrong - Crashes when attribute doesn't exist
has_region = attrs.region != null
# Wrong - Still crashes even with double check!
condition = attrs.encrypted != null && attrs.encrypted == trueFix: Use two-step safe access pattern:
# Correct
region_value = core::try(attrs.region, null)
has_region = local.region_value != nullWhy: Cannot test attribute existence before accessing (no "attr" in attrs syntax). Must use core::try() first.
❌ Mistake 4: Trusting Language Server Errors
# Language server shows error, but syntax is valid:
provider_policy "aws" "example" {
locals { # ❌ False error: "No declaration found"
version_check = ...
}
}Fix: Ignore language server errors for locals in provider_policy - they're false positives
❌ Mistake 6: Filter Without Length Check
# Suboptimal - doesn't filter empty collections
filter = attrs.ingress != null
# Better - filters both null and empty
filter = attrs.ingress != null && core::length(attrs.ingress) > 0Fix: Check both null and length for better performance
❌ Mistake 7: Multi-line Boolean Expressions
# Wrong - causes syntax errors
is_exact = local.has_version &&
!core::contains(meta.version, "~>") &&
!core::contains(meta.version, ">")
# Correct - all on one line
is_exact = local.has_version && !core::contains(meta.version, "~>") && !core::contains(meta.version, ">")Fix: Put entire boolean expression on a single line
❌ Mistake 8: Trying to Access Module Inputs
# Wrong - module inputs not accessible yet
module_policy "*" "check" {
locals {
dns_enabled = attrs.enable_dns_hostnames # ❌ Not supported
}
}Fix: Module inputs via attrs.* are work in progress. You can only check meta.source, meta.version, meta.address
❌ Mistake 9: Using Substring for Module Targeting
# Wrong - substring matching doesn't work
module_policy "vpc" "check" { } # Won't match modules with "vpc" in source
# Correct - use full source path or wildcard
module_policy "app.terraform.io/myorg/vpc/aws" "check" { }
module_policy "*" "check" { } # For all modulesFix: Module targeting requires FULL source path, not substring
❌ Mistake 10: Confusing Blocks with Attributes
# Wrong - default_tags is a Block, needs [0]
provider_policy "aws" "check" {
locals {
tags = attrs.default_tags.tags # ❌ Error: no indices
}
}
# Correct - use [0] for Blocks
provider_policy "aws" "check" {
locals {
tags = attrs.default_tags[0].tags # ✅ Works!
}
}Fix: Check provider schema - Blocks need [0] index, Attributes don't
❌ Mistake 11: Using Source for Provider Targeting
# Wrong - can't use source in first label
provider_policy "hashicorp/aws" "check" { } # Doesn't work
# Correct - use provider TYPE
provider_policy "aws" "check" {
# meta.source available inside policy
# Example: meta.source = "registry.terraform.io/hashicorp/aws"
}Fix: provider_policy first label = TYPE ("aws"), not source ("hashicorp/aws")
❌ Mistake 12: Unnecessary core::keys() for Length
# Unnecessarily complex
has_tags = core::try(core::length(core::keys(attrs.tags)), 0) > 0
# Simpler - core::length() works directly on maps
has_tags = core::try(core::length(attrs.tags), 0) > 0
# Most readable
has_tags = attrs.tags != null && core::length(attrs.tags) > 0Fix: core::length() works directly on maps - no need for core::keys() wrapper
❌ Mistake 13: Using core::getresources()
# ❌ WRONG — null or empty filter inside resource_policy runs for EVERY resource (O(N) cost)
resource_policy "aws_s3_bucket" "check" {
locals {
all_policies = core::getresources("aws_s3_bucket_policy", null) # unscoped
}
}
# ❌ ALSO WRONG — empty filter {} is equally unscoped inside resource_policy
resource_policy "aws_s3_bucket" "check" {
locals {
all_policies = core::getresources("aws_s3_bucket_policy", {})
}
}
# ✅ CORRECT — cache at top-level locals for plan-time join (runs once total)
locals {
all_policies = core::getresources("aws_s3_bucket_policy", {})
}Why: Calling core::getresources() with an empty ({}) or null filter inside resource_policy fetches ALL resources of that type once per evaluated resource — O(N) calls for N resources. Cache in top-level locals to run once.
Exception — per-resource attribute filter (parent+child pattern): When the filter value depends on the current resource's own attribute (e.g. attrs.id), the call cannot be pre-computed at the top level because attrs is only available inside resource_policy. In that case, an inline core::getresources() call with a specific per-resource filter is the correct pattern. Always use a specific filter — never pass {} or null inside a resource_policy.
Dependent child resources — use inline filter, verify via Terraform Registry. Before writing a direct
resource_policy "child_type"block or a top-level cache for a cross-resource lookup, fetch the Terraform Registry documentation for the child resource type to determine whether it is structurally dependent on a parent resource:
- URL pattern:
https://raw.githubusercontent.com/hashicorp/terraform-provider-aws/main/website/docs/r/{resource_name_without_aws_prefix}.html.markdown- Look for a
(Required)argument in "Argument Reference" whose description references another AWS resource (e.g. "Bucket to which to apply the ACL", "ARN of the load balancer"). Check the usage examples — if they showsome_attr = aws_parent.name.idor.arn, that confirms the dependency.- If the child IS dependent: use
resource_policy "aws_parent_type"with an inlinecore::getresources("aws_child_type", {linking_attr = attrs.id_or_arn})when the enforcement goal is detecting a missing child. The linking attribute name comes from the required argument (e.g.bucket); useattrs.idif the examples assign.id,attrs.arnif they assign.arn.- If the enforcement goal is checking the child's own properties and the child can exist independently (its absence is not a violation by itself), write
resource_policy "aws_child_type"directly — do not look it up inside the parent's policy.- If a resource type has no linking attribute in either direction — neither it nor the "parent" type references the other via
.id,.arn,.name, or similar in the Terraform config — it is a truly independent resource. Write a separateresource_policyblock for it. Never usecore::getresources()inside anotherresource_policyto look up independent resources.- Never use a top-level empty-filter cache for dependent child resource types when doing per-parent enforcement — it fetches all resources globally and requires a manual HCL for-loop to re-associate them with the parent.
⛔ CRITICAL: This prohibition extends to ALL cases where the filter value comes from attrs.* — not just structurally-dependent child resources. If you find yourself writing [for r in local.all_X : r if r.link_attr == attrs.Y] inside a resource_policy, that is the wrong pattern whenever an inline call with a specific filter would work. The top-level {} cache is only appropriate when the filter value is a constant (not derived from attrs.*) — for example, pre-fetching all autoscaling policies to filter by a constant scalable_dimension value inside resource_policy. For truly independent resources (no linking attribute in either direction), a top-level {} cache must NOT be used to implement cross-resource fallback logic between them — each independent resource type gets its own resource_policy block (see rule below).
For truly independent resources — those with NO linking attribute in either direction in the Terraform config — write a separate resource_policy block for each type. Do NOT use core::getresources() to check an independent resource inside another resource_policy.
The key test: look at the Terraform Registry documentation for both resource types. If neither resource has an attribute whose value is set to the other resource's .id, .arn, .name, or similar reference in example usage (e.g. bucket = aws_s3_bucket.x.id, security_configuration = aws_emr_security_configuration.x.name), the resources are independent — each gets its own resource_policy block.
# Example of a LINKING attribute (makes inline core::getresources() correct):
# aws_emr_cluster has: security_configuration = aws_emr_security_configuration.x.name
# → The cluster references the security config by name → inline lookup is valid.
# Example of NO linking attribute (makes inline core::getresources() WRONG):
# aws_instance has NO attribute that references aws_ec2_instance_metadata_defaults.
# aws_ec2_instance_metadata_defaults has NO attribute that references aws_instance.
# → They are independent → each gets its own separate resource_policy block.⛔ Sentinel fallback pattern between independent resources — never use core::getresources() as a fallback.
When a Sentinel policy has logic like: "if resource A does not have attribute X configured, then check if independent resource B has Y as a fallback" — TFPolicy cannot express this cross-resource fallback because A and B share no linking attribute.
Correct TFPolicy conversion:
- Use
filteron resource A to skip instances where X is absent (they are out of scope for A's check).- Enforce Y directly on resource B via its own separate
resource_policyblock.- Document the limitation in the policy file with a
# LIMITATION:comment.Never implement the fallback by calling
core::getresources("B", {})at the top level and using the result inside A'sresource_policylocals. That is a cross-resource check between independent resources — it is incorrect regardless of whether the filter is{}or a constant value.Concrete example — aws_instance + aws_ec2_instance_metadata_defaults:
# ❌ WRONG — top-level {} cache used to implement Sentinel fallback between independent resources locals { all_defaults = core::getresources("aws_ec2_instance_metadata_defaults", {}) defaults_compliant = core::length([for d in local.all_defaults : d if core::try(d.http_tokens, "") == "required"]) > 0 } resource_policy "aws_instance" "imdsv2" { locals { # ❌ Wrong: cross-resource fallback using independent resource is_compliant = local.has_metadata_options ? (local.http_tokens == "required") : local.defaults_compliant } enforce { condition = local.is_compliant ... } } # ✅ CORRECT — filter skips instances without metadata_options; independent resource checked separately resource_policy "aws_instance" "imdsv2" { filter = core::try(attrs.metadata_options, null) != null && core::try(core::length(attrs.metadata_options), 0) > 0 locals { http_tokens = core::try([for m in attrs.metadata_options : m][0].http_tokens, "") } enforcement_level = "advisory" enforce { condition = local.http_tokens == "required" ... } } resource_policy "aws_ec2_instance_metadata_defaults" "imdsv2" { locals { http_tokens = core::try(attrs.http_tokens, "") } enforcement_level = "advisory" enforce { condition = local.http_tokens == "required" ... } }
# ❌ WRONG — top-level {} cache, then HCL-filtered by attrs.* value inside resource_policy
locals {
all_sec_configs = core::getresources("aws_emr_security_configuration", {})
}
resource_policy "aws_emr_cluster" "check" {
locals {
# ❌ Wrong pattern: fetches all globally and re-filters per cluster
matching = [for sc in local.all_sec_configs : sc if sc.name == attrs.security_configuration]
}
}
# ❌ ALSO WRONG — same anti-pattern restructured as a top-level lookup map.
# Building { name => value } at the top level and indexing by an attrs.*-derived key
# inside resource_policy is semantically equivalent to the {} cache + HCL for-loop above.
# The lookup key (local.security_config_name = core::try(attrs.security_configuration, ""))
# is still derived from attrs.*, so this violates the same rule.
locals {
all_security_configs = core::getresources("aws_emr_security_configuration", {})
security_config_map = {
for sc in local.all_security_configs :
core::try(sc.name, "") => core::try(sc.configuration, "")
}
}
resource_policy "aws_emr_cluster" "check" {
locals {
security_config_name = core::try(attrs.security_configuration, "")
# ❌ Wrong: map lookup by attrs.*-derived key is an attrs.* filter in disguise
security_config_json = core::try(local.security_config_map[local.security_config_name], "")
}
}
# ✅ CORRECT — inline call with filter derived from attrs.*
resource_policy "aws_emr_cluster" "check" {
locals {
matching = core::getresources("aws_emr_security_configuration", {
name = attrs.security_configuration
})
}
}
# ✅ CORRECT — inline call with per-resource filter using attrs.id
# NOTE: This policy contains a cross-resource reference that will not resolve during plan time,
# but the policy will run successfully during apply time.
resource_policy "aws_s3_bucket" "public_access_required" {
locals {
public_access_block = core::getresources("aws_s3_bucket_public_access_block", {
bucket = attrs.id # Use attrs.id: Terraform sets child.bucket = parent.id
})
block_public_acls = core::try(local.public_access_block[0].block_public_acls, false)
block_public_policy = core::try(local.public_access_block[0].block_public_policy, false)
ignore_public_acls = core::try(local.public_access_block[0].ignore_public_acls, false)
restrict_public_buckets = core::try(local.public_access_block[0].restrict_public_buckets, false)
}
enforce {
condition = local.block_public_acls && local.block_public_policy && local.ignore_public_acls && local.restrict_public_buckets
error_message = "S3 bucket must have all public access block settings enabled."
}
}See the core::getresources() decision guide in Critical Rule 2 above for the full pattern guidance.
❌ Mistake 14: Using core::getdatasource() Inside resource_policy
# ❌ WRONG - Makes API calls for EVERY resource!
resource_policy "aws_s3_bucket" "check" {
locals {
account_id = core::getdatasource("aws_caller_identity", {})
}
}
# ✅ CORRECT - Cache in top-level locals
locals {
account_id = core::getdatasource("aws_caller_identity", {})
}Why: Makes real provider API calls (not cached). Never use inside resource policies.
❌ Mistake 15: Using .attrs with core::getresources()
# ❌ WRONG
locals {
all_roles = core::getresources("aws_iam_role", {})
}
resource_policy "aws_iam_role" "check" {
locals {
name = local.all_roles[0].attrs.name # ERROR!
}
}
# ✅ CORRECT
resource_policy "aws_iam_role" "check" {
locals {
name = local.all_roles[0].name # Works!
}
}Fix: Resources from core::getresources() have attributes at top level, not through .attrs.
❌ Mistake 16: Not Converting Sets to Lists for Indexing
# ❌ WRONG - rule is a SET, cannot index
resource_policy "aws_s3_bucket_server_side_encryption_configuration" "check" {
locals {
sse_algo = attrs.rule[0].sse_algorithm # ERROR!
}
}
# ✅ CORRECT - Convert set to list using for loop
resource_policy "aws_s3_bucket_server_side_encryption_configuration" "check" {
locals {
sse_algo = [for rule in attrs.rule : rule][0].sse_algorithm
}
}Fix: Sets cannot be indexed with [0]. Use [for item in set : item][0] to convert.
❌ Mistake 17: Redundant Length Checks — and When to Keep Them
core::try() catches index-out-of-range errors, so a bare length check before [0] inside a condition = expression is technically redundant:
# ❌ Redundant in condition = expressions (core::try catches the out-of-range error)
condition = core::length(local.list) > 0 && core::try(local.list[0].value, "") == "expected"
# ✅ Simpler — safe in condition = expressions
condition = core::try(local.list[0].value, "") == "expected"However, when the list comes from core::getresources() (a child-resource lookup), always use an explicit length guard in locals before indexing with [0]. This makes the intent clear, is consistent with policies that inspect nested attribute blocks, and avoids relying on core::try to silently swallow a structural absence:
# ✅ PREFERRED for core::getresources() results — explicit guard before [0]
locals {
bucket_acl_resources = core::getresources("aws_s3_bucket_acl", { bucket = attrs.id })
has_acl = core::length(local.bucket_acl_resources) > 0
acl_value = local.has_acl ? core::try(local.bucket_acl_resources[0].acl, "") : ""
}
# ✅ PREFERRED for nested block lists from getresources() — guard each level
locals {
sse_configs = core::getresources("aws_s3_bucket_server_side_encryption_configuration", { bucket = attrs.id })
has_sse_config = core::length(local.sse_configs) > 0
sse_rules = local.has_sse_config ? core::try([for r in local.sse_configs[0].rule : r], []) : []
has_sse_rule = core::length(local.sse_rules) > 0
sse_apply_block = local.has_sse_rule ? core::try([for a in local.sse_rules[0].apply_server_side_encryption_by_default : a], []) : []
has_apply_block = core::length(local.sse_apply_block) > 0
sse_algorithm = local.has_apply_block ? core::try(local.sse_apply_block[0].sse_algorithm, "") : ""
}
# ❌ AVOID for getresources() results — relies on core::try to mask absent child resource
locals {
sse_configs = core::getresources("aws_s3_bucket_server_side_encryption_configuration", { bucket = attrs.id })
sse_rules = core::try([for r in local.sse_configs[0].rule : r], []) # no guard: absence silently swallowed
sse_algorithm = core::try(local.sse_rules[0].apply_server_side_encryption_by_default[0].sse_algorithm, "")
}Rule summary:
- In a
condition =expression:core::try(list[0].attr, default)without a length guard is acceptable. - In a
localsblock withcore::getresources()results: usehas_X = core::length(local.X) > 0and guard each[0]access withlocal.has_X ? ... : default. This is the established pattern in all S3 child-resource policies and makes the "no child resource found" case explicit.
❌ Mistake 18: Expecting Cross-Resource References to Resolve at Plan Time
# ❌ Won't match during initial creation
resource "aws_s3_bucket_server_side_encryption_configuration" "example" {
bucket = aws_s3_bucket.example.id # Reference not resolved at policy time
}
# ✅ For testing, use literals
resource "aws_s3_bucket_server_side_encryption_configuration" "example" {
bucket = "my-bucket" # Literal value
}Fix: Cross-resource references aren't resolved at policy evaluation. Works best on existing infrastructure updates.
❌ Mistake 19: Using meta.address in resource_policy
# ❌ WRONG — meta.address is UNDEFINED in resource_policy real-plan evaluation
resource_policy "aws_s3_bucket" "check" {
enforce {
condition = !local.has_public_acl
error_message = "S3 bucket '${meta.address}' has a public ACL." # ERROR!
}
}
# ✅ CORRECT — use static strings or safe attrs interpolation
resource_policy "aws_s3_bucket" "check" {
enforce {
condition = !local.has_public_acl
error_message = "S3 bucket has a prohibited public ACL. Set acl to 'private' or remove it."
# Or with dynamic context using a known attribute:
# error_message = "S3 bucket '${attrs.bucket}' has a prohibited public ACL."
}
}Fix: meta.address is UNDEFINED for resource_policy in real plan evaluation. It throws Error: Unsupported attribute for every resource evaluated, including compliant ones. tfpolicy test silently passes this bug — only terraform plan --policies= catches it.
❌ Mistake 20: Incomplete Security Group Resource Coverage
# ❌ WRONG — Only covers 2 of 4 SG resource types; misses modern VPC API
resource_policy "aws_security_group" "check" { ... }
resource_policy "aws_security_group_rule" "check" { ... }
# ✅ CORRECT — Cover all 4 AWS security group resource types
resource_policy "aws_security_group" "check" {
# inline ingress/egress blocks; cidr_blocks in rule.cidr_blocks
}
resource_policy "aws_security_group_rule" "check" {
# standalone rules; cidr_blocks in attrs.cidr_blocks, type in attrs.type
}
resource_policy "aws_vpc_security_group_ingress_rule" "check" {
# modern VPC API (recommended); uses attrs.cidr_ipv4 NOT cidr_blocks
locals {
has_public_cidr = core::try(attrs.cidr_ipv4, "") == "0.0.0.0/0"
}
}
resource_policy "aws_default_security_group" "check" {
# default SG; same inline structure as aws_security_group
}Key difference: aws_vpc_security_group_ingress_rule uses cidr_ipv4 (string) and cidr_ipv6 (string), NOT cidr_blocks (list). Always include all 4 types for complete SG enforcement.
ELB family — 3 resource types required for complete listener/SSL enforcement:
# ❌ WRONG — Only covers Classic ELB; ALB/NLB (the modern standard) are silently skipped
resource_policy "aws_elb" "ssl_policy_check" { ... }
# ✅ CORRECT — Cover all 3 ELB resource types
resource_policy "aws_elb" "ssl_policy_check" {
# Classic ELB: check aws_load_balancer_policy + aws_load_balancer_listener_policy
# listeners use attrs.listener[*].lb_protocol
}
resource_policy "aws_lb_listener" "ssl_policy_check" {
# ALB/NLB: ssl_policy is a direct attribute on the listener
filter = core::contains(["HTTPS", "TLS"], core::try(attrs.protocol, ""))
locals {
ssl_policy = core::try(attrs.ssl_policy, "")
}
enforce {
condition = core::contains(local.allowed_policies, local.ssl_policy)
error_message = "ALB/NLB listener must use an approved SSL/TLS security policy."
}
}
resource_policy "aws_alb_listener" "ssl_policy_check" {
# aws_alb_listener is an alias for aws_lb_listener — same attributes, same checks
filter = core::contains(["HTTPS", "TLS"], core::try(attrs.protocol, ""))
locals {
ssl_policy = core::try(attrs.ssl_policy, "")
}
enforce {
condition = core::contains(local.allowed_policies, local.ssl_policy)
error_message = "ALB/NLB listener must use an approved SSL/TLS security policy."
}
}Key difference: aws_lb_listener/aws_alb_listener has ssl_policy as a direct attribute; aws_elb requires cross-resource checks via aws_load_balancer_policy. Always cover all 3 types for complete ELB enforcement.
❌ Mistake 21: Using core::try Default to Mask Missing Attributes
# ❌ WRONG — resources where acl is NOT SET get defaulted to "private" and silently pass.
# This does NOT affect resources where acl IS set to "public-read" — those still fail correctly.
# The problem is resources with no acl configured are treated as compliant when they may not be.
resource_policy "aws_s3_bucket" "check" {
locals {
acl_value = core::try(attrs.acl, "private") # missing acl → "private" → passes silently
is_violation = core::contains(["public-read", "public-read-write"], local.acl_value)
}
enforce {
condition = !local.is_violation
}
}
# ✅ CORRECT — use filter to only evaluate resources that have the attribute set
resource_policy "aws_s3_bucket" "check" {
filter = core::try(attrs.acl, null) != null # skip resources with no acl configured
locals {
acl_value = attrs.acl # safe after filter
is_violation = core::contains(["public-read", "public-read-write"], local.acl_value)
}
enforce {
condition = !local.is_violation
}
}Fix — two cases:
- Attribute absent = resource out of scope (resource doesn't configure the feature at all → skip it): use
filter = core::try(attrs.field, null) != nullto exclude those resources. - Attribute absent = AWS provider default applies (e.g.
encryptedabsent → AWS defaults tofalse,enabledabsent → AWS defaults totrue): the resource is in scope and should be evaluated. Do not filter on null — usecore::try(attrs.field, <aws_default>)in theconditionso the effective default is checked. Filtering out these resources would silently pass non-compliant configurations.
Deciding which case applies: check the Terraform provider documentation for the attribute. If it says "Default:
false" or "Default:true", the absence carries a meaningful value → usecore::trywith the provider default in condition. If the attribute is truly optional with no provider default (its absence means "this block is not configured"), usefilterto exclude it.
❌ Mistake 22: Using expect_failure on data source blocks
# ❌ WRONG — tfpolicy test does not support expect_failure on data blocks
data "aws_iam_policy_document" "test" {
expect_failure = true # ERROR: unsupported argument
attrs = { ... }
}
# ✅ CORRECT — expect_failure is only valid on resource blocks
resource "aws_s3_bucket" "test_violation" {
expect_failure = true
attrs = { acl = "public-read" }
}Fix: expect_failure is only supported on resource test blocks, not data blocks.
❌ Mistake 23: Assuming TFPolicy Cannot Check Integer Port Ranges
# ❌ WRONG — Adds a false limitation and requires from_port == to_port,
# which incorrectly rejects valid port-range rules.
locals {
# LIMITATION: TF Policy cannot dynamically iterate integer ranges.
# This implementation requires from_port == to_port (single-port rule only).
is_single_port = local.from_port == local.to_port
port_authorized = core::contains(local.authorized_ports, local.from_port)
is_compliant = local.is_single_port && local.port_authorized
}
# ❌ ALSO WRONG (tfpolicy < 0.3.0) — core::range() with dynamic attrs.* values silently returns
# an empty list in the policytest framework (policytest limitation only), and core::alltrue()
# does not exist before 0.3.0.
locals {
ports_in_range = core::range(local.from_port, local.to_port + 1) # empty in policytest!
all_authorized = core::alltrue([for p in local.ports_in_range : core::contains(local.authorized_ports, p)])
}
# ✅ CORRECT — count how many authorized ports fall inside [from_port, to_port].
# If the count equals the total number of ports in the range, all are authorized.
# Works with dynamic attrs.* values at both plan time and in policytest.
locals {
authorized_ports = [80, 443] # or from input block
from_port = core::try(attrs.from_port, 0)
to_port = core::try(attrs.to_port, 0)
authorized_in_range = [for p in local.authorized_ports : p if p >= local.from_port && p <= local.to_port]
all_ports_authorized = core::length(local.authorized_in_range) == (local.to_port - local.from_port + 1)
}Rule: TFPolicy CAN check whether all ports within a dynamic integer range [from_port, to_port] are authorized. Never add a "cannot iterate integer ranges" limitation — it is false.
Why the count approach works:
- Filter
authorized_portsto those within[from_port, to_port] - If every port in the range is authorized, the filtered count equals
to_port - from_port + 1 - No range iteration needed — avoids the
core::range()+ dynamic value policytest issue entirely - Verified working with dynamic
attrs.from_port/attrs.to_portvalues
Caution with core::range(): core::range(start, end) works correctly with hardcoded literals. With dynamic attrs.* integer values, it silently returns an empty list in the policytest framework (policytest limitation). Prefer the count approach for port-range policies to guarantee consistent behaviour in both tests and runtime.
❌ Mistake 24: Accessing Optional Nested Blocks Without Null Safety ("unknown condition" error)
# ❌ WRONG — ebs_block_device is optional; absent on instances without EBS.
# If the block is missing, attrs.ebs_block_device[0] is null,
# local.ebs.encrypted is unknown, and condition = unknown throws:
# Error: unknown condition
resource_policy "aws_instance" "ebs_encrypted" {
locals {
ebs = attrs.ebs_block_device[0] # ❌ null if block absent
is_encrypted = local.ebs.encrypted # ❌ unknown propagation
}
enforce {
condition = local.is_encrypted == true # ❌ "unknown condition" ERROR
error_message = "EBS block devices must be encrypted."
}
}
# ❌ ALSO WRONG — Multiple optional nested blocks combined without null guards.
# If either block is absent, the condition evaluates to unknown.
resource_policy "aws_instance" "check" {
locals {
ebs_encrypted = attrs.ebs_block_device[0].encrypted # ❌ crashes if absent
nic_sg = attrs.network_interface[0].security_groups # ❌ crashes if absent
}
enforce {
condition = local.ebs_encrypted == true && core::length(local.nic_sg) > 0
}
}
# ✅ CORRECT — Use filter to scope to resources with the block,
# convert set to list, then use core::try for safe attribute access.
resource_policy "aws_instance" "ebs_encrypted" {
# Only evaluate instances that have at least one EBS block device configured
filter = core::try(attrs.ebs_block_device, null) != null && core::try(core::length(attrs.ebs_block_device), 0) > 0
locals {
ebs_devices = [for d in attrs.ebs_block_device : d] # set → list
# Count devices that are NOT encrypted; if zero, all are encrypted
unencrypted = [for d in local.ebs_devices : d if !core::try(d.encrypted, false)]
all_encrypted = core::length(local.unencrypted) == 0
}
enforce {
condition = local.all_encrypted
error_message = "All EBS block devices on the instance must have encryption enabled."
}
}
# ✅ CORRECT — Multiple optional nested blocks: guard each independently.
resource_policy "aws_instance" "check" {
locals {
ebs_raw = core::try(attrs.ebs_block_device, null)
has_ebs = local.ebs_raw != null ? core::length(local.ebs_raw) > 0 : false
ebs_devices = local.has_ebs ? [for d in local.ebs_raw : d] : []
# ✅ "no unencrypted devices" pattern — works on any tfpolicy version.
# On 0.3.0+, core::alltrue([for d in local.ebs_devices : core::try(d.encrypted, false)]) is equivalent.
unencrypted = [for d in local.ebs_devices : d if core::try(d.encrypted, false) != true]
all_encrypted = !local.has_ebs || core::length(local.unencrypted) == 0
}
enforce {
condition = local.all_encrypted
error_message = "All EBS block devices must be encrypted."
}
}Rule: Optional nested blocks (those that may be absent on some resource instances) must always be guarded with filter or core::try before indexing. Accessing attrs.block[0] on an absent block propagates null through every dependent local, eventually reaching the condition expression as an unknown value — which tfpolicy cannot reduce to a boolean, causing Error: unknown condition at runtime. This error does not appear in tfpolicy test (mocked data always has the block present) — it only surfaces against real plans.
Pattern summary:
- Add
filter = core::try(attrs.block, null) != null && core::try(core::length(attrs.block), 0) > 0to skip resources without the block. The double-core::tryform is safe in both real plan evaluation and policytest mocks that omit the attribute. - Convert the block set to a list:
[for item in attrs.block : item]. - Use
core::try(item.attr, <safe_default>)on individual attributes inside the loop. - When a block is truly optional and its absence means "compliant", use
!local.has_block || <check>so resources without the block pass automatically.
core::alltrue(list) and core::anytrue(list) — tfpolicy 0.3.0+ Only
core::anytrue(list) and core::alltrue(list) are available starting in tfpolicy 0.3.0. On tfpolicy < 0.3.0 these functions do not exist, and using them anywhere — including in locals, in for...if filter expressions, or in enforce conditions — produces:
Error: Call to unknown function
There is no function named "anytrue" in namespace core::.or
Error: Call to unknown function
There is no function named "alltrue" in namespace core::.Semantics (0.3.0+):
core::alltrue(list)—trueif every element istrue. Empty list →true(vacuous truth).falseornullfound →false; unknown with nofalseornullpresent → unknown.core::anytrue(list)—trueif any element istrue. Empty list →false.nullelements are ignored.truefound →trueeven if other elements are unknown; notruefound but an unknown element is present → unknown.- Parameter type is
list(bool):null, booleans, and boolean-like strings ("true","false","1","0") are accepted/coerced; numbers, nested lists, and other strings error withall elements must be boolean values.
# ✅ tfpolicy 0.3.0+ — direct use
locals {
all_encrypted = core::alltrue([for d in local.devices : core::try(d.encrypted, false)])
any_public = core::anytrue([for r in local.rules : r.cidr == "0.0.0.0/0"])
}
# ✅ tfpolicy < 0.3.0 — use core::length() with list comprehension instead
locals {
# "all encrypted" = no unencrypted devices exist
unencrypted = [for d in local.devices : d if !core::try(d.encrypted, false)]
all_encrypted = core::length(local.unencrypted) == 0
# "any public" = at least one public rule exists
public_rules = [for r in local.rules : r if r.cidr == "0.0.0.0/0"]
any_public = core::length(local.public_rules) > 0
}
# ✅ CORRECT on any version — boolean conditions in for...if: use plain && / || operators
locals {
bad_rules = [
for rule in attrs.ingress : rule
if (rule.protocol == "tcp" && rule.from_port == 22) # ✅ plain boolean — safe
]
wide_open_rules = [
for rule in attrs.ingress : rule
if (rule.cidr_blocks == ["0.0.0.0/0"] || rule.ipv6_cidr_blocks == ["::/0"]) # ✅ safe
]
}
# ✅ ALSO CORRECT — two-pass pattern for complex conditions
locals {
ingress_with_flags = [
for rule in attrs.ingress : {
rule = rule
is_ssh_tcp = rule.protocol == "tcp" && rule.from_port == 22
}
]
ssh_tcp_rules = [for r in local.ingress_with_flags : r.rule if r.is_ssh_tcp] # ✅ safe
}Rule: On tfpolicy 0.3.0+, prefer core::alltrue() / core::anytrue() directly for readability. On tfpolicy < 0.3.0, replace them with core::length() patterns:
- Instead of
core::anytrue(list_of_bools)→core::length([for b in list_of_bools : b if b]) > 0 - Instead of
core::alltrue(list_of_bools)→core::length([for b in list_of_bools : b if !b]) == 0 - For filtering: use plain
&&/||boolean operators infor...ifclauses instead (works on any version). - Both functions accept
list(bool)coercions (null, booleans,"true","false","1","0"). Numbers, nested lists, and other strings error — keep list comprehensions producing plain booleans when possible.
❌ Mistake 26: Assuming core::try() Returns the Default When an Attribute Is Explicitly null
# ❌ WRONG — destination_ranges exists but is null; core::try() does NOT catch null values.
# core::try() only catches attribute-access errors (missing attributes / index-out-of-bounds).
# When the attribute is present but set to null, core::try() returns null — NOT the default [].
# Passing null to core::length(), core::contains(), or a for-loop then causes:
# "Invalid value for "list" parameter: argument must not be null"
resource_policy "google_compute_firewall" "check" {
locals {
dest_ranges = core::try(attrs.destination_ranges, []) # ❌ returns null, not []
bad_ranges = [for r in local.dest_ranges : r if r == "0.0.0.0/0"] # ❌ crashes
}
}
# ❌ ALSO WRONG — looks safe but crashes in `locals`! TFPolicy does NOT short-circuit `&&` in locals.
# Both sides of `&&` are always evaluated, so core::length(null) is called even when
# logging_raw is null, causing: "Invalid value for 'collection': argument must not be null"
resource_policy "google_storage_bucket" "check" {
locals {
logging_raw = core::try(attrs.logging, null)
logging_present = local.logging_raw != null && core::length(local.logging_raw) > 0 # ❌ crashes when null!
}
}
# ✅ CORRECT — explicitly check for null after core::try(), then fall back to [].
resource_policy "google_compute_firewall" "check" {
locals {
dest_ranges_raw = core::try(attrs.destination_ranges, null)
dest_ranges = local.dest_ranges_raw != null ? local.dest_ranges_raw : []
bad_ranges = [for r in local.dest_ranges : r if r == "0.0.0.0/0"]
}
}
# ✅ CORRECT — use ternary to guard core::length() call; ternary DOES short-circuit.
resource_policy "google_storage_bucket" "check" {
locals {
logging_raw = core::try(attrs.logging, null)
logging_not_null = local.logging_raw != null
logging_length = local.logging_not_null ? core::length(local.logging_raw) : 0 # ✅ ternary safe
logging_present = local.logging_not_null && local.logging_length > 0
}
}
# ✅ ALSO CORRECT — inline ternary in one line (equivalent to above).
locals {
dest_ranges = core::try(attrs.destination_ranges, null) != null ? attrs.destination_ranges : []
}Rule: core::try(expr, default) catches attribute-access errors (e.g. missing attribute, index out of range) and returns default in that case. It does NOT substitute default when the attribute exists but its value is null. Always use the two-step pattern:
core::try(attrs.field, null)— safe access; returnsnullon missing attribute OR on null value!= null ? attrs.field : <safe_default>— explicit null guard before passing to list functions
CRITICAL: && does NOT short-circuit in TFPolicy locals blocks, condition = expressions inside enforce {} blocks, or for...if predicates. Even if the left side local.var != null is false, the right side (e.g. core::length(local.var) or core::contains([...], local.var)) will still be evaluated and crash. Always use the ternary operator (condition ? value_if_true : value_if_false) to conditionally call functions on potentially-null values.
# ❌ WRONG — condition = also does NOT short-circuit; crashes when local.X is null
enforce {
condition = local.X != null && core::contains(["a", "b"], local.X) # ❌ crashes!
}
# ✅ CORRECT — use ternary inside the locals block, then reference in condition
locals {
is_allowed = local.X != null ? core::contains(["a", "b"], local.X) : false
}
enforce {
condition = local.is_allowed
}
# ❌ WRONG — for...if predicate also does NOT short-circuit; crashes when r.field is absent
violating = [
for r in local.rules : r
if core::try(r.field, null) != null && core::contains(["a", "b"], r.field) # ❌ crashes!
]
# ✅ CORRECT — use ternary in for...if predicate; also wrap the second access with core::try
violating = [
for r in local.rules : r
if (core::try(r.field, null) != null ? core::contains(["a", "b"], core::try(r.field, "")) : false)
](Note: && in filter = does short-circuit for null values in real plan evaluation — optional absent attributes are treated as null in the Terraform resource schema, so local.raw != null && core::length(local.raw) > 0 (where local.raw = core::try(attrs.field, null)) is safe. However, in policytest mocks, if the mock completely omits an optional block attribute (rather than including it as null), the attrs object is a strict HCL literal that truly lacks that attribute. In that case, core::try(attrs.field, null) != null && core::length(attrs.field) > 0 fails: core::try catches the error on the first access and returns null, but the second bare attrs.field still throws "does not have attribute named 'field'" because && does not protect against the independent evaluation error. Safe patterns that work in both real plans and policytest:
- Pre-capture:
local.raw = core::try(attrs.field, null)thenfilter = local.raw != null && core::length(local.raw) > 0 - Double-wrap:
filter = core::try(attrs.field, null) != null && core::try(core::length(attrs.field), 0) > 0)
String interpolation + null: core::try() catches errors (missing attributes, index out of range), NOT null values. ${core::try(local.X, "default")} returns null — not "default" — when local.X is null, causing: "The expression result is null. Cannot include a null value in a string template.". Fix: use ternary in the string template: ${local.X != null ? local.X : "default"}.
Affected functions: core::length(), core::contains(), core::join(), for loops, and any function that requires a non-null list/map argument will crash if passed null. Apply the pattern wherever a list/set attribute may be absent or explicitly null in the provider schema.
❌ Mistake 27: Duplicating Common Values Across Multiple Policy Blocks
# ❌ WRONG — The same allowlist is copied into every resource_policy block.
# Changing the list requires editing multiple blocks, which is error-prone.
resource_policy "azurerm_linux_virtual_machine" "allowed_sizes" {
locals {
allowed_sizes = ["Standard_D2s_v3", "Standard_D4s_v3"]
}
enforce {
condition = core::contains(local.allowed_sizes, core::try(attrs.size, ""))
error_message = "VM size is not in the allowed list."
}
}
resource_policy "azurerm_windows_virtual_machine" "allowed_sizes" {
locals {
allowed_sizes = ["Standard_D2s_v3", "Standard_D4s_v3"] # ❌ duplicated value
}
enforce {
condition = core::contains(local.allowed_sizes, core::try(attrs.size, ""))
error_message = "VM size is not in the allowed list."
}
}
# ✅ CORRECT — Extract the shared value to a top-level locals block.
# All policy blocks reference it via local.<name>. One change updates every policy.
locals {
allowed_sizes = ["Standard_D2s_v3", "Standard_D4s_v3"]
}
resource_policy "azurerm_linux_virtual_machine" "allowed_sizes" {
enforce {
condition = core::contains(local.allowed_sizes, core::try(attrs.size, ""))
error_message = "VM size is not in the allowed list."
}
}
resource_policy "azurerm_windows_virtual_machine" "allowed_sizes" {
enforce {
condition = core::contains(local.allowed_sizes, core::try(attrs.size, ""))
error_message = "VM size is not in the allowed list."
}
}Rule: When two or more resource_policy, module_policy, or provider_policy blocks share a local variable that holds the same constant value (e.g. an allowlist, blocklist, threshold, or configuration string), extract it to a top-level locals {} block and reference it as local.<name> in each policy. This follows the DRY principle — the value has a single source of truth.
When to extract:
- Any literal list, map, string, or number used identically in two or more policy blocks
- Computed values derived from the same constant inputs across multiple blocks (e.g. a formatted string built from shared constants)
When NOT to extract:
- A value specific to exactly one resource type with no meaning outside that block
- Any value that depends on
attrs.*— those are resource-scoped and must stay inside the policy block (top-levellocalscannot accessattrs)
❌ Mistake 28: String Concatenation with + Is Not Supported
Error: Invalid operand — Unsuitable value for left operand: a number is required.
# ❌ WRONG — The + operator does not concatenate strings in tfpolicy
locals {
pattern = "^com\\.amazonaws\\..+\\." + input.service_name # ❌ runtime error
}
# ✅ CORRECT — Use ${ } interpolation for dynamic string building
locals {
pattern = "^com\\.amazonaws\\..+\\.${input.service_name}"
error_msg = "Service name must match pattern com.amazonaws.<region>.${input.service_name}."
}
# ✅ ALSO CORRECT — Avoid dynamic regex patterns entirely; use core::contains_substring
locals {
service_ok = core::startswith(local.svc, "com.amazonaws.") &&
core::contains_substring(local.svc, input.service_name)
}Rule: String concatenation using + is not supported in tfpolicy HCL. Use "${expr}" interpolation syntax for all dynamic string construction.
❌ Mistake 29: enforcement_level Defined Multiple Times
Error: Attribute redefined — The argument "enforcement_level" was already set at line X.
# ❌ WRONG — enforcement_level appears twice, once before each enforce block
resource_policy "aws_vpc_endpoint" "check" {
locals { ... }
enforcement_level = "advisory"
enforce {
condition = local.is_interface
error_message = "..."
}
enforcement_level = "advisory" # ❌ ERROR: already defined above
enforce {
condition = local.service_matches
error_message = "..."
}
}
# ✅ CORRECT — enforcement_level appears exactly once for the whole block
resource_policy "aws_vpc_endpoint" "check" {
locals { ... }
enforcement_level = "advisory" # ✅ declared once
enforce {
condition = local.is_interface
error_message = "..."
}
enforce {
condition = local.service_matches
error_message = "..."
}
}Rule: enforcement_level is an attribute of the policy block itself, not of each enforce block. Declare it exactly once per resource_policy/module_policy/provider_policy block, at the same level as locals and enforce. Multiple enforce blocks within one policy block all share the same enforcement_level.
❌ Mistake 30: Referencing local.* Inside a For-Object Comprehension
Error: Undefined Reference — The reference "local.origin_domain" is not defined.
# ❌ WRONG — keys defined inside the for-object literal are NOT in local scope
locals {
origin_checks = [
for origin in local.origins : {
origin_domain = core::try(origin.domain_name, "")
is_s3_origin = core::contains_substring(local.origin_domain, ".s3.") # ❌ local.origin_domain is unknown here
is_compliant = !local.is_s3_origin || local.has_oac_id # ❌ local.is_s3_origin is unknown here
}
]
}
# ✅ CORRECT — repeat the expression inline; each key is independent
locals {
origin_checks = [
for origin in local.origins : {
origin_domain = core::try(origin.domain_name, "")
is_s3_origin = core::contains_substring(core::try(origin.domain_name, ""), ".s3.")
is_compliant = !core::contains_substring(core::try(origin.domain_name, ""), ".s3.") ||
(core::try(origin.oac_id, null) != null && core::try(origin.oac_id, "") != "")
}
]
}
# ✅ ALSO CORRECT — two-pass pattern: compute properties first, then combine
locals {
origins_with_props = [
for origin in local.origins : {
domain = core::try(origin.domain_name, "")
has_oac = core::try(origin.origin_access_control_id, null) != null
}
]
# Now the second pass can reference the object's own keys by iterating:
origin_checks = [
for o in local.origins_with_props : {
is_s3 = core::contains_substring(o.domain, ".s3.")
is_compliant = !core::contains_substring(o.domain, ".s3.") || o.has_oac
}
]
}Rule: Inside a for ... : { ... } object comprehension, keys defined within the same object literal cannot be referenced via local.key_name. The local.* scope only contains entries from the surrounding locals {} block. Either:
- Inline the expression wherever needed (repeat it), or
- Use a two-pass approach: compute intermediate values in one for-comprehension, then reference them by the iteration variable in a second for-comprehension.
❌ Mistake 31: Ternary Branches with Inconsistent Object Types
Error: The true and false result expressions must have consistent types. The 'true' value includes object attribute "X", which is absent in the 'false' value.
# ❌ WRONG — the true branch produces an object with "Statement" key,
# but the false branch is an empty object {} without that key
locals {
policy_doc = local.appears_inline ? core::try(core::jsondecode(local.policy_value), {}) : {}
statements = core::try(local.policy_doc.Statement, [])
}
# ✅ CORRECT — both branches must produce objects with the SAME set of keys
locals {
policy_doc = local.appears_inline
? core::try(core::jsondecode(local.policy_value), { Statement = [] })
: { Statement = [] }
statements = core::try(local.policy_doc.Statement, [])
}
# ✅ ALSO CORRECT — avoid the ternary entirely; use core::try for the safe path
locals {
# Always parse; core::try returns empty-statement fallback if decoding fails or is not applicable
policy_doc = core::try(core::jsondecode(local.policy_value), { Statement = [] })
statements = core::try(local.policy_doc.Statement, [])
}Rule: In tfpolicy HCL, both branches of cond ? a : b must return values of identical type and shape. This is especially important for objects: if the true branch returns an object with key K, the false branch must also include key K with a compatible type. Mismatched object shapes cause a compile-time type error. Use a consistent fallback object (e.g. { Statement = [] }) or avoid the ternary by using core::try directly on the full expression.
❌ Mistake 32: inputs Block Inside a Resource Test Block
Error: An argument named "inputs" is not expected here. Did you mean to define a block of type "inputs"?
# ❌ WRONG — inputs block placed INSIDE a resource test block
resource "aws_db_instance" "pass_instance" {
inputs = { # ❌ inputs is not valid inside resource {}
resource_type = "aws_db_instance"
source_type = "db-instance"
}
attrs = { ... }
}
# ✅ CORRECT — inputs is a TOP-LEVEL block in the policytest file,
# and it applies to ALL test cases in that file
policytest {
targets = ["my-policy.policy.hcl"]
}
inputs { # ✅ top-level block, applies to all resources below
resource_type = "aws_db_instance"
source_type = "db-instance"
}
resource "aws_db_instance" "pass_instance" {
attrs = { ... }
}Rule: The inputs {} block is a top-level construct in a .policytest.hcl file. It overrides input block defaults for every test case in that file. It is not an attribute or a nested block inside resource, data, or module test blocks. If you need different input values for different test resources, put them in separate .policytest.hcl files, each with its own top-level inputs {} block.
❌ Mistake 33: data_policy Block Type Does Not Exist
Error: Blocks of type "data_policy" are not expected here.
# ❌ WRONG — data_policy is NOT a valid policy block type
data_policy "aws_iam_policy_document" "permissive_actions_denied" {
enforce {
condition = local.no_star_actions
error_message = "IAM policy documents must not use wildcard actions."
}
}
# ✅ CORRECT — use resource_policy for actual Terraform resources
# To check IAM policy content, parse the inline_policy or the policy document
# that is attached to an actual resource (aws_iam_policy, aws_iam_role, etc.)
resource_policy "aws_iam_policy" "permissive_actions_denied" {
locals {
policy_doc = core::try(core::jsondecode(core::try(attrs.policy, "{}")), { Statement = [] })
statements = core::try(local.policy_doc.Statement, [])
star_stmts = [for s in local.statements : s if core::contains(core::try(s.Action, []), "*")]
no_star_actions = core::length(local.star_stmts) == 0
}
enforce {
condition = local.no_star_actions
error_message = "IAM managed policies must not use wildcard (*) actions."
}
}Rule: The only valid top-level policy block types are:
resource_policy "<resource_type>" "<name>"— evaluates Terraform-managed resourcesmodule_policy "<source_path>" "<name>"— evaluates Terraform modulesprovider_policy "<provider_type>" "<name>"— evaluates provider configuration
data_policy does not exist. Data sources (e.g. aws_iam_policy_document, aws_caller_identity) are not evaluated via policy blocks. To enforce content constraints on IAM documents, write a resource_policy that targets the resource (aws_iam_policy, aws_iam_role, etc.) and parses the policy attribute using core::jsondecode.
❌ Mistake 34: Duplicate Local Variable or Policy Block Definitions
Error (duplicate local): The local expression "..." is already defined. Each local expression must have a unique name.
Error (duplicate policy block): The resource block "..." is already defined. Each resource_policy block must have a unique resource type + name combination.
# ❌ WRONG — same local variable name defined twice in the same locals {} block
resource_policy "aws_vpc" "flow_logging_enabled" {
locals {
flow_logs = core::getresources("aws_flow_log", {})
matching_logs = [for fl in local.flow_logs : fl if fl.vpc_id == attrs.id]
flow_logs = core::try(attrs.enable_dns_support, false) # ❌ duplicate name!
}
enforce { ... }
}
# ❌ WRONG — same resource_policy block defined twice (same type + name)
resource_policy "aws_vpc" "flow_logging_enabled" {
locals { ... }
enforce { condition = ... }
}
resource_policy "aws_vpc" "flow_logging_enabled" { # ❌ duplicate!
locals { ... }
enforce { condition = ... }
}
# ✅ CORRECT — unique local names + all checks in a single resource_policy block
# Fixes both mistakes above:
# 1. Each local has a distinct name (flow_logs vs dns_support) — no duplicate-name error.
# 2. Both checks live in one "aws_vpc" / "flow_logging_enabled" block with two enforce
# blocks — no duplicate-policy-block error.
# NOTE: aws_flow_log.vpc_id links to the parent VPC — filter inline by attrs.id; never use
# a top-level core::getresources("aws_flow_log", {}) + HCL for-loop filter (Mistake 13).
resource_policy "aws_vpc" "flow_logging_enabled" {
locals {
flow_logs = core::getresources("aws_flow_log", { vpc_id = attrs.id })
dns_support = core::try(attrs.enable_dns_support, false) # unique name — no conflict
has_flow_log = core::length(local.flow_logs) > 0
}
enforce {
condition = local.has_flow_log
error_message = "VPC must have at least one flow log configured."
}
enforce {
condition = local.dns_support
error_message = "VPC must have DNS support enabled."
}
}Rules:
- Within a single
locals {}block, every local variable name must be unique. If you copy-paste or refactor, check for accidental name reuse. - Within a single policy file, every
resource_policy "type" "name"combination must be unique. To add multiple checks on the same resource type, either add moreenforceblocks to the existing policy block, or use a differentnamelabel (e.g."flow_logging_enabled"vs"flow_logging_destination").
❌ Mistake 35: Using || Chains for Enum/Allowlist Checks Instead of core::contains()
Problem: When checking if an attribute value belongs to a set of allowed values, chaining || comparisons is verbose, harder to maintain, and doesn't match idiomatic TF Policy style.
# ❌ WRONG — verbose chain, hard to maintain
locals {
ssl_mode = core::try(attrs.ssl_mode, null)
is_valid = local.ssl_mode == "require" || local.ssl_mode == "verify-ca" || local.ssl_mode == "verify-full"
}
# ✅ CORRECT — core::contains() with a named list local
locals {
ssl_mode = core::try(attrs.ssl_mode, "none")
valid_modes = ["require", "verify-ca", "verify-full"]
is_valid = core::contains(local.valid_modes, local.ssl_mode)
}
enforce {
condition = local.is_valid
error_message = "Attribute 'ssl_mode' must be one of: require, verify-ca, verify-full."
}Rules:
- Whenever a Sentinel policy uses
value in [list]orvalue in set([...]), always translate tocore::contains(allowed_list, value)in TF Policy. - Store the allowed list in a named local variable (e.g.,
valid_modes) for readability. core::contains(list, null)is safe — it returnsfalsewhen value isnull. No extra null guard is needed before callingcore::contains().- Use a non-null default in
core::try()(e.g.,core::try(attrs.ssl_mode, "none")) sonullattribute values map to a clearly non-compliant default.
❌ Mistake 36: Using Multiple core::startswith() Calls for Version Range Matching Instead of core::regex()
Problem: When a Sentinel policy checks a version string with >= or < (e.g., engine_version < "6.0"), HCL does not support string comparison operators. Using multiple core::startswith() calls for each major version is verbose and brittle — it will break if a new major version prefix appears (e.g., "0.x" or "10.x").
# ❌ WRONG — 5 separate startswith calls for versions 1.x through 5.x
locals {
is_version_lt_6 = (
core::startswith(local.engine_version, "1.") ||
core::startswith(local.engine_version, "2.") ||
core::startswith(local.engine_version, "3.") ||
core::startswith(local.engine_version, "4.") ||
core::startswith(local.engine_version, "5.")
)
}
# ✅ CORRECT — core::regex() matches all versions with major version 1–5 in one expression
# Use in filter to skip resources where engine_version is >= 6.0 or unset
filter = core::try(attrs.engine_version, "") != "" &&
core::try(core::regex("^[1-5]\\.", core::try(attrs.engine_version, "")), null) != null
locals {
auth_token = core::try(attrs.auth_token, "")
}
enforce {
condition = local.auth_token != null && local.auth_token != ""
error_message = "Attribute 'auth_token' must be set when 'engine_version' < 6.0."
}Rules:
- When a Sentinel policy compares a version string with
<or>=, identify the version boundary and translate tocore::regex(). - Always wrap in
core::try(..., null)—core::regex()returnsnullon no match (notfalse), and calling it on a null input causes an error. - Common patterns:
- Versions
< 6.0(major 1–5):core::regex("^[1-5]\\.", version) - Versions
>= 2.xand< 10.x:core::regex("^[2-9]\\.", version) - Patch versions like
"5.0.6"or"6.x"are handled correctly by the major-version prefix pattern.
- Versions
- Prefer
core::semverconstraint()if the version string is a proper SemVer (e.g.,"6.2.0"); usecore::regex()only for non-standard version strings (e.g.,"6.x","5.0.6"from AWS ElastiCache engine versions). - Never use
>,<,>=,<=on strings in HCL — HCL string comparison is lexicographic and unreliable for version ordering.
❌ Mistake 37: Anchoring resource_policy on an Optional Companion Resource Instead of the Parent
Problem: When a Sentinel policy checks an S3 companion resource (e.g., aws_s3_bucket_public_access_block), it is tempting to write resource_policy "aws_s3_bucket_public_access_block". However, this companion resource is optional — a bucket can exist in a Terraform plan with no aws_s3_bucket_public_access_block at all. Anchoring on the companion means buckets with no companion resource silently pass the policy.
# ❌ WRONG — anchored on the companion type; buckets with no public_access_block resource silently pass
resource_policy "aws_s3_bucket_public_access_block" "block_public_access" {
locals {
block_public_acls = core::try(attrs.block_public_acls, false)
}
enforce {
condition = local.block_public_acls == true
error_message = "S3 bucket must block public ACLs."
}
}
# ✅ CORRECT — anchored on the parent; a missing companion resource = false → violation is caught
# NOTE: apply-time cross-resource reference; resolves correctly at apply time.
resource_policy "aws_s3_bucket" "block_public_access" {
locals {
public_access_block = core::getresources("aws_s3_bucket_public_access_block", {
bucket = attrs.id
})
block_public_acls = core::try(local.public_access_block[0].block_public_acls, false)
block_public_policy = core::try(local.public_access_block[0].block_public_policy, false)
ignore_public_acls = core::try(local.public_access_block[0].ignore_public_acls, false)
restrict_public_buckets = core::try(local.public_access_block[0].restrict_public_buckets, false)
}
enforce {
condition = local.block_public_acls && local.block_public_policy && local.ignore_public_acls && local.restrict_public_buckets
error_message = "S3 bucket '${attrs.bucket}' must have all four public access block settings enabled."
}
}Rules:
- Always ask: "Can the parent (
aws_s3_bucket) exist in a Terraform plan WITHOUT this companion?" If YES → anchor on the parent, not the companion. - The following S3 companions MUST always be checked via
resource_policy "aws_s3_bucket":aws_s3_bucket_public_access_block— lookup:{ bucket = attrs.id }(apply-time inline)aws_s3_bucket_acl— use parent anchor when enforcing that every bucket has a compliant ACL; use directresource_policy "aws_s3_bucket_acl"only when checking ACL's own attribute values on ACLs that already existaws_s3_bucket_logging— lookup:{ bucket = attrs.id }(apply-time inline)aws_s3_bucket_server_side_encryption_configuration— lookup:{ bucket = attrs.id }(apply-time inline)aws_s3_bucket_versioning— plan-time: top-levelcore::getresources, filter byv.bucket == attrs.bucket
- The Sentinel source pattern does not matter. Even if the original Sentinel iterates over companion resource types, the TFPolicy MUST anchor on the parent.
- Requirement translation: If requirement.txt says "every
aws_s3_bucket_public_access_blockmust have X = true", reframe it as "everyaws_s3_bucketmust have a companionaws_s3_bucket_public_access_blockwith X = true; a missing companion is a violation." This reframing is mandatory before writing HCL. - Use apply-time inline
core::getresources("companion", { bucket = attrs.id })inside theresource_policy "aws_s3_bucket"block.
❌ Mistake 38: Checking Only aws_iam_policy for IAM Content Rules — Missing Inline Policy Resource Types
Symptom: Policy only checks aws_iam_policy for privilege conditions (e.g. "no admin *:*") but misses inline policies attached directly to roles, users, and groups.
# ❌ INCOMPLETE — only catches standalone managed policies
resource_policy "aws_iam_policy" "no_admin_privileges" {
locals {
statements = core::try(core::jsondecode(core::try(attrs.policy, "{}")).Statement, [])
admin_stmts = [for s in local.statements : s if ...]
}
enforce { condition = core::length(local.admin_stmts) == 0 ... }
}
# Missing: aws_iam_role_policy, aws_iam_user_policy, aws_iam_group_policy
# ✅ CORRECT — cover all 4 inline policy resource types; each has attrs.policy (JSON string)
# Each resource_policy block is fully self-contained — attrs is only available inside a
# resource_policy, module_policy, or provider_policy block, not in top-level locals.
resource_policy "aws_iam_policy" "no_admin_privileges" {
locals {
statements = core::try(core::jsondecode(core::try(attrs.policy, "{}")).Statement, [])
admin_stmts = [for s in local.statements : s if core::lower(core::try(s.Effect, "Allow")) == "allow" && core::contains(core::try(s.Action, []), "*") && core::contains(core::try(s.Resource, []), "*")]
}
enforce {
condition = core::length(local.admin_stmts) == 0
error_message = "IAM policies must not grant full admin privileges (*:* on *)."
}
}
resource_policy "aws_iam_role_policy" "no_admin_privileges" {
locals {
statements = core::try(core::jsondecode(core::try(attrs.policy, "{}")).Statement, [])
admin_stmts = [for s in local.statements : s if core::lower(core::try(s.Effect, "Allow")) == "allow" && core::contains(core::try(s.Action, []), "*") && core::contains(core::try(s.Resource, []), "*")]
}
enforce {
condition = core::length(local.admin_stmts) == 0
error_message = "IAM role inline policies must not grant full admin privileges (*:* on *)."
}
}
resource_policy "aws_iam_user_policy" "no_admin_privileges" {
locals {
statements = core::try(core::jsondecode(core::try(attrs.policy, "{}")).Statement, [])
admin_stmts = [for s in local.statements : s if core::lower(core::try(s.Effect, "Allow")) == "allow" && core::contains(core::try(s.Action, []), "*") && core::contains(core::try(s.Resource, []), "*")]
}
enforce {
condition = core::length(local.admin_stmts) == 0
error_message = "IAM user inline policies must not grant full admin privileges (*:* on *)."
}
}
resource_policy "aws_iam_group_policy" "no_admin_privileges" {
locals {
statements = core::try(core::jsondecode(core::try(attrs.policy, "{}")).Statement, [])
admin_stmts = [for s in local.statements : s if core::lower(core::try(s.Effect, "Allow")) == "allow" && core::contains(core::try(s.Action, []), "*") && core::contains(core::try(s.Resource, []), "*")]
}
enforce {
condition = core::length(local.admin_stmts) == 0
error_message = "IAM group inline policies must not grant full admin privileges (*:* on *)."
}
}Rule: Any IAM content enforcement (wildcard actions, admin privileges, etc.) MUST cover all 4 inline policy resource types:
| Resource type | When it's used | policy attribute |
|---|---|---|
aws_iam_policy |
Standalone managed policy | JSON string |
aws_iam_role_policy |
Inline policy attached to a role | JSON string |
aws_iam_user_policy |
Inline policy attached to a user | JSON string |
aws_iam_group_policy |
Inline policy attached to a group | JSON string |
All 4 have the same attrs.policy JSON string — use core::jsondecode(core::try(attrs.policy, "{}")) on each.
Note on aws_iam_policy_document: This is a Terraform DATA SOURCE. data_policy does not exist in tfpolicy (see Mistake 31). Do NOT write a resource_policy "aws_iam_policy_document" to enforce IAM content — use the 4 managed/inline resource types above instead.
❌ Mistake 39: Repeating core::try() Calls in Complex For-Loop Predicates
Problem: When a for...if list comprehension has a complex filter predicate that references the same attribute multiple times via core::try() (e.g. core::try(rule.from_port, 0), core::try(rule.to_port, 0), core::try(rule.protocol, "") each appearing 3–5 times), the expression becomes an unreadable single line that is hard to maintain and verify.
# ❌ WRONG — core::try(rule.from_port, 0) appears 4 times; core::try(rule.to_port, 0) appears
# 4 times; core::try(rule.protocol, "") appears 5 times in one predicate.
violating_rules = [for rule in local.ingress_rules : rule if (core::contains(core::try(rule.cidr_blocks, []), "0.0.0.0/0") || core::contains(core::try(rule.ipv6_cidr_blocks, []), "::/0")) && (core::try(rule.protocol, "") == "all" || core::try(rule.protocol, "") == "-1" || (core::try(rule.protocol, "") == "tcp" && core::length([for p in input.authorized_tcp_ports : p if p >= core::try(rule.from_port, 0) && p <= core::try(rule.to_port, 0)]) != (core::try(rule.to_port, 0) - core::try(rule.from_port, 0) + 1)) || (core::try(rule.protocol, "") == "udp" && core::length([for p in input.authorized_udp_ports : p if p >= core::try(rule.from_port, 0) && p <= core::try(rule.to_port, 0)]) != (core::try(rule.to_port, 0) - core::try(rule.from_port, 0) + 1)))]# ✅ CORRECT — two-phase approach: map items to enriched objects (extract sub-expressions
# into named fields), then filter on those named fields.
locals {
enriched_rules = [for rule in local.ingress_rules : {
rule = rule
has_public_ip = core::contains(core::try(rule.cidr_blocks, []), "0.0.0.0/0") || core::contains(core::try(rule.ipv6_cidr_blocks, []), "::/0")
protocol = core::try(rule.protocol, "")
from_port = core::try(rule.from_port, 0)
to_port = core::try(rule.to_port, 0)
}]
violating_rules = [for r in local.enriched_rules : r.rule if r.has_public_ip && (r.protocol == "all" || r.protocol == "-1" || (r.protocol != "tcp" && r.protocol != "udp") || (r.protocol == "tcp" && core::length([for p in input.authorized_tcp_ports : p if p >= r.from_port && p <= r.to_port]) != (r.to_port - r.from_port + 1)) || (r.protocol == "udp" && core::length([for p in input.authorized_udp_ports : p if p >= r.from_port && p <= r.to_port]) != (r.to_port - r.from_port + 1)))]
}When to use the two-phase pattern:
- A single field is referenced 3 or more times in the predicate via
core::try()(each call is a repeated sub-expression). - The predicate contains a nested for-loop that re-accesses the same outer-loop variable.
- The resulting predicate is too long to comfortably fit on a single line (required by Mistake 7).
Benefits:
- Each
core::try()call is written exactly once — no repeated accesses. - Named fields (
r.from_port,r.protocol) are self-documenting. - The filter predicate is shorter and the structure matches the original Sentinel logic.
- Easier to debug: inspect
local.enriched_rulesdirectly to see computed intermediate values.
Rule summary: When a for-loop predicate repeats the same core::try(rule.field, default) call more than twice, split into two steps: (1) map items to enriched objects with computed fields, (2) filter on those fields. See also the "Multi-Stage Filtering for Readability" best practice below.
Best Practices
✅ Provider Schema Awareness
Rule: tfpolicy exposes raw provider schemas without transformation
Implication: You need to understand the actual provider schema:
- Sets remain sets (not converted to lists)
- Know whether attributes are optional
- Understand nested object structures
Example:
# attrs.ingress is a SET (per AWS provider), but iteration works the same
for rule in attrs.ingress : rule.from_portTip: Use terraform console or provider docs to inspect schemas
✅ Filter Pattern: Null + Length Check
Rule: Always check both null and length for collection filters; wrap both accesses when the attribute may be absent from policytest mocks
# ✅ Best practice — safe in real plans AND policytest mocks that omit the attribute
filter = core::try(attrs.ingress, null) != null && core::try(core::length(attrs.ingress), 0) > 0
# ✅ Also correct — pre-capture ensures the second operand references a local (never absent)
# local.ingress_raw = core::try(attrs.ingress, null)
# filter = local.ingress_raw != null && core::length(local.ingress_raw) > 0
# ⚠️ Works but less efficient (doesn't filter empty collections)
filter = core::try(attrs.ingress, null) != null
# ❌ RISKY — second attrs.ingress access is not wrapped; fails in policytest mocks
# that completely omit the attribute (where attrs is a strict HCL object without the key)
filter = core::try(attrs.ingress, null) != null && core::length(attrs.ingress) > 0Why: In real Terraform plan evaluation, absent optional attributes are represented as null in the resource schema, and filter && short-circuits after null != null = false. In policytest mocks however, if the mock omits an attribute, attrs is a strict HCL literal — the attribute genuinely does not exist, and the bare second access throws an error that && does not protect against. Always wrap the second access with core::try.
✅ Multi-Stage Filtering for Readability
Rule: Break complex logic into multiple local variables
# ✅ Good: Multi-stage filtering
locals {
# Stage 1: Filter to relevant items
ssh_rules = [
for rule in attrs.ingress :
rule if rule.from_port <= 22 && rule.to_port >= 22
]
# Stage 2: Filter to violations
public_ssh_rules = [
for rule in local.ssh_rules :
rule if core::contains(core::try(rule.cidr_blocks, []), "0.0.0.0/0")
]
# Stage 3: Check compliance
is_compliant = core::length(local.public_ssh_rules) == 0
}
Benefits:
- More readable and maintainable
- Easier to debug (inspect intermediate lists)
- Can reuse filtered lists for multiple checks
- Better error messages possible
✅ Multiple Focused Policies
Rule: Use multiple enforce blocks within a single resource_policy block to separate concerns — do NOT split checks on the same resource type into multiple resource_policy blocks (SKILL.md Output Structure Rule 1).
# ✅ Correct — separate concerns via multiple enforce blocks in one block
resource_policy "aws_security_group" "security_group_checks" {
enforce {
condition = !local.has_public_ssh
error_message = "Security group must not allow public SSH ingress."
}
enforce {
condition = !local.has_public_rdp
error_message = "Security group must not allow public RDP ingress."
}
enforce {
condition = local.has_required_tags
error_message = "Security group must have required tags."
}
}
# ❌ Wrong — same resource type split across multiple resource_policy blocks
resource_policy "aws_security_group" "ingress_check" {
# Check ingress rules only
}
resource_policy "aws_security_group" "egress_check" {
# Check egress rules only
}Benefits:
- All checks on the same resource type are co-located and consistent
- Each
enforceblock reports independently — user sees all failures at once - Easier to maintain and test
✅ All Errors Shown Pattern
Rule: Multiple enforce blocks show all failures, not just first
resource_policy "aws_security_group" "comprehensive_check" {
enforce {
condition = !local.has_public_ssh
error_message = "SSH violation: ..."
}
enforce {
condition = !local.has_public_rdp
error_message = "RDP violation: ..."
}
enforce {
condition = local.has_description
error_message = "Description required: ..."
}
}Behavior: User sees all failing messages in encounter order
Benefit: Comprehensive feedback - users can fix all issues at once
Verified Capabilities by Policy Type
resource_policy
- ✅ Full
attrs.*access, nested attributes via dot notation - ✅
meta.provider_type,meta.tfe_workspace.tags - ✅
meta.tfe_stack.deployment_name,meta.tfe_stack.stack_name,meta.tfe_stack.deployment_group; fields are empty outside Stack evaluations - ❌
meta.addressis UNDEFINED in real plan evaluation — do not use infilter,locals,condition, orerror_message; it causesError: Unsupported attributeat runtime. Note:tfpolicy testwill NOT catch this error — onlyterraform plan --policies=will. - ✅
filter,locals, multipleenforceblocks
module_policy
- ✅
meta.source,meta.version,meta.address - ✅
filter,locals, multipleenforceblocks - ❌
attrs.*(inputs) - work in progress - ✅
meta.tfe_stack.*,meta.tfe_workspace.tags; Stack fields are empty outside Stack evaluations
provider_policy
- ✅ Full
attrs.*(config),meta.alias,meta.version,meta.source - ✅
filter,locals, multipleenforceblocks - ✅
meta.tfe_stack.*,meta.tfe_workspace.tags; Stack fields are empty outside Stack evaluations
⚠️ meta.version is the resolved version (e.g. "6.50.0"), not the constraint string (e.g. ">= 4.0"). Use core::semverconstraint(meta.version, "~> 5.0") to enforce an approved range. Test mocks should use realistic resolved version numbers, not constraint strings. Verified on tfpolicy 0.0.2-beta20260513.
Targeting Pattern:
| Policy Type | First Label | Example |
|---|---|---|
| resource_policy | Resource TYPE | "aws_instance" |
| module_policy | Full SOURCE path | "app.terraform.io/myorg/vpc/aws" |
| provider_policy | Provider TYPE | "aws" (not "hashicorp/aws") |
Testing
See the tfpolicy-test skill (tfpolicy-test.md) for comprehensive testing guidance.
Module mock syntax (two labels required)
module_policy test mocks take two labels — source and a mock name — matching the policy block's own two-label signature:
# ✅ CORRECT — two labels: source pattern, then mock name
module "registry.terraform.io/hashicorp/consul/aws" "approved" {
meta = {
source = "registry.terraform.io/hashicorp/consul/aws"
version = "0.1.0"
address = "module.consul"
}
}
# ❌ WRONG — one label causes parse error
module "registry.terraform.io/hashicorp/consul/aws" {
...
}The same applies to the policy block itself:
# ✅ CORRECT
module_policy "*" "require_private_registry" { ... }
# ❌ WRONG — "Only 1 labels (source) are expected" error (misleading message; two ARE required)
module_policy "*" { ... }Verified on tfpolicy 0.0.2-beta20260513.
Quick Decision Tree
Need to compare versions?
→ SemVer strings (e.g. "6.2.0"): Use core::semverconstraint(). Non-SemVer strings (e.g. AWS "6.x", "5.0.6"): Use core::try(core::regex("^[1-5]\\.", version), null) != null — see Mistake 36.
Checking if a value is one of several allowed values?
→ Use core::contains(allowed_list, value) — NOT chained ||. Store the list in a named local — see Mistake 35.
Enforcing a rule on an S3 companion resource (e.g. public_access_block, acl, versioning, logging)?
→ ALWAYS anchor on resource_policy "aws_s3_bucket" with core::getresources("companion", { bucket = attrs.id }) inside — NEVER anchor on the companion type directly — see Mistake 37.
Enforcing IAM content rules (no admin privileges, no wildcard actions, etc.)?
→ Write 4 separate resource_policy blocks: aws_iam_policy, aws_iam_role_policy, aws_iam_user_policy, aws_iam_group_policy. All use core::jsondecode(core::try(attrs.policy, "{}")). Do NOT use aws_iam_policy_document (data_policy does not exist) — see Mistake 38.
Need built-in Terraform function?
→ Add core:: prefix
Want to use locals in provider_policy? → Go ahead! Language server errors are false
Need string pattern matching?
→ ✅ core::startswith(str, prefix) and core::endswith(str, suffix) are available directly. For regex/substring use core::try(core::regex("pattern", string), null) != null.
Need to handle null values?
→ Use core::try(value, default)
Testing policies?
→ Create .policytest.hcl files and run tfpolicy test
Version Requirements
- Terraform: >= 1.13.0-policyYYYYMMDD (private beta builds)
- tfpolicy CLI: >= 0.0.1-alphaYYYYMMDD
- HCP Terraform: Organization with policy feature enabled
Contact
- Questions: team-tf-policy@wwpdl.vnet.ibm.com (mailto:team-tf-policy@wwpdl.vnet.ibm.com)
- Documentation: See terraform-policy-agent-skill/ directory
- Examples: See the reusable patterns in
tfpolicy-author.md(tfpolicy-author.md) and any companion example directories that may exist in your broader beta workspace
Status: ✅ All behaviors verified with user Ready for: Agent skill usage, documentation generation, policy creation Last Review: 2026-02-20
See Also: Authoring Reference (tfpolicy-author.md)
Frontmatter written into each target's SKILL.md.
Common
No fields set for this target.