AthenodeAthenode

Back to Terraform

terraform-policy

Created here

Write, test, or convert Terraform Policy files (.policy.hcl, .policytest.hcl, Sentinel→tfpolicy). Triggers: policy.hcl, policytest, convert sentinel, tfpolicy, write a policy.

SKILL.md

terraform-policy

UTILITY SKILL — INVOKES: tfpolicy-author (references/tfpolicy-author.md) | tfpolicy-test (references/tfpolicy-test.md)

USE FOR:

  • Writing a new .policy.hcl policy from a description or requirement
  • Converting a .sentinel policy to Terraform Policy
  • Writing or debugging a .policytest.hcl test 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-level policy { required_providers { ... } } block when authoring .policy.hcl files containing resource or provider policies. It is mandatory for tfpolicy validate; version-range validation is best effort, and wildcard targets such as resource_policy "*" are not schema-validated. tfpolicy test does not preflight mocked attrs/prior_attrs against provider schemas, core::alltrue/core::anytrue do not exist, and, only in this 0.2.x line, mock resource {} blocks may omit attrs/prior_attrs entirely.
  • If the CLI is 0.3.x or newer, the other guidance above still applies, but the 0.2.x allowance for omitting resource state does not: every mock resource {} block in .policytest.hcl files must declare attrs or prior_attrs; if both evaluate to empty, the test case is skipped (provider {} and module {} mocks are unaffected) (see tfpolicy-test (references/tfpolicy-test.md#every-resource--mock-must-declare-a-non-empty-state-block-tfpolicy-030)). tfpolicy test reuses the target .policy.hcl's existing top-level policy { required_providers { ... } } block (there is no separate .policytest.hcl-level declaration) to validate provider, resource, and data-source policies and core::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) and core::anytrue(list) are also available — prefer them over the core::length() list-comprehension workaround (see tfpolicy-author (references/tfpolicy-author.md#core-functions--common-idioms)). meta.tfe_stack and meta.tfe_workspace.tags are 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.x and 0.3.x paths.

DO NOT USE FOR:

  • Writing .tftest.hcl files for Terraform modules — use terraform-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

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 examples
  • references/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 approximation
  • README.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 via core::getresources() (Limited)
  • cloudfront-associated-with-waf - approximation only due to missing reference metadata (Not convertible as 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 via core::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 convertible as 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 convertible as 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/v2 config inspection becomes a planned-value check in tfpolicy
  • The converted policy focuses on whether kms_key_id is 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 positive condition
  • maps.get(res, "values.ssl_mode", null) becomes core::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_ids is present directly on the network ACL, or
  • a matching aws_network_acl_association can be found via core::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_group
  • aws_security_group_rule
  • aws_vpc_security_group_ingress_rule
  • aws_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 empty becomes core::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", ...) becomes core::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 become core::try(local.endpoint_options[0]....)
  • One compound Sentinel predicate becomes multiple focused enforce blocks
  • 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_options and subnet_ids in 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/v2
  • tfconfig-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.hcl file ("block public RDS", "require encryption", "deny instance types outside an allowlist").
  • The user has a Sentinel .sentinel file (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, or provider_policy block.
  • 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() or core::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.hcl test file — use tfpolicy-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_providers is mandatory for .policy.hcl validation.
  • 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_providers declares provider source and version constraints for validation. This is distinct from meta.version in provider_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_attrs holds pre-change state.
  • operations = ["update"] — fires only on updates; prior_attrs available.
  • Default (no operations) = create and update (never destroy).
  • prior_attrs is only accessible when "create" is NOT in operations.
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 in input.
  • Use locals or 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 input unless 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 explicitly null.

  • Membership: core::contains(list, value) — for lists. For string substring use core::contains_substring.

  • Strings: core::startswith, core::endswith, core::contains_substring, core::regex (throws on no match — wrap in core::try), core::split(separator, string) (use with core::parseint() for numeric decomposition — see verified-syntax.md Section 2 for full examples). ❌ Never use + for string concatenation — + is numeric addition only; using it with strings throws Error: 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+, use core::alltrue(list) and core::anytrue(list) for boolean collection checks; on tfpolicy 0.2.x, use filtered-count patterns instead. See verified-syntax.md for 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 from lower up to (but not including) upper. Works with hardcoded integer literals. ⚠️ With dynamic attrs.* 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 (see verified-syntax.md Mistake 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 in core::try(..., false) to handle non-semver or unparseable version strings gracefully. ⚠️ Prefer this over core::split + core::parseint for 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::semverconstraint directly in a filter = expression. Even with != null and != "" guards in the same expression, semverconstraint is evaluated regardless of short-circuit ordering in the filter context and throws a parse error when the version string is malformed (e.g. a non-semver string like "x.y") or null/empty. Always move it into locals and wrap with core::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 to null, core::try returns null — not the default. This is a silent pitfall: core::length(null) crashes with Invalid value for "collection" parameter; null == false evaluates as null (not true), 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 == false

    For filter expressions that must exclude both null and 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 be null, use core::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::unmarshal does not exist — use core::jsondecode instead.

  • 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 index attrs.encryption_config[0].provider[0].key_arn. Use core::try(attrs.field, []) and field[0].subattr.
    • Object block (e.g. redirect = { port = "443", protocol = "HTTPS" }): access directly attrs.redirect.port. Use core::try(attrs.redirect.port, "").
    • ❌ Never call core::length() on an object — core::length requires a list, map, or tuple. Calling core::length(attrs.redirect) when redirect is an object crashes with collection must be a list, a map or a tuple. To check presence of an object block, use core::try(attrs.redirect, null) != null instead.
    • ❌ Never iterate over an object block with a for expression. for r in core::try(attrs.block, []) — when attrs.block is 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 use redirect = [{ ... }] (list), use list indexing. Mismatching the shape causes either runtime crashes or silent wrong results.
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 data block (the common case in real Terraform configurations) — data_policy does not exist in tfpolicy (Mistake 31). Do NOT write data_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 resource in .policytest.hcl mocks and when the Sentinel source reads it via tfstate/v2 — resource_policy "aws_iam_policy_document" is valid and directly targets the document's statement attribute (lowercase actions, not Action). When a Sentinel policy reads aws_iam_policy_document from tfstate, the correct TFPolicy conversion is resource_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., "every aws_lb_listener must use HTTPS") → anchor resource_policy directly on the child type
  • "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") → anchor resource_policy on 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_vpc with inline core::getresources("aws_flow_log", {vpc_id = attrs.id}); when checking "every LB has at least one compliant listener" → anchor on aws_lb with inline core::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_bucket with 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_attr value → 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 (not attrs.bucket) when filtering S3 child resources such as aws_s3_bucket_public_access_block, aws_s3_bucket_acl, aws_s3_bucket_server_side_encryption_configuration, and aws_s3_bucket_policy. Terraform providers set the child resource's linking attribute (bucket) to the parent bucket's .id. Using attrs.id ensures 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 in resource_policy and crashes at runtime. tfpolicy test will NOT catch this; only a real terraform plan will.
  • Use error_message for all enforceable violations (condition can be false).
  • Use info_message only in non-convertible stub blocks where condition = true and no real enforcement is possible. Never use info_message in 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 a Simplify or Not convertible quality 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 or print() 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
  1. Time-based rules — core::timestamp() + core::formatdate() + core::parseint() cover Sentinel's time import. All values are UTC; document that assumption in policy comments.
  2. param blocks — direct equivalent: input blocks with type and default.
  3. rc.change.before for update/delete — prior_attrs is available when operations does NOT include "create".
  4. Integer range checks — Sentinel policies that check whether all ports within [from_port, to_port] are authorized CAN be converted. Use the count approach: filter authorized_ports to those within the range and compare the count to to_port - from_port + 1. Do not use core::range() with dynamic attrs.* values. See verified-syntax.md Mistake 23.
  5. tfconfig/v2 reference count — each resource reference is stored twice in .references (once as resource.name, once as resource.name.id). When simplifying a reference-count check to a direct core::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 inside resource_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)
  1. Mocking/testing infrastructure (import "tfconfig-functions") — tfpolicy uses .policytest.hcl. See the tfpolicy-test skill (tfpolicy-test.md).
  2. Custom Sentinel imports — limited plugin support; use HTTP plugins or native functions if available.
  3. Sentinel simulator / built-in test framework — replace with .policytest.hcl test files.
  4. Cross-workspace data access — tfpolicy evaluates a single plan. Use workspace tags (meta.tfe_workspace.tags) or external plugins.
  5. print() / debug statements — no debug output mechanism; rely on concise error_message / info_message text only when it adds remediation context.
  6. Stateful logic across evaluations — policies are stateless; use external systems via plugins if state is required.
  7. rc.change.before outside delete/update — for first-time creates there is no pre-state.
  8. 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 using core::getresources() with a value-based filter — see "Plan-Time vs Apply-Time Policies" above.
  9. Data source content inspection by address — core::getdatasource() requires filter attributes and cannot query by Terraform address.
  10. 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 using core::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_document outputs 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 on resource_policy "aws_s3_bucket", fetching the child aws_s3_bucket_policy via core::getresources("aws_s3_bucket_policy", { bucket = attrs.id }), and inspecting its policy attribute via core::jsondecode() inside the parent block. This follows the standard dependent-child pattern — aws_s3_bucket_policy always 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 (.id vs .arn). The required argument name is the linking attribute; use the corresponding attrs.id or attrs.arn in the core::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 in resource_policy and throws Error: Unsupported attribute at runtime for every evaluated resource. tfpolicy test will not catch this; only a real terraform 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 (→ input block).
  • 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 filter for performance and to exclude resources where the attribute is meaningfully absent. Two cases:
    • Attribute absent = resource out of scope (e.g. no acl block set at all → resource doesn't configure ACLs → skip it): use filter = core::try(attrs.field, null) != null.
    • Attribute absent = AWS provider default applies (e.g. encrypted absent → AWS defaults to false → resource is still in scope and may violate the policy): do not filter on null. Use core::try(attrs.field, <aws_provider_default>) in the condition instead so absent resources are evaluated against the effective default.
  • Move complex predicates into locals for readability.
Step 3 — Generate the policy
  • Start .policy.hcl files containing resource or provider policies with a top-level policy { required_providers { ... } } block.
  • Use provider sources and version constraints that match the resource types referenced by the policy.
  • Run tfpolicy validate after authoring to confirm the policy parses and the referenced provider schemas can be resolved.
  • See Required policy.required_providers Block (#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 enforce blocks when you want independent diagnostics.
  • Never interpolate ${meta.address} in error_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
  1. Use descriptive policy names.
  2. Add a comprehensive header comment with description, resources checked, and compliance references.
  3. Always use core::try() for optional attributes.
  4. Break down complex logic with locals.
  5. Provide actionable, remediation-focused error messages.
  6. 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).
  7. Use filter to skip resources that don't apply (saves work and avoids false positives).
  8. 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 because attrs is only available inside resource_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.
  9. Avoid core::getdatasource() inside resource_policy — it calls provider APIs.
  10. Build lookup maps once for O(1) matching when iterating many resources.
  11. Keep each boolean expression on a single line (HCL parser limitation in beta).
  12. Use clear variable names (scanning_config, not sc).
  13. Convert sets to lists before indexing: [for item in set : item][0].
  14. Don't use core::try() defaults to mask missing values that should fail the policy — use filter instead.
  15. For cross-resource lookups where the filter value is the current resource's own attribute: use an inline core::getresources() with the direct filter inside resource_policy. To find the correct linking attribute name and filter value, fetch the child resource's Terraform Registry documentation at https://raw.githubusercontent.com/hashicorp/terraform-provider-aws/main/website/docs/r/{resource_name_without_aws_prefix}.html.markdown and look for the (Required) or (Optional) argument that references the parent resource. Check whether the usage examples assign it .id, .arn, or .name — use attrs.id, attrs.arn, or attrs.name accordingly. ⚠️ Some linking attributes are (Optional) in the schema (e.g. event_bus_name on aws_cloudwatch_event_bus_policy defaults 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.
  16. 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 use attrs.id, attrs.arn, or attrs.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_policy block. Write the resource_policy block on the parent type. Fetch the dependent child inside the parent block via core::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 standalone resource_policy on 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 require bucket) → never standalone for any enforcement goal; always fetched inside resource_policy "aws_s3_bucket". aws_lb_listener → standalone resource_policy "aws_lb_listener" is valid when checking every listener's own attributes (e.g., protocol, ssl_policy); use resource_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."
Communication
  1. Ask clarifying questions; don't assume requirements.
  2. Show sample passing and failing resources alongside the policy.
  3. Explain enforcement-level trade-offs.
  4. Offer simplifications when an exact rule isn't expressible.

See Also

  • tfpolicy-test (tfpolicy-test.md) — write .policytest.hcl files to validate the policies authored here.
  • ../../examples/README.md (../examples/README.md) — side-by-side Sentinel + .policy.hcl examples 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:

  1. Identify the policy type: resource_policy, module_policy, or provider_policy
  2. Use the correct structure: filter (optional), locals (optional), enforce (required)
  3. Remember: ALL built-in functions need core:: prefix
  4. For versions: Always use core::semverconstraint(), never direct comparison
  5. Validate: Check examples in this guide for patterns

Table of Contents

  1. Critical Rules (#critical-rules)
  2. Policy Structure (#policy-structure)
  3. Policy Types (#policy-types)
  4. Core Functions Reference (#core-functions-reference)
  5. 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
  1. filter - Applied first, determines which resources/modules/providers to evaluate
  2. locals - Computed once per filtered item
  3. 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:

  1. 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_policy block
    • ❌ Do not use a top-level empty-filter call ({}) and then filter by attrs.* inside resource_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
        }
    }
  2. Inline core::getresources() inside resource_policy when 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, or attrs.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."
        }
    }
  3. Use filter to 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 configuration
  • meta.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.address is UNDEFINED for resource_policy in 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].rules

Common 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 with core::semverconstraint())
  • meta.address - Module address (e.g., module.vpc)

⚠️ Current Limitations (Private Beta):

  • ❌ attrs.* (module inputs) NOT accessible yet - work in progress
  • ✅ meta.tfe_stack and meta.tfe_workspace.tags are 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.name and meta.type are NOT confirmed available in reference/verified-syntax.md. Do not rely on them — use meta.source to identify a provider and meta.version for 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].s3

