Skip to content
Agentic Control Plane

How to stop Claude Code from running terraform destroy

David Crowe David Crowe · · 8 min read
claude-code terraform permissions hooks
Share X HN LinkedIn

Add these to .claude/settings.json:

{
  "permissions": {
    "deny": [
      "Bash(terraform destroy *)",
      "Bash(terraform * destroy*)",
      "Bash(terraform apply *-destroy*)"
    ],
    "ask": ["Bash(terraform apply *)"]
  }
}

By the matching rules in Claude Code’s docs, that refuses terraform destroy, terraform -chdir=infra destroy and terraform apply -destroy, and prompts before any other apply. We derived these from the docs and have not run them in a live session. It does not see make teardown, and it does not cover tofu or terragrunt unless you repeat the rules for them. It also misses terraform -chdir=infra apply -destroy, which the hook below catches.

Why there are three deny rules

Rules go in permissions.deny, permissions.ask and permissions.allow, in .claude/settings.json for the repo or ~/.claude/settings.json for every project. Deny is checked first, then ask, then allow (permissions docs).

Two details that decide whether these rules match:

  • A trailing ` * also matches the bare command, so Bash(terraform destroy *) covers terraform destroy` with no arguments.
  • A rule is matched against the command text. terraform -chdir=infra destroy does not start with terraform destroy, which is why the second rule is there.

Committing the rules to the repo means every teammate who opens it in Claude Code gets them.

One hook for Terraform, OpenTofu and Terragrunt

One hook covers Terraform, OpenTofu and Terragrunt, and both spellings of destroy. Save it as ~/.claude/hooks/no-terraform-destroy.mjs:

#!/usr/bin/env node
let raw = "";
process.stdin.on("data", (d) => (raw += d));
process.stdin.on("end", () => {
  let cmd = "";
  try { cmd = String(JSON.parse(raw).tool_input?.command ?? ""); } catch {}
  const destroy = /\b(terraform|tofu|terragrunt)\b[^|;&\n]*\s(destroy\b|apply\b[^|;&\n]*\s--?destroy\b)/;
  if (destroy.test(cmd)) {
    console.error("Blocked: this destroys infrastructure. Stop and ask the human to run it.");
    process.exit(2);
  }
});

Register it in ~/.claude/settings.json:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "node \"$HOME/.claude/hooks/no-terraform-destroy.mjs\"", "onFailure": "block" }
        ]
      }
    ]
  }
}

The registration is the same as on the force-push page, which explains exit code 2 and onFailure.

Wrappers and other ways to delete infrastructure

Ten commands, with the script’s answer to each:

BLOCKED  terraform destroy -auto-approve
BLOCKED  terraform -chdir=infra destroy
BLOCKED  terraform apply -destroy -auto-approve
BLOCKED  tofu destroy
ran      terraform plan -destroy
ran      terraform apply
ran      make teardown
ran      npm run infra:down
ran      terraform state rm aws_db_instance.main
ran      aws rds delete-db-instance --db-instance-identifier prod

terraform plan -destroy only prints a plan, so letting it run is intended. The rest is the gap:

  • make teardown and npm run infra:down run the destroy from a file. The command text does not say so.
  • terraform apply with a changed config can delete as much as a destroy. Only the plan tells you. That is what the ask rule on apply is for.
  • terraform state rm and a direct cloud CLI call remove things without Terraform’s destroy path at all.

Start with credentials. Give the shell the agent runs a role that can plan and cannot apply, and run apply from CI or your own terminal. The rule and the hook sit on top of that.

ACP beyond Terraform

ACP’s hook covers more tools than the one above. It holds pulumi destroy, cdk destroy and kubectl delete namespace, along with destroy and apply -destroy on terraform, tofu and terragrunt. With you at the keyboard in Claude Code, the command shows in a prompt and you can say yes. With nobody there, it is blocked until you approve it on the console or from the email. Plans and previews are not held.

It reads command text like the hook above, so make teardown gets past it too. Today it also misses two spellings the hook above catches: a credential wrapper such as aws-vault exec prod -- terraform destroy, and Terragrunt’s older destroy-all. Keep the hook above if you use either. What ACP adds for the cases neither catches is the log: every command the agent ran, in order, with the decision next to it.

Frequently asked questions

How do I stop Claude Code from running terraform destroy?

Add Bash(terraform destroy ), Bash(terraform * destroy) and Bash(terraform apply -destroy) to permissions.deny in .claude/settings.json. For OpenTofu and Terragrunt, add a PreToolUse hook that checks the command and exits with code 2.

Can Claude Code ask for approval before terraform apply?

Yes. Put Bash(terraform apply *) in permissions.ask and Claude Code prompts before each apply. Ask rules are checked after deny rules and before allow rules.

Does a deny rule stop terraform destroy inside a Makefile?

No. A Bash rule matches the command text Claude writes. make teardown or npm run infra:down does not contain the word terraform, so no rule or hook on the command text sees it.

What is the safest setup for an agent working on Terraform?

Give the agent’s shell credentials that can plan but not apply, and run apply and destroy from CI or your own terminal. A rule is a second layer on top of that.

Share X HN LinkedIn
Get the next data drop
What agents actually cost, new tool-surface captures, and the occasional incident post-mortem — sent when we publish something worth your inbox, not on a schedule. Unsubscribe anytime.
Share: Twitter LinkedIn
Related posts

← back to blog