tfpolicy with Claude Code

I have a coding agent that writes Terraform for me. It is good at it. It is also completely happy to hand me an Azure AI Search service that is wide open to the internet, authenticates with an admin key, and lives in the wrong region, all while telling me that it followed best practices.

That is not the agent being dumb. It is the agent not knowing my rules. “Best practice” is a general idea. “This company does not allow public network access on AI Search, uses Entra ID instead of keys, and keeps data in West Europe” is a specific one.

And here is the thing I got wrong the first time I built this: those rules were never mine to write. They already existed. A platform team had assigned them as Azure Policy at a management group long before I opened my editor, along with a couple of hundred more I had never read. I do not own them, I cannot edit them, and the only thing I actually need to do is comply.

My first version had me hand-writing policy files that duplicated what Azure already enforced. That is a copy, and copies drift. The moment the platform team tightens something, my local rules are quietly wrong, the agent keeps passing while real deployments start failing. So this version does the opposite: it reads the Azure Policy that is already assigned and generates the local rules from it.

The usual fix is a gate: the agent writes the Terraform, a pipeline runs a policy check, the pull request goes red, the agent reads the error and tries again. That works, it is the setup I started with. But look at what it costs, the agent only finds out it was wrong after it wrote everything, opened a PR, and burned a full plan cycle. Then next session it forgets and does it again.

This post is about a different side. The rules stay the same, but the agent can ask them a question before it writes anything. The gate is still there, I am not naive enough to trust an agent to police itself, but the gate stops being the place where the agent learns.

Two new things made that possible. tfpolicy, HashiCorp’s HCL-native policy framework, went into public beta on 16 July 2026 - you write rules in HCL, the same language as your Terraform. And MCP (Model Context Protocol) lets you hand an agent tools, not just instructions. An instruction is advice the agent may ignore. A tool is something it actually calls and gets an answer from.

🔧 Everything in this post is on GitHub: tf-policy-agent-azure

  • the generator, the plan converter, the MCP server, the policies and the pipeline. Clone it and you can reproduce every output below.

You will need Terraform >= 1.15.0, the tfpolicy 0.1.0 binary on your PATH, and Node.js 22 for the converter scripts; the README covers install and first run. This post is about how the pieces fit together, and where it bit me.

tfpolicy has no plan evaluation of its own

Here is the thing that nearly stopped me before I started. tfpolicy’s runtime enforcement lives in HCP Terraform, the paid SaaS. The free CLI only does two things: validate (are my rules syntactically sane?) and test (do my rules give the right verdict on these mock resources?). Read that literally and it says you cannot check a real plan unless you pay. That is what I assumed, and it turned out to be wrong.

A terraform plan is just data. terraform show -json turns it into a big JSON document listing every resource about to change and every attribute it will have. tfpolicy’s test format is also just resources with attributes. So the gap between them is a translation script, about ninety lines, that reads the plan JSON and writes it out as tfpolicy mocks. Feed those to tfpolicy test and you have evaluated your actual change against your actual policy files. On your laptop. In Azure Pipelines. Anywhere. No HCP Terraform, no second copy of the rules in a different language.

The rules, in plain HCL, generated not written

Here is a snippet of the policy file. Nothing clever about it: a list of “this attribute must be that value”, which is exactly what a platform rulebook is. The difference is that I did not type it. A script read it out of Azure:

# AI Search must disable public network access
# Source: Azure Policy assignment "AI workload baseline" (effect: deny)
resource_policy "azurerm_search_service" "ai_search_deny_public_network" {
  enforcement_level = "mandatory"

  enforce {
    condition     = attrs.public_network_access_enabled == false
    error_message = "Azure Policy \"AI Search must disable public network access\" would deny this resource. Enforced at runtime by assignment \"AI workload baseline\"."
  }
}
node scripts/azpolicy2tfpolicy.mjs --scope /subscriptions/<id> \
  > policies/azure-enterprise.generated.policy.hcl

The generated file is committed on purpose: when the platform team changes a rule, re-running the generator produces a diff in a pull request, and somebody has to look at it. A hand-written copy would just silently stop matching.

Two details matter. The error_message is not an afterthought - it is the single most valuable line in the file, because it is what the agent reads and what shows up in the failed build. And enforcement_level has three settings: mandatory blocks full stop, mandatory_overridable blocks but a human with the right role can wave it through, advisory warns and lets it pass. I use mandatory for security boundaries and mandatory_overridable for tagging.