Wildcards:

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 with core::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]) == 0
Safe 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 != null

Why: 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.version with core::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_policy block with multiple enforce blocks — 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.id is the linking attribute referenced by attrs.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 inline core::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 focused enforce condition

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_name is 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.hcl files, policytest { targets = [...] } blocks, resource {} / module {} mocks, or using expect_failure / skip.
  • The user is testing operation-aware policies and needs to mock attrs and/or prior_attrs for create/update/delete scenarios.
  • The user is asking how to mock cross-resource lookups (e.g. aws_s3_bucket_versioning for core::getresources patterns).
  • The user is investigating a runner caveat (mocks evaluated regardless of operations scope, expect_failure not supported on data blocks, 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.hcl test — start with tfpolicy-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 sets attrs.field = null, core::try returns null — not the default. This asymmetry means a null case 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) then val = raw != null ? raw : []) — null is explicitly normalized to a safe default. Add a pass case (no expect_failure) with the attribute set to null to verify this normalization works.
  • Explicit non-compliance check (condition = val != null && val != "") — null is intentionally treated as non-compliant. Add a fail case (expect_failure = true) with the attribute set to null.
  • Single-step only (val = core::try(attrs.field, [])) without an explicit null guard — null is not normalized and will crash downstream expressions (for val in null, core::length(null)). Do NOT add a null case. 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:

  1. 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.
  2. Use skip = true on companion resources. Resources that should be visible to core::getresources() but must not be evaluated directly by the policy should be declared with skip = true. Without this, the runner evaluates them as standalone resources, which may produce unexpected results.
  3. 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).
  4. Test the match case explicitly. Include a test case where the companion resource is present with matching attributes to confirm the lookup resolves correctly.
  5. filter on the policy affects ALL resources in the test file when companions are present. When the policy uses a top-level core::getresources()-based filter (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 fails is_linked && check_attr == VALUE → violation — even if check_attr has 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 uses filter = 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

  1. Testing Basics (#testing-basics)
  2. Resource Policy Testing (#resource-policy-testing)
  3. Module Policy Testing (#module-policy-testing)
  4. Provider Policy Testing (#provider-policy-testing)
  5. Advanced Techniques (#advanced-techniques)
  6. 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_providers lives only in .policy.hcl, not .policytest.hcl. There is no policytest { required_providers { ... } } block. On tfpolicy 0.3.0+, the existing top-level policy { required_providers { ... } } block in the targeted .policy.hcl file (see tfpolicy-author (tfpolicy-author.md#required-policyrequired_providers-block)) is reused by tfpolicy test to resolve provider schemas and preflight the attrs/prior_attrs values 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 test evaluates 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 = true on such a resource can pass for the wrong reason.
  • expect_failure = true applies to ALL policies evaluating the resource
  • expect_failure is ONLY valid on resource {} blocks — using it on data {} blocks causes Unsupported argument error

⚠️ Critical: tfpolicy test does NOT evaluate error_message

tfpolicy test only evaluates the condition expression. It never evaluates or interpolates the error_message string. This means:

  • A policy with error_message = "Failed: ${meta.address}" will pass all tfpolicy test runs even though meta.address is UNDEFINED and will crash every resource at runtime.
  • Only terraform plan --policies= evaluates error_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
  1. Multiple policies evaluate same resources - All matching policies run against all matching test resources
  2. Tests continue on failure - All tests run to completion, not stopping at first failure
  3. 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 in policy.required_providers.
  • tfpolicy test --policies=... --tests=... can still emit: Warning: Resource types not verified against provider schemas even when validate succeeded 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 test alone to catch misspelled or unknown resource types. Always run tfpolicy validate as 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 show pass.

CI/CD Usage:

tfpolicy validate --policies=./policies
tfpolicy test --policies=./policies --tests=./tests
if [ $? -eq 0 ]; then echo "Passed"; else echo "Failed"; exit 1; fi

Resource 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_failure is NOT supported on data {} blocks. Using it on a data block causes Unsupported argument "expect_failure". Only mock resource {} 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:

  1. Be provided in the mock's attrs = {} block, OR
  2. 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 targets regardless of the policy's operations scope. Keep your .policytest.hcl file targeted at a single policy (or a set of policies that share the same operation scope), and only supply the attrs / prior_attrs fields 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 test preflights every attrs/prior_attrs value against the schemas resolved from required_providers before 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_type returns UNDEFINED (test may fail)
  • With terraform plan --policies=: meta.provider_type returns "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 source
  • address - 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
  1. Official providers only: Check meta.source == "registry.terraform.io/hashicorp/aws"
  2. Version constraints: Use core::semverconstraint(meta.version, ">= 4.0.0, < 6.0.0")
  3. 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 variables
  • local - Local variables
  • attrs - Resource/data source attributes
  • meta - Metadata

Workarounds:

  1. Use resource tags for environment-based logic
  2. Separate policy sets per environment in HCP Terraform
  3. CI/CD-level enforcement based on workspace name
  4. 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) — using input {} (singular) throws Unsupported block type error.

# 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.hcl files per input scenario
  • When no inputs {} block is present, the policy's default values 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
  1. Use descriptive names: encrypted_volume_passes not test1
  2. Organize by scenario: Group passing/failing tests with comments
  3. Test edge cases: Always include missing-attribute, null, empty-collection, and boundary-value scenarios — see Mandatory Edge-Case Checklist (#mandatory-edge-case-checklist) above
  4. Consult provider schemas: Match provider's block/attribute structure
Testing Strategy
  1. Separate concerns: One test file per policy file
  2. Use skip strategically: Only when resource is referenced or in getresources() counts
  3. Test both sides of filters: Resources that match and don't match
  4. 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.hcl

Quick 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 = true ARE visible to core::getresources()
  • ✅ Use top-level locals for core::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 buckets
Common 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() and core::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 the core::range() limitation below: core::range() still returns [] for dynamic attrs.* 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.


  • 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 comparison

Constraint 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:: prefix

Common functions:

  • core::try(expr, default) - Safe access with fallback
  • core::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 elements
  • core::semverconstraint(version, constraint) - Version comparison
  • core::getresources(type, filter_map) - Query related resources
  • core::alltrue(list) / core::anytrue(list) - Boolean collection helpers, available starting in tfpolicy 0.3.0. See core::alltrue() and core::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_map argument 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.id where aws_s3_bucket.x is 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 inside resource_policy: you MAY call core::getresources() once in a top-level locals block. 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 inside resource_policy is by attrs.*: use an inline core::getresources() call with the specific per-resource filter inside resource_policy. The top-level cache with a {} empty filter plus HCL-side attrs.* 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_dimension does not make it Pattern A — the secondary resource_id == "table/${attrs.name}" filter inside resource_policy is still derived from attrs.name, so the correct approach is an inline core::getresources() call filtered by resource_id. Pre-filtering by scalable_dimension at the top level forces you to add the attrs.*-derived resource_id filter inside resource_policy, which is the anti-pattern. Use the inline call and apply the constant scalable_dimension check as a simple HCL filter after the inline fetch.

  • ⚠️ When the filter value is derived from attrs.* and that attribute is unknown at plan time (e.g. bucket = aws_s3_bucket.x.id for 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 to aws_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. Call core::getresources() inline inside the parent resource_policy with 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 ✅ NOT resource.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 inline core::getresources() inside resource_policy with the specific per-parent filter; access returned attributes directly at top level (NOT through .attrs). For truly independent/account-level resources: cache in top-level locals with 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's strings.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 with core::try(): core::try(core::regex("pattern", string), null) != null
  • ✅ core::split(separator, string) - Splits a string into a list of substrings at each occurrence of separator. Example: core::split("-", "1-100") returns ["1", "100"]; core::split("-", "22") returns ["22"]. Use with core::parseint() to parse port ranges like "start-end" without regex:
    # ✅ 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 > 22
    Note: ternary short-circuits, so core::parseint is only called when core::length == 2. When the port string is not a range (e.g. "22" or "*"), range_start and range_end default 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 check strings.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 no json:: namespace. Always use core::jsondecode instead.
    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
  • ATTRIBUTES → Represented as direct values (maps, strings, numbers, etc.)
    • Direct access: attrs.attribute_name
    • Examples: region, tags, instance_type

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:

  1. Check provider schema documentation
  2. Use terraform console to inspect structure
  3. 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 Sentinel rc.change.actions is not ["delete"]
  • operations = ["delete"] — fires only on destroy; prior_attrs holds the before-state
  • prior_attrs is only available when "create" is NOT in operations
  • Default (no operations) = fires on create and update
  • A policy cannot list both "create" and "delete" in operations. Replacement plans are evaluated as separate delete and create operations, so split policies targeting both operations into separate resource_policy blocks.

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 string
  • core::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 timestamps
  • core::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_name across 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 missed
  • core::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.0

Fix: 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 == true

Fix: Use two-step safe access pattern:

# Correct
region_value = core::try(attrs.region, null)
has_region = local.region_value != null

Why: 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) > 0

Fix: 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 modules

Fix: 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) > 0

Fix: 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 show some_attr = aws_parent.name.id or .arn, that confirms the dependency.
  • If the child IS dependent: use resource_policy "aws_parent_type" with an inline core::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); use attrs.id if the examples assign .id, attrs.arn if 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 separate resource_policy block for it. Never use core::getresources() inside another resource_policy to 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:

  1. Use filter on resource A to skip instances where X is absent (they are out of scope for A's check).
  2. Enforce Y directly on resource B via its own separate resource_policy block.
  3. 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's resource_policy locals. 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 locals block with core::getresources() results: use has_X = core::length(local.X) > 0 and guard each [0] access with local.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) != null to exclude those resources.
  • Attribute absent = AWS provider default applies (e.g. encrypted absent → AWS defaults to false, enabled absent → AWS defaults to true): the resource is in scope and should be evaluated. Do not filter on null — use core::try(attrs.field, <aws_default>) in the condition so 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 → use core::try with the provider default in condition. If the attribute is truly optional with no provider default (its absence means "this block is not configured"), use filter to 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_ports to 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_port values

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:

  1. Add filter = core::try(attrs.block, null) != null && core::try(core::length(attrs.block), 0) > 0 to skip resources without the block. The double-core::try form is safe in both real plan evaluation and policytest mocks that omit the attribute.
  2. Convert the block set to a list: [for item in attrs.block : item].
  3. Use core::try(item.attr, <safe_default>) on individual attributes inside the loop.
  4. 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) — true if every element is true. Empty list → true (vacuous truth). false or null found → false; unknown with no false or null present → unknown.
  • core::anytrue(list) — true if any element is true. Empty list → false. null elements are ignored. true found → true even if other elements are unknown; no true found 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 with all 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 in for...if clauses 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:

  1. core::try(attrs.field, null) — safe access; returns null on missing attribute OR on null value
  2. != 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) then filter = 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-level locals cannot access attrs)
❌ 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:

  1. Inline the expression wherever needed (repeat it), or
  2. 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 resources
  • module_policy "<source_path>" "<name>" — evaluates Terraform modules
  • provider_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:

  1. Within a single locals {} block, every local variable name must be unique. If you copy-paste or refactor, check for accidental name reuse.
  2. 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 more enforce blocks to the existing policy block, or use a different name label (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:

  1. Whenever a Sentinel policy uses value in [list] or value in set([...]), always translate to core::contains(allowed_list, value) in TF Policy.
  2. Store the allowed list in a named local variable (e.g., valid_modes) for readability.
  3. core::contains(list, null) is safe — it returns false when value is null. No extra null guard is needed before calling core::contains().
  4. Use a non-null default in core::try() (e.g., core::try(attrs.ssl_mode, "none")) so null attribute 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:

  1. When a Sentinel policy compares a version string with < or >=, identify the version boundary and translate to core::regex().
  2. Always wrap in core::try(..., null) — core::regex() returns null on no match (not false), and calling it on a null input causes an error.
  3. Common patterns:
    • Versions < 6.0 (major 1–5): core::regex("^[1-5]\\.", version)
    • Versions >= 2.x and < 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.
  4. Prefer core::semverconstraint() if the version string is a proper SemVer (e.g., "6.2.0"); use core::regex() only for non-standard version strings (e.g., "6.x", "5.0.6" from AWS ElastiCache engine versions).
  5. 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:

  1. 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.
  2. 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 direct resource_policy "aws_s3_bucket_acl" only when checking ACL's own attribute values on ACLs that already exist
    • aws_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-level core::getresources, filter by v.bucket == attrs.bucket
  3. The Sentinel source pattern does not matter. Even if the original Sentinel iterates over companion resource types, the TFPolicy MUST anchor on the parent.
  4. Requirement translation: If requirement.txt says "every aws_s3_bucket_public_access_block must have X = true", reframe it as "every aws_s3_bucket must have a companion aws_s3_bucket_public_access_block with X = true; a missing companion is a violation." This reframing is mandatory before writing HCL.
  5. Use apply-time inline core::getresources("companion", { bucket = attrs.id }) inside the resource_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:

  1. A single field is referenced 3 or more times in the predicate via core::try() (each call is a repeated sub-expression).
  2. The predicate contains a nested for-loop that re-accesses the same outer-loop variable.
  3. 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_rules directly 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_port

Tip: 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) > 0

Why: 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 enforce block 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.address is UNDEFINED in real plan evaluation — do not use in filter, locals, condition, or error_message; it causes Error: Unsupported attribute at runtime. Note: tfpolicy test will NOT catch this error — only terraform plan --policies= will.
  • ✅ filter, locals, multiple enforce blocks
module_policy
  • ✅ meta.source, meta.version, meta.address
  • ✅ filter, locals, multiple enforce blocks
  • ❌ 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, multiple enforce blocks
  • ✅ 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.

Ready to ship better, together?

Spec it. Decompose it. Ship it. All with your AI agent.

Start for free

Join engineers building with Athenode today.