the short answer
Add a pre-tool policy that denies terraform destroy, apply -destroy, local applies and state changes, and lets plan, validate, fmt, init, show and workspace list/select through. It only reads terraform in command position, so grep terraform or a commit message is not blocked. The deny reason tells the agent to save a plan and open a pull request so CI applies it after review. Blocking the whole Terraform CLI is simpler but stops useful work; a subcommand allowlist keeps both safety and speed.
- Deny
destroy, anyapply,import, state changes, unknown subcommands- Allow
plan(includingplan -destroy),validate,fmt,init,show,output- Redirect
- The deny reason points the agent at plan plus pull request
- Built-in alternative
block-terraform(opt-in), reviewable byproduction-infra-change- Event
PreToolUseon the shell tool
Why Destroy Needs a Hard Block
terraform destroy is an alias for terraform apply -destroy: both remove every resource in the configuration. In a developer shell with a production backend configured, one agent call can take down a whole stack. Most teams already require Terraform applies to go through CI with review; a coding agent running locally skips that path entirely unless something stops it.
Policy: Plan Locally, Apply in CI
It reads the subcommand of each Terraform, OpenTofu or Terragrunt invocation in the command, so cd infra && terraform destroy and terraform fmt && terraform validate are judged correctly. Applies are denied rather than “instructed”: an instruct decision lets the call run with guidance, which is not what you want for a production apply. Pair it with the script-write rule from how agents get around deny rules so a teardown.sh containing the same command is refused before it exists.
import { customPolicies, allow, deny } from "failproofai";
// Finds each terraform/tofu/terragrunt invocation in command position (start of
// a segment, after sudo/env/time or bash -c, quoted or not) and reads its
// subcommand, skipping global flags such as -chdir=infra/prod. A mention in
// grep, brew or a commit message is not an invocation.
const INVOCATION = /(?:^|[;&|(`\n]|\b(?:sudo|env|time|exec|nohup|xargs)\s|\s-c\s)\s*(?:[A-Z_][A-Z0-9_]*=\S+\s+)*["']?(?:\S*\/)?(?:terraform|tofu|terragrunt)["']?((?:\s+-[\w-]+(?:=\S+)?)*)\s+([a-z][\w-]*)(?:\s+(?:-\S+\s+)*([a-z][\w-]*))?/g;
const READ_ONLY = new Set(["plan", "validate", "fmt", "init", "show", "output", "providers", "version", "graph", "console"]);
const WORKSPACE_READ = new Set(["list", "show", "select"]);
customPolicies.add({
name: "terraform-plan-only",
description: "Agents may plan Terraform; applies and destroys go through CI",
match: { events: ["PreToolUse"] },
fn: async (ctx) => {
if (ctx.toolName !== "Bash") return allow();
const cmd = String(ctx.toolInput?.command ?? "");
for (const [, , sub, next] of cmd.matchAll(INVOCATION)) {
if (sub === "destroy" || (sub === "apply" && /\s-destroy\b/.test(cmd))) {
return deny("terraform destroy is not allowed from agents. Describe the change in a PR.");
}
if (sub === "apply" || sub === "import" || (sub === "state" && next !== "list" && next !== "show")) {
return deny("Agents do not change Terraform state locally. Save a plan with terraform plan -out=tfplan and open a PR; CI applies after review.");
}
if (sub === "workspace" && !WORKSPACE_READ.has(next)) {
return deny(`terraform workspace ${next ?? ""} changes state. Ask a human to run it.`);
}
if (sub === "workspace" || sub === "state" || READ_ONLY.has(sub)) continue;
return deny(`terraform ${sub} is not on the agent allowlist. Ask a human to run it.`);
}
return allow();
},
});Test Cases to Run Before Enforcing
Run them with fp policies test ./terraform-plan-only.policy.mjs --command "terraform apply" --expect deny, one row at a time, and backtest the draft in Admin → policy editor against the last 30 days of fleet traffic to count the working calls it would have interrupted.
| Command | Expected |
|---|---|
terraform plan -out=tfplan | allow |
terraform -chdir=infra/prod plan | allow |
terraform plan -destroy | allow |
terraform fmt -recursive && terraform validate | allow |
terraform state list | allow |
terraform destroy -target=aws_route53_record.cdn | deny |
terraform apply -destroy | deny |
terraform apply -auto-approve tfplan | deny |
terraform apply | deny |
terraform state rm module.cdn | deny |
cd infra && terraform destroy | deny |
tofu apply | deny |
"terraform" destroy | deny |
bash -c "terraform destroy -auto-approve" | deny |
terraform workspace delete -force prod | deny |
terraform workspace select staging && terraform plan | allow |
grep -rn terraform infra/ | allow |
git commit -m "terraform apply fix" | allow |
brew upgrade terraform tflint | allow |
Or Use the Built-in Policy with a Decision Model
The coding agent pack includes block-terraform, which is off by default and denies the whole CLI, plan included. It is marked reviewable by the production-infra-change semantic check: with Jev on and the FailproofAI/jev-policies pack installed, read-only calls like plan and validate are cleared while mutations against live infrastructure stay denied. Jev comes with Failproof: connect with a machine key carrying jev:evaluate, or run failproofai jev setup.
failproofai policies add FailproofAI/policies --policy block-terraform
failproofai policies add FailproofAI/jev-policies
failproofai jev status # confirm Jev is onScope It by Directory or Persona
- Use
ctx.session?.cwdto apply the strict rule only when the session runs under your production stack directories. It is the session’s working directory, socd infra/prod && …inside one command does not change it. - Deploy the strict version to admin and on-call machines first, where the blast radius is largest. See different rules per team.
- Keep the deny reason actionable. An agent told exactly what to do instead stops looking for another route.
FAQ
Does this stop Terraform Cloud runs?
No. It gates commands the agent runs locally. Remote runs triggered by a merged PR are governed by your Terraform Cloud or CI permissions.
What about `terraform plan -destroy`?
Allowed: it only produces a plan, which is useful evidence for a human reviewer.
Does the same rule work in Codex and Cursor?
Yes. Failproof normalizes each harness’s shell tool to Bash with a command field, so one policy covers every supported harness at the pre-tool gate.
Sources
Checked against the sources below on October 21, 2026. Harness settings, hook capabilities and cloud features change between versions.
- HashiCorp: terraform destroy command reference
- HashiCorp: terraform apply command reference
- Failproof AI docs: Custom policies (policy SDK)
- Failproof AI docs: Test a policy
- Failproof AI docs: Use a policy pack
- Failproof AI docs: Policy authority
- Failproof AI docs: Jev policies
- Failproof AI docs: Agent harnesses and enforcement capability