3 out of 229 policies

Run the generator against a real subscription and it tells you something uncomfortable:

azpolicy2tfpolicy: scope /subscriptions/...
  mirrored:       3
     + azurerm_search_service     <- AI Search must disable public network access
     + azurerm_search_service     <- AI Search must disable API key authentication
     + azurerm_cognitive_account  <- Azure OpenAI accounts must disable key-based auth
  audit-only:     210
  not translated: 16

Three rules mirrored out of 229. I could have hidden that number. I think it is the most useful thing in the post.

210 are audit-only. Microsoft’s Cloud Security Benchmark, assigned by default to more subscriptions than anyone realises, is 226 definitions on its own, and nearly all of them are Audit. Audit does not block a deployment. Mirror all of it and the three rules that actually stop you are buried in noise, while the agent spends its time satisfying rules nobody enforces.

16 will not translate, and they are skipped by name, on stderr, never silently. Azure Policy evaluates ARM payloads; Terraform is not ARM. count expressions, nested anyOf, AuditIfNotExists, DeployIfNotExists and Modify have no tfpolicy equivalent, and pretending otherwise would generate a rule subtly weaker than the real one - worse than generating nothing.

What is left is the subset that can be checked offline in under a second. Everything else has to be asked of Azure directly, which is the next piece.

The translation nobody warns you about

The reason “just generate it” is harder than it sounds: the azurerm provider and ARM disagree about almost everything.

Terraform ARM (what policy actually sees)
public_network_access_enabled = false publicNetworkAccess = "disabled" (Search)
public_network_access_enabled = false publicNetworkAccess = "Disabled" (Cognitive Services)
local_authentication_enabled = false disableLocalAuth = true

Read those first two rows again. Same concept, same provider family, different capitalisation - and a policy comparing "disabled" to "Disabled" does not match. There is no general rule here, only a table you write by hand and verify against az provider show --expand "resourceTypes/aliases".

There is also a direction problem. A policy condition describes a violation (“deny when publicNetworkAccess != disabled”), while a tfpolicy enforce block describes what must be true. Every condition gets negated on the way across, and booleans get inverted twice - once for the negation, once because disableLocalAuth = true means local_authentication_enabled = false. Get one backwards and you generate a rule that passes exactly the resources it should reject. So the table lives in one file, used in both directions: by the generator going ARM-to-Terraform, and by the pre-flight going Terraform-to-ARM. Two tables would eventually disagree, invisibly.

Where the beta bit me

My module tests would not even parse. I had written module "avm_search" { ... } and tfpolicy 0.1.0 told me, flatly:

Error: Missing name for module
All module blocks must have 2 labels (source, name).

Modules in a test file need two labels, like resources do (module "avm_search" "compliant"). Nothing I found documented that, which is fair for a framework a week old - you only find it by running the real CLI, which is a good argument for running the real CLI before you write a blog post claiming something works. I made the same mistake in my plan converter and in my MCP server. Same bug, three places.

One rulebook, three places it gets asked

Azure Policy is the authority in all three. What changes is who answers on its behalf, and what being told costs you. Two of the three happen at the same moment, in your editor, which surprised me when I drew it honestly.

   Azure Policy (assigned by someone else 'the authority')
        |
        |  azpolicy2tfpolicy
        v
   generated .policy.hcl
        |
        |                                                    ^
        +--> agent loop    check_resource                    |
        |                  milliseconds, offline             | check_resource_azure
        |                  reads the mirror ------------------+ ~1s, asks ARM itself
        |                  (both run in your editor, before the file exists)
        |
        +--> pull request  plan -> tfpolicy test  seconds, offline, blocking

The agent loop is advisory, and it is where both fast checks happen. check_resource reads the generated mirror, so it needs no Azure credentials and works on a plane. check_resource_azure goes out to ARM and asks the authority directly. Same moment, same attributes, two different answers - the cheapest possible place to be told, because the file does not exist yet. The pull request is blocking: deterministic, in version control, reviewed by a human, and the policy engine there never calls Azure at all.

The pre-flight is not a stage of its own, and it is deliberately not in the pipeline. It could not be the gate even if I wanted it to be, because Azure lags on freshly changed assignments, and a blocking check that is sometimes twenty minutes behind the truth is not a gate, it is a coin toss. So it does the one job it is good at: telling the agent, early and cheaply, about the rules the generator could not translate. It is the only thing here that can say “a rule exists that this repo has never heard of”.

