Generated banner for Terraform Policy
Generated logo for Terraform Policy

Terraform Policy

HashiCorpOfficialPublished by HashiCorp

  • Skill
  • Tool integration
  • Cloud and DevOps
  • MPL-2.0
  • No issues found

From the author

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

AutoGineer summary

Written by AutoGineer from the skill’s own files, not by the author

This skill serves as a central interface for managing Terraform Policy files, including .policy.hcl and .policytest.hcl formats. It routes requests to specialized sub-skills for authoring policies or writing test suites, while providing version-specific guidance for the tfpolicy CLI. It supports the creation of new policies, the conversion of legacy Sentinel policies, and the development of test mocks to ensure policy compliance.

What it does

  • Routes requests to specialized sub-skills for policy authoring and testing.
  • Provides version-aware guidance for tfpolicy CLI versions 0.3.x, 0.4.x, and 0.5.x.
  • Assists in converting existing Sentinel policies to the Terraform Policy format.
  • Facilitates the creation of .policy.hcl files from natural-language requirements.
  • Supports the development of .policytest.hcl files to validate policy logic.

When to use it

  • When you need to write a new Terraform Policy.
  • When you are migrating legacy Sentinel policies to Terraform Policy.
  • When you need to create or debug test files for your policies.
  • When you require guidance on tfpolicy CLI features or syntax.

Advantages

  • Centralized management of policy authoring and testing workflows.
  • Version-specific instructions ensure compatibility with different CLI releases.
  • Streamlined conversion process for legacy Sentinel codebases.
  • Clear separation of concerns between policy definition and test validation.

Usage examples

“Block EC2 instances without encryption”
The skill routes the request to the authoring sub-skill to generate the appropriate resource_policy block.
“Convert this Sentinel policy to tfpolicy”
The skill translates the Sentinel logic into an idiomatic .policy.hcl file.
“Write a policytest for my EBS policy”
The skill routes the request to the testing sub-skill to generate a .policytest.hcl file with necessary mocks.

Install

  1. Download the skill as a ZIP:

    https://autogineer.com/agent-skills/hashicorp/terraform-policy/download
  2. Unzip it into the cross-client skills folder of your project:

    mkdir -p .agents/skills && unzip terraform-policy.zip -d .agents/skills

Documentation by HashiCorp

terraform-policy

UTILITY SKILL — INVOKES: tfpolicy-author | tfpolicy-test

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.4.x and 0.5.x. The 0.3.x bullet below is baseline guidance that applies to every supported line. When a new minor ships, drop the oldest line and add the new one. Inline notes for versions earlier than 0.3.0 are retained only for older CLIs.

  • If the CLI is 0.3.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. 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). 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). core::alltrue(list) and core::anytrue(list) are available — prefer them over the core::length() list-comprehension workaround (see tfpolicy-author). meta.tfe_stack and meta.tfe_workspace.tags are available to resource, provider, and module policies; Stack fields are empty strings outside Stack evaluations on 0.3.x and 0.4.x (null on 0.5.x+, see below). On 0.3.x only, the no-op operation does not exist: resource policies evaluate only resources that the run creates, updates, or deletes, and the default operations is ["create", "update"].

  • If the CLI is 0.4.x or newer, all 0.3.x guidance still applies except the operations default, plus the no-op operation, which evaluates resources that the run leaves unchanged (the entire state). operations accepts "create", "update", "delete", and "no-op". For policies that do not reference prior_attrs, the default operations is now ["create", "update", "no-op"], so omitting it evaluates every resource in state. Use operations = ["create", "update"] to evaluate only changed resources. no-op exposes the existing values through attrs, and prior_attrs is unavailable. delete cannot be combined with create or no-op (["update", "delete"] is valid), and prior_attrs policies must set operations explicitly. Attrs-only policies (no prior_attrs) calling core::getresources whose operations omit no-op load with a warning; do not add no-op to a prior_attrs policy, which fails validation. In tests, set meta.operation = "no-op" explicitly because it is never inferred (see tfpolicy-author and tfpolicy-test).

  • If the CLI is 0.5.x or newer, all 0.4.x guidance still applies, with two changes. (1) Absent meta attributes are exposed as null instead of an empty string: meta.alias (no alias), meta.version (no version specified, including modules), and meta.tfe_stack.deployment_group / deployment_name / stack_name (workspace evaluations or not set). meta.tfe_workspace.tags is still an empty map. Passing null to a function such as core::semverconstraint, or interpolating it into a string such as error_message, fails, and && does not short-circuit, so guard with a conditional: meta.version != null ? core::semverconstraint(meta.version, ">= 2.0.0") : false. Equality checks are null-safe (meta.alias == "secondary" is false when there is no alias), but checks like meta.tfe_stack.stack_name == "" no longer match (use == null). (2) prior_attrs is rejected at parse time in provider_policy and module_policy blocks; it is valid only in resource_policy (update and delete operations). Use attrs. for provider attributes (see tfpolicy-author).

  • If the CLI version is unknown, ask the user to check it first or provide guidance that clearly distinguishes the 0.3.x, 0.4.x, and 0.5.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

