SkillsHooksPromptsAgentsPersonasModelsPoliciesToolsTemplatesBundlesCategoriesStart here
← Hooks
Hk hookdevopsblockingstable

Destructive Terraform Guard

Blocks terraform destroy and terraform apply. Opt-in via command-wrappers.toml. Requires explicit bypass via AI_ALLOW_DESTRUCTIVE_TERRAFORM=1. Prevents accidental infrastructure destruction or unreviewed applies. Other tofu/terraform subcommands (plan, init, validate, output) pass through.

id hook/destructive-terraform-guardv1.0.1by convergent-systems-key
Event
PreToolUse
Trigger
tool-nameBash
Language
python
Side effects
  • blocks tool call with explanation
  • bypass via AI_ALLOW_DESTRUCTIVE_TERRAFORM=1
Platforms
linuxmacoswindows
Notes
Logic is cross-platform. Wiring: use 'ai hooks run destructive-terraform-guard' in settings.json — the ai binary discovers Python on each OS. tofu/terraform available on all platforms. Python logic is cross-platform.
Depends on
hook/lib

Script · destructive-terraform-guard.py

#!/usr/bin/env python3
"""hooks/destructive-terraform-guard.py — gate `terraform {destroy,apply}`
per Common.md §2.2. Opt-in via command-wrappers.toml.

Blocks (without bypass env): `terraform destroy`, `terraform apply`.
Other subcommands pass through.

The bypass env is AI_ALLOW_DESTRUCTIVE_TERRAFORM=1.

Self-check:
  --self-check
"""
from __future__ import annotations

import argparse
import json
import os
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))
import _lib  # noqa: E402


GUARDED = {"destroy", "apply"}
BYPASS_ENV = "AI_ALLOW_DESTRUCTIVE_TERRAFORM"
TOOL = "terraform"


def argv_from_wrapper() -> list[str]:
    """The args after `terraform`, as JSON in WRAPPED_ARGV (command-wrapper path).

    The wrapper does not forward the tool's argv on the command line — it
    publishes it in WRAPPED_ARGV so argparse never sees subcommand tokens.
    """
    try:
        return json.loads(os.environ.get("WRAPPED_ARGV", "[]"))
    except json.JSONDecodeError:
        return []


def argv_from_stdin() -> list[str]:
    """The args after `terraform`, parsed from a Claude PreToolUse JSON payload."""
    raw = sys.stdin.read()
    if not raw.strip():
        return []
    try:
        payload = json.loads(raw)
    except json.JSONDecodeError:
        return []
    cmd = (
        payload.get("command")
        or payload.get("tool_input", {}).get("command")
        or ""
    ) if isinstance(payload, dict) else ""
    parts = cmd.split()
    return parts[1:] if parts and parts[0] == TOOL else []


def check_invocation(argv: list[str]) -> int:
    if not argv or argv[0] not in GUARDED:
        return 0
    if os.environ.get(BYPASS_ENV) == "1":
        _lib.log(f"`terraform {argv[0]}` — bypass active ({BYPASS_ENV}=1). Logged.")
        return 0
    _lib.log(f"blocking — `terraform {argv[0]}` mutates real infrastructure.")
    _lib.log("Per Common.md §2.2 + §2.4: state what will change, name reversibility, wait for an unambiguous yes.")
    _lib.log(f"To bypass for one session only: {BYPASS_ENV}=1 terraform {argv[0]} ...")
    return 1


def main(argv: list[str]) -> int:
    parser = argparse.ArgumentParser(add_help=True)
    parser.add_argument("--self-check", action="store_true")
    parser.add_argument("--mode", choices=["claude", "wrapper"], default=None,
                        help="invocation mode (set by the command wrapper)")
    parser.add_argument("rest", nargs=argparse.REMAINDER)
    args = parser.parse_args(argv)
    if args.self_check:
        return _lib.self_check_ok()
    if args.mode == "wrapper":
        return check_invocation(argv_from_wrapper())
    if args.mode == "claude" or not sys.stdin.isatty():
        return check_invocation(argv_from_stdin())
    return check_invocation(args.rest)


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))
governanceterraformtofuinfrastructuredestructiveguardclaude-code
Author convergent-systems-key. Catalog data license CC-BY-4.0.