SLOPSHOPPER

tf-apply-guard

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

newguardtoaststatus
v0.2.1MITupdated 2026-10-26giannimassi/tf-apply-guard
A shopper browsing a rack in a slop shop
README

tf-apply-guard

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.

demo: Claude plans, the mod stops the production apply with a PROD APPLY dialog, the user picks "Yes, apply to PROD", the apply runs

What it does

  • Watches every Bash command Claude is about to run.
  • When a watched binary (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.
  • Production always asks. The confirm button reads Yes, apply to PROD and is listed second, so a reflexive first pick denies.
  • Staging and unknown ask by default; each can be set to 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.

Install

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.

Configuration

Set in /config under the plugin's name, or in settings.json under pluginConfigs["tf-apply-guard"].options.

OptionDefaultMeaning
commandsterraform,tofu,terragruntBinaries to watch (comma-separated).
actionsapply,destroySubcommands that need approval.
prodKeywordsprod,production,prdWhole words that mark a command as production.
stagingKeywordsstaging,stage,stg,dev,sandboxWhole words that mark a command as staging.
stagingPolicyaskask or allow for staging commands.
unknownPolicyaskask or allow when no keyword matches.
blockAutoApprovetrueDeny -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.

Limits

  • Matching is on the command text, per shell segment (split on |, ;, &). cd envs/production && terraform apply is caught because the keyword is in the same command string; a wrapper script (make deploy) is not.
  • Only Bash tool calls are watched; nothing stops a human running terraform in another terminal.
  • Any Bash command whose text merely contains a gated pattern (say, a heredoc writing a script) is also gated. That is intentional: the guard reads text, not intent.

Development

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.

Source 1 files
hooks/register.ts 89 lines
1import 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