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.
Rule: ALL built-in Terraform functions require core:: prefix
hcl
# ✅ Correctfilter = core::try(attrs.encrypted, false) == trueis_allowed = core::length([for v in local.version_checks : v if v]) > 0error_message = "Allowed: ${core::join(", ", local.allowed_versions)}"# ❌ Wrongfilter = 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::anytrue(list) — DOES NOT EXIST in tfpolicy runtime. Use core::length([for b in list : b if b]) > 0 instead.
❌ core::alltrue(list) — DOES NOT EXIST in tfpolicy runtime. Use core::length([for b in list : b if !b]) == 0 instead.
⚠️ 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:
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.
hcl
# ✅ CORRECT — filter value "table/${attrs.name}" comes from attrs.* — must be inlineresource_policy "aws_dynamodb_table" "autoscaling_required" { locals { table_resource_id = "table/${attrs.name}" scaling_targets = core::getresources
⚠️ 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::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:
hcl
# ✅ 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) : -1range_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
hcl
# 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 validation: Every .policy.hcl file 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.
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:
hcl
# 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.
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.
hcl
# ✅ Correct — parameterized, overridable per policy setinput "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 configurablelocals { 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.
Rule: Use operations = [...] to restrict when a policy fires. Use prior_attrs to read pre-change state on delete/update.
hcl
# ✅ Fires only on create and update — never on destroyresource_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 destroyedresource_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
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.
# 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
# Wrong - substring matching doesn't workmodule_policy "vpc" "check" { } # Won't match modules with "vpc" in source# Correct - use full source path or wildcardmodule_policy "app.terraform.io/myorg/vpc/aws" "check" { }module_policy "*" "check" { } # For all modules
Fix: Module targeting requires FULL source path, not substring
# ❌ 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_policyresource_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:
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.
hcl
# Example of a LINKING attribute (makes inline core::getresources() correct):# aws_emr_cluster has: security_configuration = aws_emr_security_configuration.x.name# → The cluster references the security config by name → inline lookup is valid.# Example of NO linking attribute (makes inline core::getresources() WRONG):# aws_instance has NO attribute that references aws_ec2_instance_metadata_defaults.# aws_ec2_instance_metadata_defaults has NO attribute that references aws_instance.# → They are independent → each gets its own separate resource_policy block.
⛔ Sentinel fallback pattern between independent resources — never use core::getresources() as a fallback.
When a Sentinel policy has logic like:
“if resource A does not have attribute X configured, then check if independent resource B has Y as a fallback”
— TFPolicy cannot express this cross-resource fallback because A and B share no linking attribute.
Correct TFPolicy conversion:
Use filter on resource A to skip instances where X is absent (they are out of scope for A’s check).
Enforce Y directly on resource B via its own separate resource_policy block.
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, then HCL-filtered by attrs.* value inside resource_policylocals { 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.
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:
hcl
# ✅ 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 levellocals { 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 resourcelocals { 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.
# ❌ WRONG — meta.address is UNDEFINED in resource_policy real-plan evaluationresource_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 interpolationresource_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.
# ❌ WRONG — Only covers 2 of 4 SG resource types; misses modern VPC APIresource_policy "aws_security_group" "check" { ... }resource_policy "aws_security_group_rule" "check" { ... }# ✅ CORRECT — Cover all 4 AWS security group resource typesresource_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:
hcl
# ❌ WRONG — Only covers Classic ELB; ALB/NLB (the modern standard) are silently skippedresource_policy "aws_elb" "ssl_policy_check" { ... }# ✅ CORRECT — Cover all 3 ELB resource typesresource_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.
# ❌ 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 setresource_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.
# ❌ 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 — core::range() with dynamic attrs.* values silently returns# an empty list in the policytest framework (policytest limitation only).# Do not use core::range() with dynamic port attributes.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.
# ❌ 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 conditionresource_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] : [] # ✅ Use "no unencrypted devices" pattern — core::alltrue() does NOT exist unencrypted = [for d in local.ebs_devices : d if core::try(d.encrypted, false) != true] all_encrypted = !local.has_ebs || core::length(local.unencrypted) == 0 } enforce { condition = local.all_encrypted error_message = "All EBS block devices must be encrypted." }}
Rule: Optional nested blocks (those that may be absent on some resource instances) must always be guarded with filter or core::try before indexing. Accessing attrs.block[0] on an absent block propagates null through every dependent local, eventually reaching the condition expression as an unknown value — which tfpolicy cannot reduce to a boolean, causing Error: unknown condition at runtime. This error does not appear in tfpolicy test (mocked data always has the block present) — it only surfaces against real plans.
Pattern summary:
Add filter = core::try(attrs.block, null) != null && core::try(core::length(attrs.block), 0) > 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.
Convert the block set to a list: [for item in attrs.block : item].
Use core::try(item.attr, <safe_default>) on individual attributes inside the loop.
When a block is truly optional and its absence means “compliant”, use !local.has_block || <check> so resources without the block pass automatically.
CRITICAL:core::anytrue() and core::alltrue() are not available in the tfpolicy runtime. Using them anywhere — including in locals, in for...if filter expressions, or in enforce conditions — will produce:
Error: Call to unknown functionThere is no function named "anytrue" in namespace core::.
or
Error: Call to unknown functionThere is no function named "alltrue" in namespace core::.
hcl
# ❌ WRONG — core::alltrue() does NOT exist in tfpolicy runtimelocals { all_encrypted = core::alltrue([for d in local.devices : core::try(d.encrypted, false)])}# ❌ WRONG — core::anytrue() does NOT exist in tfpolicy runtimelocals { any_public = core::anytrue([for r in local.rules : r.cidr == "0.0.0.0/0"])}# ✅ CORRECT — use core::length() with list comprehension instead of core::alltrue()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}# ✅ CORRECT — use core::length() instead of core::anytrue()locals { # "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 — boolean conditions in for...if: use plain && / || operatorslocals { 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 conditionslocals { 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:core::anytrue() and core::alltrue() do not exist. 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.
# ❌ WRONG — destination_ranges exists but is null; core::try() does NOT catch null values.# core::try() only catches attribute-access errors (missing attributes / index-out-of-bounds).# When the attribute is present but set to null, core::try() returns null — NOT the default [].# Passing null to core::length(), core::contains(), or a for-loop then causes:# "Invalid value for "list" parameter: argument must not be null"resource_policy "google_compute_firewall" "check" { locals { dest_ranges = core::try(attrs.destination_ranges, []) # ❌ returns null, not [] bad_ranges = [for r in local.dest_ranges : r if r == "0.0.0.0/0"] # ❌ crashes }}# ❌ ALSO WRONG — looks safe but crashes in `locals`! TFPolicy does NOT short-circuit `&&` in locals.# Both sides of `&&` are always evaluated, so core::length(null) is called even when# logging_raw is null, causing: "Invalid value for 'collection': argument must not be null"resource_policy "google_storage_bucket" "check" { locals { logging_raw = core::try(attrs.logging, null) logging_present = local.logging_raw != null && core::length(local.logging_raw) > 0 # ❌ crashes when null! }}# ✅ CORRECT — explicitly check for null after core::try(), then fall back to [].resource_policy "google_compute_firewall" "check" { locals { dest_ranges_raw = core::try(attrs.destination_ranges, null) dest_ranges = local.dest_ranges_raw != null ? local.dest_ranges_raw : [] bad_ranges = [for r in local.dest_ranges : r if r == "0.0.0.0/0"] }}# ✅ CORRECT — use ternary to guard core::length() call; ternary DOES short-circuit.resource_policy "google_storage_bucket" "check" { locals { logging_raw = core::try(attrs.logging, null) logging_not_null = local.logging_raw != null logging_length = local.logging_not_null ? core::length(local.logging_raw) : 0 # ✅ ternary safe logging_present = local.logging_not_null && local.logging_length > 0 }}# ✅ ALSO CORRECT — inline ternary in one line (equivalent to above).locals { dest_ranges = core::try(attrs.destination_ranges, null) != null ? attrs.destination_ranges : []}
Rule:core::try(expr, default) catches attribute-access errors (e.g. missing attribute, index out of range) and returns default in that case. It does NOT substitute default when the attribute exists but its value is null. Always use the two-step pattern:
core::try(attrs.field, null) — safe access; returns null on missing attribute OR on null value
!= null ? attrs.field : <safe_default> — explicit null guard before passing to list functions
CRITICAL: && does NOT short-circuit in TFPolicy locals blocks, condition = expressions inside enforce {} blocks, or for...if predicates. Even if the left side local.var != null is false, the right side (e.g. core::length(local.var) or core::contains([...], local.var)) will still be evaluated and crash. Always use the ternary operator (condition ? value_if_true : value_if_false) to conditionally call functions on potentially-null values.
hcl
# ❌ WRONG — condition = also does NOT short-circuit; crashes when local.X is nullenforce { condition = local.X != null && core::contains(["a", "b"], local.X) # ❌ crashes!}# ✅ CORRECT — use ternary inside the locals block, then reference in conditionlocals { 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 absentviolating = [ 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::tryviolating = [ 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:
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.
# ❌ 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)
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.
Error:Undefined Reference — The reference "local.origin_domain" is not defined.
hcl
# ❌ WRONG — keys defined inside the for-object literal are NOT in local scopelocals { 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 independentlocals { 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 combinelocals { origins_with_props = [ for origin in local.origins : { domain = core::try(origin.domain_name, "") has_oac = core::try(origin.origin_access_control_id, null) != null } ] # Now the second pass can reference the object's own keys by iterating: origin_checks = [ for o in local.origins_with_props : { is_s3 = core::contains_substring(o.domain, ".s3.") is_compliant = !core::contains_substring(o.domain, ".s3.") || o.has_oac } ]}
Rule: Inside a for ... : { ... } object comprehension, keys defined within the same object literal cannot be referenced via local.key_name. The local.* scope only contains entries from the surrounding locals {} block. Either:
Inline the expression wherever needed (repeat it), or
Use a two-pass approach: compute intermediate values in one for-comprehension, then reference them by the iteration variable in a second for-comprehension.
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.
hcl
# ❌ WRONG — the true branch produces an object with "Statement" key,# but the false branch is an empty object {} without that keylocals { 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 keyslocals { 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 pathlocals { # 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.
Error:An argument named "inputs" is not expected here. Did you mean to define a block of type "inputs"?
hcl
# ❌ WRONG — inputs block placed INSIDE a resource test blockresource "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 filepolicytest { 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.
Error:Blocks of type "data_policy" are not expected here.
hcl
# ❌ WRONG — data_policy is NOT a valid policy block typedata_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:
data_policydoes 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.
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.
hcl
# ❌ WRONG — same local variable name defined twice in the same locals {} blockresource_policy "aws_vpc" "flow_logging_enabled" { locals { flow_logs = core::getresources("aws_flow_log", {}) matching_logs = [for fl in local.flow_logs : fl if fl.vpc_id == attrs.id] flow_logs = core::try(attrs.enable_dns_support, false) # ❌ duplicate name! } enforce { ... }}# ❌ WRONG — same resource_policy block defined twice (same type + name)resource_policy "aws_vpc" "flow_logging_enabled" { locals { ... } enforce { condition = ... }}resource_policy "aws_vpc" "flow_logging_enabled" { # ❌ duplicate! locals { ... } enforce { condition = ... }}# ✅ CORRECT — unique local names + all checks in a single resource_policy block# Fixes both mistakes above:# 1. Each local has a distinct name (flow_logs vs dns_support) — no duplicate-name error.# 2. Both checks live in one "aws_vpc" / "flow_logging_enabled" block with two enforce# blocks — no duplicate-policy-block error.# NOTE: aws_flow_log.vpc_id links to the parent VPC — filter inline by attrs.id; never use# a top-level core::getresources("aws_flow_log", {}) + HCL for-loop filter (Mistake 13).resource_policy "aws_vpc" "flow_logging_enabled" { locals { flow_logs = core::getresources("aws_flow_log", { vpc_id = attrs.id }) dns_support = core::try(attrs.enable_dns_support, false) # unique name — no conflict has_flow_log = core::length(local.flow_logs) > 0 } enforce { condition = local.has_flow_log error_message = "VPC must have at least one flow log configured." } enforce { condition = local.dns_support error_message = "VPC must have DNS support enabled." }}
Rules:
Within a single locals {} block, every local variable name must be unique. If you copy-paste or refactor, check for accidental name reuse.
Within a single policy file, every resource_policy "type" "name" combination must be unique. To add multiple checks on the same resource type, either add more enforce blocks to the existing policy block, or use a different name label (e.g. "flow_logging_enabled" vs "flow_logging_destination").
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.
hcl
# ❌ WRONG — verbose chain, hard to maintainlocals { 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 locallocals { ssl_mode = core::try(attrs.ssl_mode, "none") valid_modes = ["require", "verify-ca", "verify-full"] is_valid = core::contains(local.valid_modes, local.ssl_mode)}enforce { condition = local.is_valid error_message = "Attribute 'ssl_mode' must be one of: require, verify-ca, verify-full."}
Rules:
Whenever a Sentinel policy uses value in [list] or value in set([...]), always translate to core::contains(allowed_list, value) in TF Policy.
Store the allowed list in a named local variable (e.g., valid_modes) for readability.
core::contains(list, null) is safe — it returns false when value is null. No extra null guard is needed before calling core::contains().
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.
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").
hcl
# ❌ WRONG — 5 separate startswith calls for versions 1.x through 5.xlocals { 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 unsetfilter = core::try(attrs.engine_version, "") != "" && core::try(core::regex("^[1-5]\\.", core::try(attrs.engine_version, "")), null) != nulllocals { auth_token = core::try(attrs.auth_token, "")}enforce { condition = local.auth_token != null && local.auth_token != "" error_message = "Attribute 'auth_token' must be set when 'engine_version' < 6.0."}
Rules:
When a Sentinel policy compares a version string with < or >=, identify the version boundary and translate to core::regex().
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.
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.
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).
Never use >, <, >=, <= on strings in HCL — HCL string comparison is lexicographic and unreliable for version ordering.
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.
hcl
# ❌ WRONG — anchored on the companion type; buckets with no public_access_block resource silently passresource_policy "aws_s3_bucket_public_access_block" "block_public_access" { locals { block_public_acls = core::try(attrs.block_public_acls, false) } enforce { condition = local.block_public_acls == true error_message = "S3 bucket must block public ACLs." }}# ✅ CORRECT — anchored on the parent; a missing companion resource = false → violation is caught# NOTE: apply-time cross-resource reference; resolves correctly at apply time.resource_policy "aws_s3_bucket" "block_public_access" { locals { public_access_block = core::getresources("aws_s3_bucket_public_access_block", { bucket = attrs.id }) block_public_acls = core::try(local.public_access_block[0].block_public_acls, false) block_public_policy = core::try(local.public_access_block[0].block_public_policy, false) ignore_public_acls = core::try(local.public_access_block[0].ignore_public_acls, false) restrict_public_buckets = core::try(local.public_access_block[0].restrict_public_buckets, false) } enforce { condition = local.block_public_acls && local.block_public_policy && local.ignore_public_acls && local.restrict_public_buckets error_message = "S3 bucket '${attrs.bucket}' must have all four public access block settings enabled." }}
Rules:
Always ask: “Can the parent (aws_s3_bucket) exist in a Terraform plan WITHOUT this companion?” If YES → anchor on the parent, not the companion.
The following S3 companions MUST always be checked via resource_policy "aws_s3_bucket":
aws_s3_bucket_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_versioning — plan-time: top-level core::getresources, filter by v.bucket == attrs.bucket
The Sentinel source pattern does not matter. Even if the original Sentinel iterates over companion resource types, the TFPolicy MUST anchor on the parent.
Requirement translation: If requirement.txt says “every aws_s3_bucket_public_access_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.
Use apply-time inline core::getresources("companion", { bucket = attrs.id }) inside the resource_policy "aws_s3_bucket" block.
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.
hcl
# ❌ INCOMPLETE — only catches standalone managed policiesresource_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.
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.
hcl
# ❌ 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)))]
hcl
# ✅ 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}
When to use the two-phase pattern:
A single field is referenced 3 or more times in the predicate via core::try() (each call is a repeated sub-expression).
The predicate contains a nested for-loop that re-accesses the same outer-loop variable.
The resulting predicate is too long to comfortably fit on a single line (required by Mistake 7).
Benefits:
Each core::try() call is written exactly once — no repeated accesses.
Named fields (r.from_port, r.protocol) are self-documenting.
The filter predicate is shorter and the structure matches the original Sentinel logic.
Easier to debug: inspect local.enriched_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.
Rule: Always check both null and length for collection filters; wrap both accesses when the attribute may be absent from policytest mocks
hcl
# ✅ Best practice — safe in real plans AND policytest mocks that omit the attributefilter = 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.
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).
hcl
# ✅ Correct — separate concerns via multiple enforce blocks in one blockresource_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 blocksresource_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
✅ Full attrs.* access, nested attributes via dot notation
✅ meta.provider_type, meta.tfe_workspace
❌ 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.
✅ Full attrs.* (config), meta.alias, meta.version, meta.source
✅ filter, locals, multiple enforce blocks
❌ meta.tfe_workspace - resource_policy only
⚠️ 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.
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