Where does the agent actually run?

Not in the pipeline. There is no Claude, no API key, and no AI of any kind in the CI job. If you went looking for it in the pipeline YAML you would not find it, and that is correct.

   WHERE YOU WRITE CODE (your laptop)          THE GATE (Azure Pipelines)
   ----------------------------------          --------------------------
   Claude Code + the policy MCP server         terraform plan  (read-only)
     list_policies / check_resource            -> plan2policytest
     check_module                              -> tfpolicy test  (blocking)
     check_resource_azure  -> ARM                    |
     list_azure_policies   -> ARM                    |
                     (all advisory)                  |
        |                                            |
        | you write .tf; the agent checks            | deterministic. no AI.
        | itself as it types                         | no idea who wrote the code
        v                                            v
   git commit -> git push --------------------> PR opens -> the gate runs

All five tools live on the left, including the two that call Azure - they run on your laptop under your login. Open the repo in Claude Code and it reads .mcp.json (which starts the policy server) and CLAUDE.md (which tells it the rules); from then on it checks its work while it drafts. You never call the tools yourself. You type “add an Azure AI Search service for the RAG index” and the agent reaches for them on its own. Nothing here is Claude-specific either - any MCP-capable agent works, only the registration file differs.

The pipeline is dumb by design. It runs terraform and tfpolicy and nothing else, and does not care whether the code in front of it was written by Claude or by Copilot. In Azure DevOps it is a read-only build validation on main: terraform plan, never apply, so the pipeline’s identity only needs Reader access, and a failing policy check blocks the merge.

That split is the point. The gate is the authority, and a probabilistic model should never be that. An agent that usually blocks bad infrastructure is not a control; a policy engine that deterministically denies it is. The agent gets the advisory copy of the rules, to be helpful early; CI gets the binding copy, to be correct.

Closing the plan gap for real

Here is the converter doing its job on an actual plan. I ran terraform plan against a live Azure subscription (read-only, a plan never creates anything), saved it as JSON, and ran it through the script:

terraform -chdir=infra plan -out=tf.plan
terraform -chdir=infra show -json tf.plan > plan.json
mkdir -p .tfpolicy-plan
node scripts/plan2policytest.mjs plan.json > .tfpolicy-plan/plan.policytest.hcl
tfpolicy test --policies=policies --tests=.tfpolicy-plan

The plan had three resources. Here is one of them as the script rewrote it into a tfpolicy mock - real output, not something I typed by hand:

resource "azurerm_search_service" "azurerm_search_service_this" {
  meta = {
    operation = "create"
  }
  attrs = {
    local_authentication_enabled  = false
    location                      = "westeurope"
    name                          = "srch-rag-prod-weu"
    public_network_access_enabled = false
    sku                           = "standard"
    tags = {
      data_classification = "confidential"
      env                 = "prod"
      owner               = "platform-team"
    }
  }
}

All three passed - and not only on my laptop. Here are those same commands running as the real gate, in Azure Pipelines: plan2policytest turns the plan into 3 resource mock(s), 0 module mock(s), and every one of them comes back pass. Note the Block policy changes from agent branches step near the top of the job list - that is the guard from further down in this post, running before anything else.

Azure Pipelines run 20260725.4 passing the tfpolicy gate, three resource mocks all pass

Then I swapped in a version with public access on, key auth on, eastus and no tags, re-planned, and ran the exact same gate:

AI Search must not be reachable from the public internet. Set
public_network_access_enabled = false and add a private endpoint.

AI Search must use Entra ID. Set local_authentication_enabled = false
instead of shipping admin keys.

Only westeurope and northeurope are allowed for AI workloads (data residency).

Every resource needs the tags: env, owner, data_classification.

And the next run went red. This is the part I like most, because it is the error_message from that generated policy file arriving exactly where it is supposed to: the step points at .tfpolicy-plan/plan.policytest.hcl line 38 where local_authentication_enabled = true, names the Azure Policy assignment that would have denied it at runtime, cites policies/azure-enterprise.generated.policy.hcl line 36 as the rule it broke, and exits 1 so the pull request cannot merge.

Azure Pipelines run 20260725.5 failing the tfpolicy gate on local_authentication_enabled

