
Claude Code will happily run terraform apply if nothing tells it not to. The fix isn't one setting. It's a set of layers, each catching what the previous one misses, ending at a credential that can't apply no matter what the agent does.
This post is the concrete configuration companion to our primer on infrastructure as code with AI coding agents. Everything below was checked against Anthropic's live documentation as of July 21, 2026; Claude Code changes quickly, so re-verify the syntax before copying it into a team-wide config.
Decide the policy before touching settings. A useful default for Terraform and OpenTofu work:
Allowed: read the repository, edit HCL, run fmt, validate, test on files a human has verified as plan-only, produce speculative plans, and query provider documentation through a registry MCP server.
Denied: apply, destroy, any state subcommand, import, taint, force-unlock, -auto-approve, -lock=false, and init -upgrade. Approval and apply stay with a human or a controlled pipeline.
That split matches how the tool itself is trending: as of July 2026, Claude Code's auto permission mode ships with a classifier that refuses terraform destroy and plans that destroy resources by default. Sensible, but a default someone else tunes isn't a policy. Write yours down.
Permission rules live in .claude/settings.json (project, shared through Git), .claude/settings.local.json (personal), or ~/.claude/settings.json (user). Rules merge across scopes and a deny at any level wins, so a project file is a real team control rather than a suggestion. A starting point:
{
"permissions": {
"deny": [
"Bash(terraform apply*)",
"Bash(terraform destroy*)",
"Bash(terraform state*)",
"Bash(terraform import*)",
"Bash(terraform taint*)",
"Bash(terraform force-unlock*)",
"Bash(tofu apply*)",
"Bash(tofu destroy*)",
"Bash(tofu state*)"
],
"allow": [
"Bash(terraform fmt*)",
"Bash(terraform validate*)",
"Bash(terraform plan*)",
"Bash(tofu fmt*)",
"Bash(tofu validate*)",
"Bash(tofu plan*)"
]
}
}Two semantics worth knowing. Evaluation order is deny, then ask, then allow, with the first match winning, so a broad deny beats a narrow allow. And Claude Code splits compound commands on &&, ;, and pipes, matching each part separately, so terraform validate && terraform apply doesn't slip through on the strength of its first half.
Because Anthropic says so, in the permissions documentation itself: Bash patterns that constrain command arguments are fragile. The docs' own example shows a curl restriction bypassed by option reordering and shell variables, and the same tricks apply to any command matcher. A permission rule evaluates the command string before execution; it can't see what the process actually does.
So treat the deny list as the polite layer. It stops the agent's ordinary behavior and documents your policy in a reviewable file. For enforcement, the documentation points to two stronger mechanisms: hooks and the sandbox.
A PreToolUse hook runs your script before a tool call executes. If the script exits with code 2, the call is blocked and your stderr message is fed back to the model, which then knows why and can route around it properly (for example, by handing you the plan for approval instead of retrying).
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.command' | grep -qE '(terraform|tofu) +(apply|destroy|state|import|taint|force-unlock)|-auto-approve|-lock=false' && { echo 'Blocked: apply/destroy/state operations require human approval' >&2; exit 2; } || exit 0"
}
]
}
]
}
}The hook receives the full command as JSON on stdin, after variable expansion has had its chance to fool a string matcher, and a blocking hook takes precedence over allow rules. It's still pattern matching, so don't expect miracles from it. What it adds over deny rules is a second, independently evaluated gate that you control completely.
The sandbox is the only layer here the operating system enforces. Enabled with /sandbox or "sandbox": {"enabled": true} in settings, it confines bash commands and their child processes, explicitly including terraform, on macOS (Seatbelt) and Linux (bubblewrap).
Three defaults matter for IaC work. Writes are confined to the working directory and session temp, which protects the rest of the machine. Reads are not confined by default: the documentation specifically warns that ~/.aws/credentials and ~/.ssh remain readable unless you turn on sandbox.credentials protection, so do that. And network access goes through a proxy with per-domain approval, so an unexpected outbound request becomes a visible prompt instead of a silent exfiltration path.
One Terraform-specific gotcha from the docs: Go-based CLIs, Terraform among them, can fail TLS verification under the macOS sandbox. If plans start failing on certificate errors, the documented workaround is excluding that command rather than disabling the sandbox wholesale.
Add servers at project scope so the configuration lives in .mcp.json, gets reviewed like code, and each teammate approves it once:
claude mcp add --transport http --scope project terraform-registry https://<registry-mcp-endpoint>Start registry-only. Documentation lookup covers most authoring work and carries no control-plane access. When a task genuinely needs run history or workspace data, add a control-plane server with a read-only scope; the pillar post's access-class taxonomy is the checklist for what a given server can reach. Some platforms make the read-only choice for you. Scalr's MCP server, for instance, can't trigger runs or approve applies as of July 2026, so connecting it doesn't extend the blast radius to apply even if the token holder could apply in the UI.
Output size is handled for you up to a point: Claude Code warns when one MCP result exceeds 10,000 tokens and caps results at 25,000 by default. That's protection against a flooded context, not against a malicious payload; treat tool output as untrusted input either way.
Plans, provider schemas, and run logs shouldn't sit in your main session. Define a read-only subagent in .claude/agents/plan-reader.md:
---
name: plan-reader
description: Reads Terraform plan JSON and run logs, returns a filtered summary
tools: Read, Grep, Glob
---
Summarize the plan or log at the path you are given. Return affected resource
addresses, action types, every delete or replacement, the first causal error
if any, and nothing else. Keep the summary under one page.The tools field is an allowlist; omit it and the subagent inherits everything, including write tools, so don't omit it. The subagent burns its own context on the 200 KB plan file and returns a page, and the boundary doubles as containment: if a poisoned log tries to steer the model, the reader has no tools that mutate anything.
Credentials. Every layer above evaluates intent; only the credential constrains outcome. Give the session a backend or control-plane identity that can read state, acquire locks, and create speculative plans, and cannot apply, approve, or write state. Then a bypass of every software layer still ends in a 403.
Platforms with run-level RBAC make this clean. In Scalr, the apply approval is a distinct permission, so a service account for agent sessions can create plan-only runs while approval stays with named humans; short-lived tokens via OIDC federation avoid parking a long-lived secret in the agent's environment. HCP Terraform, Spacelift, and env0 offer their own scoping models, and plain open-source Terraform can approximate it with a plan-only IAM role for the backend. The mechanism matters less than the property: the agent's identity can't complete the change alone. Once a plan exists, review it like any agent-authored change; our review checklist for AI-generated Terraform covers that half.
Copy the deny list into a project .claude/settings.json, add the hook, and turn on the sandbox with credential protection. That's twenty minutes. Then do the part that outlasts any client configuration: issue the agent its own plan-only identity, and let every other layer be defense in depth rather than the thing standing between an autocompleted command and production.
