Intercepts terraform apply/destroy in Bash and asks for approval, with a distinct confirmation for production.

A Claude Code mod that catches terraform apply (and destroy) before Claude runs it and asks you to approve, with a separate, deliberately different confirmation for production.

terraform, tofu, terragrunt by default) is followed by a gated action (apply, destroy), it classifies the command as production, staging or unknown from keywords in the command text: the -chdir path, workspace, profile, var file, anything.Yes, apply to PROD and is listed second, so a reflexive first pick denies.allow (run, show a toast) instead.-auto-approve is denied outright, with a hint back to the model to plan first and apply the saved plan. Can be turned off.plan, init, validate and anything else pass through untouched.A denied or dismissed dialog turns the tool call into a deny the model sees, so it stops and tells you instead of retrying.
At the prompt of a terminal session:
/plugin install tf-apply-guard --marketplace giannimassi/tf-apply-guard
Answer y to add the marketplace, then pick a scope (user is fine). From a shell: claude plugin install tf-apply-guard --marketplace giannimassi/tf-apply-guard.
Set in /config under the plugin's name, or in settings.json under pluginConfigs["tf-apply-guard"].options.
| Option | Default | Meaning |
|---|---|---|
commands | terraform,tofu,terragrunt | Binaries to watch (comma-separated). |
actions | apply,destroy | Subcommands that need approval. |
prodKeywords | prod,production,prd | Whole words that mark a command as production. |
stagingKeywords | staging,stage,stg,dev,sandbox | Whole words that mark a command as staging. |
stagingPolicy | ask | ask or allow for staging commands. |
unknownPolicy | ask | ask or allow when no keyword matches. |
blockAutoApprove | true | Deny -auto-approve without asking. |
Keywords match case-insensitively as whole words anywhere in the command, so -chdir=envs/production, AWS_PROFILE=prod, -var-file=prod.tfvars and workspace select prod all count. A command matching both prod and staging keywords is treated as production.
|, ;, &). cd envs/production && terraform apply is caught because the keyword is in the same command string; a wrapper script (make deploy) is not.claude plugin validate .
claude plugin test .
Re-record the demo headlessly with VHS: vhs demo/demo.tape. It runs a real claude session against demo/fake-infra, whose bin/terraform is a stub that prints plausible output and changes nothing.
MIT licensed.
hooks/register.ts 89 lines1import type { Register } from 'claude-code'
2
3type Env = 'production' | 'staging' | 'unknown'
4type Policy = 'ask' | 'allow'
5
6const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
7
8function list(v: unknown, fallback: string): string[] {
9 const raw = typeof v === 'string' && v.trim() ? v : fallback
10 return raw.split(',').map(s => s.trim()).filter(Boolean)
11}
12
13const wordAlt = (words: string[]) => `\\b(?:${words.map(escape).join('|')})\\b`
14
15export const register: Register = (on, options) => {
16 const commands = list(options.commands, 'terraform,tofu,terragrunt')
17 const actions = list(options.actions, 'apply,destroy')
18 const prodWords = list(options.prodKeywords, 'prod,production,prd')
19 const stagingWords = list(options.stagingKeywords, 'staging,stage,stg,dev,sandbox')
20 const stagingPolicy: Policy = options.stagingPolicy === 'allow' ? 'allow' : 'ask'
21 const unknownPolicy: Policy = options.unknownPolicy === 'allow' ? 'allow' : 'ask'
22 const blockAutoApprove = options.blockAutoApprove !== false
23
24 // One shell segment (no | ; & or newline) naming a watched binary, then a gated action.
25 const SEGMENT = `${wordAlt(commands)}[^|;&\\n]*`
26 const GATED = new RegExp(`${SEGMENT}${wordAlt(actions)}`)
27 const AUTO = new RegExp(`${SEGMENT}-auto-approve\\b`)
28 const PROD = new RegExp(wordAlt(prodWords), 'i')
29 const STAGING = new RegExp(wordAlt(stagingWords), 'i')
30
31 const classify = (command: string): Env =>
32 PROD.test(command) ? 'production' : STAGING.test(command) ? 'staging' : 'unknown'
33
34 const actionOf = (command: string): string =>
35 actions.find(a => new RegExp(`${SEGMENT}\\b${escape(a)}\\b`).test(command)) ?? actions[0] ?? 'apply'
36
37 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
38 const command = e.command
39 if (!GATED.test(command)) return next(e)
40
41 const action = actionOf(command)
42 const env = classify(command)
43 const shown = command.length > 300 ? command.slice(0, 300) + '…' : command
44
45 if (blockAutoApprove && AUTO.test(command)) {
46 $.ui.toast(`blocked ${action} -auto-approve`)
47 return {
48 deny: `${$.plugin.name}: -auto-approve is not allowed. Use plan -out=plan.out, then ${action} plan.out, so the plan is reviewed.`,
49 }
50 }
51
52 const policy: Policy = env === 'production' ? 'ask' : env === 'staging' ? stagingPolicy : unknownPolicy
53 $.ui.log(`${action} detected [${env}, ${policy}]: ${shown}`)
54
55 if (policy === 'allow') {
56 $.ui.toast(`${action} allowed (${env})`)
57 return next(e)
58 }
59
60 const isProd = env === 'production'
61 const yes = isProd ? `Yes, ${action} to PROD` : 'Approve'
62 let answer: string
63 try {
64 answer = await $.ui.ask(
65 isProd ? `PRODUCTION ${action}. Run it?\n\n${shown}` : `${action} (${env}). Run it?\n\n${shown}`,
66 {
67 options: isProd ? ['Deny', yes] : [yes, 'Deny'],
68 header: isProd ? `PROD ${action.toUpperCase()}` : `${action} (${env})`,
69 },
70 )
71 } catch {
72 return { deny: `${$.plugin.name}: approval dialog dismissed; ${action} not run.` }
73 }
74
75 if (answer !== yes) {
76 $.ui.toast(`${action} denied (${env})`)
77 return { deny: `${$.plugin.name}: user denied ${action} (${env}). Answer: ${answer}` }
78 }
79
80 $.ui.toast(`${action} approved (${env})`)
81 $.ui.status(`${action} running [${env}]`)
82 const ran = await next(e)
83 $.ui.status(undefined)
84 return ran
85 }).catch(($, e, next) =>
86 next.called ? next(e) : { deny: `${$.plugin.name}: guard failed; command not run.` },
87 )
88}
89