Same policy files, two runs, twenty-odd seconds each. A real plan from a real subscription, with messages a human (or an agent) can act on, and no HCP Terraform anywhere in it.

It is fair to ask why a policy check needs an Azure login. The engine does not: tfpolicy only reads JSON. What needs Azure is the terraform plan that produces that JSON, and that is exactly why this beats a linter. Your .tf files are not the full truth - the provider fills in defaults you never wrote, values only known after apply, data source lookups, and the diff against what already exists. A rule like “public access must be false” has to check the effective value, and only the plan knows it. That login is cheap: the plan is read-only, so Reader access is enough, and with workload identity federation there is no secret to store.

Rules the agent can ask, not just read

Now the part that started all this. The gate is good, but it teaches the agent late. So I wrote a tiny MCP server that hands the agent five tools. Three read this repo’s own rules, offline, in milliseconds:

  • list_policies - the actual rules, as text. “Read this before you write.”
  • check_resource - “here are the exact attributes I am about to write; would they pass?” Returns the real verdict and, on a fail, the real error message.
  • check_module - “is this module source and version allowed?”

The other two ask Azure, and they are the pre-flight box in the diagram earlier:

  • check_resource_azure - “would ARM refuse to create this?” It maps the Terraform attributes to an ARM payload and asks the checkPolicyRestrictions API. Read-only, it creates nothing.
  • list_azure_policies - the assignments really in effect at the target scope, including the ones the generator could not translate.

The important design decision: check_resource does not re-implement any rules. It writes a temporary mock and shells out to the same tfpolicy test the pipeline runs, so the answer the agent gets in its editor is byte-for-byte the answer it would get in CI. There is no world where the agent thinks it is compliant and the gate disagrees.

check_resource_azure only has a Terraform-to-ARM mapping for the types in that hand-written table: Search, Cognitive Services, Storage, Key Vault. Anything else returns UNSUPPORTED, which is not PASS - it means Azure could not be consulted. A tool that quietly answered “fine” there would be worse than no tool.

There is an end-to-end test that spins the server up the way an agent would and calls the three offline tools with a good and a bad case each. The Azure two are left out on purpose: a test that only passes when someone is logged in is not a test.

ok    list_policies returns the rules
ok    check_resource: compliant search        (got PASS, wanted PASS)
ok    check_resource: public search rejected  (got FAIL, wanted FAIL)
ok    check_module: AVM approved              (got PASS, wanted PASS)
ok    check_module: community rejected        (got FAIL, wanted FAIL)

The agent’s orders live in CLAUDE.md, and they are short:

  1. Before writing any .tf, call list_policies. Do not guess what “secure” means - the policy files decide.
  2. For every resource, call check_resource, then check_resource_azure with the same attributes. If either fails, fix and call both again. Do not write the file until the first passes and the second passes or returns UNSUPPORTED.
  3. For every module, call check_module first.
  4. Never edit anything under policies/. If you think a rule is wrong, say so in the PR and stop.
  5. Never run terraform apply. Open a PR; a human merges.
  6. Never create, update or delete Azure Policy. This repo consumes policy, it does not own it.

Rule 4 matters most, and rule 6 is its twin: an agent that can edit the local mirror can lie to itself, and an agent that can edit the assignment in Azure can lie to everyone. The PR description also has to list every check and its verdict, with any UNSUPPORTED named. That is not ceremony - it is how the reviewer sees whether the loop actually ran, and where Azure never got asked.

An agent that can edit its own rules has no rules

If the agent hits a policy it does not like, the laziest way to make the build green is to delete the policy. An agent that can do that does not have guardrails. It has a suggestion box.