TaskSub-skill
Write or convert a .policy.hcl policytfpolicy-author
Write or debug a .policytest.hcl testtfpolicy-test

Examples

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

Files

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 (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.

  • The user is converting a Sentinel test to a .policytest.hcl test — start with tfpolicy-author, 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 Terraform Policy syntax, function names, runtime limitations. Anything in conflict with the testing guide should defer to this file.

See Also


Table of Contents

  1. Testing Basics

  2. Resource Policy Testing

  3. Module Policy Testing

  4. Provider Policy Testing

  5. Advanced Techniques

  6. 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) 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 do nothing (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", "no-op"] ("no-op" requires tfpolicy 0.4.0+). Test mocks support a matching prior_attrs = { ... } block alongside attrs = { ... }, so create, update, no-op, and delete-gate policies are all fully testable.

Mock shape per operation:

Operation being testedProvide attrsProvide prior_attrs
create✅ planned values—
update✅ planned values✅ pre-change values
delete—✅ pre-change values
no-op (0.4.0+)✅ existing values—

If meta.operation is omitted, tfpolicy infers the operation from the state blocks: attrs only → create, attrs + prior_attrs → update, prior_attrs only → delete. It never infers no-op, so set meta = { operation = "no-op" } explicitly on no-op mocks. Otherwise a policy scoped to ["no-op"] skips them: passing mocks pass without being evaluated, and expect_failure mocks fail with "Missing expected failure".

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"] }
}

No-op policy (tfpolicy 0.4.0+, evaluates unchanged resources in state):

# Policy (operations omitted = ["create", "update", "no-op"])
resource_policy "aws_cloudtrail" "logging_enabled" {
  enforce {
    condition     = attrs.enable_logging == true
    error_message = "CloudTrail logging must be enabled."
  }
}
# Test
resource "aws_cloudtrail" "existing_trail" {
  meta  = { operation = "no-op" }
  attrs = { name = "existing-trail", s3_bucket_name = "my-bucket", enable_logging = true }
}

resource "aws_cloudtrail" "existing_trail_no_logging" {
  expect_failure = true
  meta           = { operation = "no-op" }
  attrs          = { name = "trail-without-logging", s3_bucket_name = "my-bucket", enable_logging = false }
}
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 AttributeMock 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 (null on 0.5.0+)
meta.tfe_stack.stack_name✅ Available in tfpolicy 0.3.x+ mocks✅ Always present; empty outside Stack evaluations (null on 0.5.0+)
meta.tfe_stack.deployment_group✅ Available in tfpolicy 0.3.x+ mocks✅ Always present; empty outside Stack evaluations (null on 0.5.0+)
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 are empty strings outside Stack evaluations on 0.3.x/0.4.x and null on 0.5.0+ (guard with != null; in mocks, set them to null explicitly to simulate a workspace, because an omitted key is missing rather than null and reading it fails), 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 = meta.version != null ? core::semverconstraint(meta.version, ">= 4.0.0") : false
    error_message = "Module must use version >= 4.0.0, found ${meta.version != null ? meta.version : "no 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") (on 0.5.0+ wrap it as meta.version != null ? core::semverconstraint(...) : false, since an unspecified version is null and && does not short-circuit)

  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:

ScenarioMock patternWhy it matters
Missing attribute (omitted entirely)attrs = { bucket = "x" } — target attribute not presentCrashes policies that don't use core::try(); most common real-world gap
Null attributeattrs = { ..., field = null }Tests core::try() default handling
Empty listattrs = { ..., items = [] }Policies expecting non-empty collections must handle []
Empty stringattrs = { ..., value = "" }String-check policies must not treat "" as compliant
Boundary valueExact 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 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 TypeTest BlockAvailable Attributes
resource_policyresource "type" "name" { attrs = {...} }attrs.*, meta.provider_type (real plans only)
module_policymodule "source" "name" { meta = {...} }meta.source, meta.address, meta.version
provider_policyprovider "type" "name" { meta = {...} }meta.source
data sourcedata "type" "name" { attrs = {...} }attrs.*
Common Features
FeatureSyntaxScope
Test targetpolicytest { targets = ["file.policy.hcl"] }Optional (best practice when multiple policies in dir)
Override input valuesinputs { key = value }Per test file — uses policy default if omitted
Expected failureexpect_failure = trueAll policies
Skip evaluationskip = trueResources only
Cross-resource refresource_type.name.attrs.attributeSame file only
Omitted attributesDon'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), 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.



Requirements and compatibility

Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta

“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.

Needs: Needs Node.js

Security

No issues found by our static scan· No issues found

A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.

Attribution and licence

Published by
HashiCorp
Publisher
HashiCorp (Official)
Source
hashicorp/agent-skills · plugins/terraform/skills/terraform-policy · commit 5e30fa5
Licence
Mozilla Public License 2.0 (redistribution allowed)
  • LICENSE (added by AutoGineer from LICENSE)
  • ATTRIBUTION.md (written by AutoGineer)
Synced

AutoGineer is not affiliated with or endorsed by HashiCorp or HashiCorp.

Download ZIP

The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download terraform-policy.zip