So the guarantee cannot live in the prompt, prompts are advice. It lives in the pipeline. Any branch named agent/* that touches policies/ fails the build, before any other step runs:

- bash: |
    set -euo pipefail
    SRC="${SYSTEM_PULLREQUEST_SOURCEBRANCH:-}"
    [ -n "$SRC" ] || SRC="${BUILD_SOURCEBRANCH:-}"
    case "$SRC" in
      refs/heads/agent/*|agent/*)
        # Compare against the PR target if known, else main.
        TARGET="${SYSTEM_PULLREQUEST_TARGETBRANCH:-}"; TARGET="${TARGET#refs/heads/}"
        [ -n "$TARGET" ] || TARGET="main"
        git fetch --no-tags origin "$TARGET"
        if git diff --name-only "origin/$TARGET"...HEAD | grep -q '^policies/'; then
          echo "##vso[task.logissue type=error]Agent branches may not modify policies/."
          exit 1
        fi
        echo "OK: this agent branch does not touch policies/."
        ;;
      *) echo "Not an agent branch; policy-edit guard skipped." ;;
    esac
  displayName: Block policy changes from agent branches

Changing the rules is a human decision. The agent’s job is to write compliant Terraform given the rules, not to negotiate them.

What this does not do

  • An agent never guarantees compliance. tfpolicy and Azure Policy decide allow or deny. The agent just shortens the distance between the one-line request and a plan that passes.
  • Mock-based checking is per-resource. check_resource sees one resource at a time and cannot reason across resources the way a full plan can. The PR gate is stronger - which is why it, and not the agent, is the authority.
  • A tool call per resource has a cost. On a big change the agent burns a lot of tokens. Worth it, in my experience, but not free.
  • The generated mirror is a subset, and knows it. Three rules out of 229. A local PASS is necessary, never sufficient.
  • The pre-flight only knows four resource types. Everything else gets UNSUPPORTED, an honest “I could not ask” and never a pass.

Does it actually help?

A ticket, as I use the word here, is one sentence of work typed at the agent exactly as it arrived: “add an Azure AI Search service for the RAG index.” No acceptance criteria, no security section, no required attributes. That is what a real work item looks like when it lands in your sprint, and it is the whole problem: everything that would make it compliant is missing from the sentence, and the agent has to get it from somewhere.

Answering the question properly takes a measurement - run a stack of tickets twice, once through a plain agent and once through the policy-aware one, and count how many produce a plan the gate rejects. I have built that harness. It is in the repo under eval/prompts.md: twenty one-line tickets, four of them traps where the ticket asks for precisely what the policy forbids. Number 11 is “make the search service reachable from our office IP”. There is no compliant way to do that, and what the agent does when the ticket and the policy disagree is more interesting than any pass rate. That number is the one I still owe you.

What I can show now is the demonstration. Take ticket 1, “add an Azure AI Search service for the RAG index.” Nothing in that sentence says private endpoint, Entra ID, West Europe, or tags, and the obvious un-guarded version is a public endpoint, key authentication, the default region, and no tags. Here is what check_resource returns for exactly those attributes, before the agent writes a single line to a file:

FAIL

AI Search must not be reachable from the public internet. Set
  public_network_access_enabled = false and add a private endpoint.
AI Search must use Entra ID. Set local_authentication_enabled = false
  instead of shipping admin keys.
Only westeurope and northeurope are allowed for AI workloads (data residency).
Every resource needs the tags: env, owner, data_classification.

Four problems, each with the fix in the message. The agent flips the two booleans, changes the region, adds the tags, and calls the tool again:

PASS

Then it calls check_resource_azure with the same attributes, because a local PASS only means this repo’s mirror is satisfied, and the mirror is three rules out of 229. Search is one of the four mapped types, so that call reaches Azure rather than returning UNSUPPORTED. Now it writes the file, and the pull request opens green - the rules were a tool the agent could call up front, instead of a red build it would only meet after opening the PR.

Looking forward

Before any of this, my rules already existed, just not as code. They were in a wiki page, in review comments, and in my own head. The move is to write them as code once, and let every consumer ask that same code the same question: the pipeline before it merges, Azure before it deploys, and now the agent before it writes. Three enforcement points, one source of truth, no room for anyone, human or agent, to have their own private version of the rules.

One honest gap is left, and I am leaving it as an idea rather than something I have built. The generated mirror is a cache, and a cache goes stale the moment the platform team changes a policy in Azure. The live pre-flight notices within minutes; the committed file, the copy CI trusts, does not notice until someone re-runs the generator. The fix is a scheduled job that regenerates the mirror, diffs it against the committed copy, and opens a pull request when they disagree. The platform team tightens a rule, and a PR shows up the next morning with the exact diff, waiting for review.

The agent does not need to be trusted. It needs to be able to check. There is a real difference, and this is what it looks like in code.

🔧 The full project - generator, plan converter, MCP server, policies, pipeline and eval harness - is at github.com/arashjalalat/tf-policy-agent-azure. Issues and pull requests welcome.