destructive_command_guard 0.4.5

A Claude Code hook that blocks destructive commands before they execute
Documentation
//! Terraform patterns - protections against destructive terraform commands.
//!
//! This includes patterns for:
//! - terraform destroy
//! - terraform taint
//! - terraform apply with -auto-approve
//! - terraform state rm

use crate::packs::{DestructivePattern, Pack, SafePattern};
use crate::{destructive_pattern, safe_pattern};

/// Create the Terraform pack.
#[must_use]
pub fn create_pack() -> Pack {
    Pack {
        id: "infrastructure.terraform".to_string(),
        name: "Terraform",
        description: "Protects against destructive Terraform operations like destroy, \
                      taint, and apply with -auto-approve",
        keywords: &["terraform", "destroy", "taint", "state"],
        safe_patterns: create_safe_patterns(),
        destructive_patterns: create_destructive_patterns(),
        keyword_matcher: None,
        safe_regex_set: None,
        safe_regex_set_is_complete: false,
    }
}

fn create_safe_patterns() -> Vec<SafePattern> {
    // `(?=\s|$)` on each subcommand stops a workspace/module name that
    // contains the subcommand keyword as a substring from making a
    // destructive command short-circuit as safe. Without this anchor,
    // `terraform destroy plan-stack` would match `terraform-plan` via
    // `plan` in `plan-stack` and bypass the destroy rule.
    vec![
        // plan is safe (read-only)
        safe_pattern!(
            "terraform-plan",
            r"terraform\b(?:\s+--?\S+(?:\s+\S+)?)*\s+plan(?=\s|$)(?!\s+.*-destroy)"
        ),
        // init is safe
        safe_pattern!(
            "terraform-init",
            r"terraform\b(?:\s+--?\S+(?:\s+\S+)?)*\s+init(?=\s|$)"
        ),
        // validate is safe
        safe_pattern!(
            "terraform-validate",
            r"terraform\b(?:\s+--?\S+(?:\s+\S+)?)*\s+validate(?=\s|$)"
        ),
        // fmt is safe
        safe_pattern!(
            "terraform-fmt",
            r"terraform\b(?:\s+--?\S+(?:\s+\S+)?)*\s+fmt(?=\s|$)"
        ),
        // show is safe
        safe_pattern!(
            "terraform-show",
            r"terraform\b(?:\s+--?\S+(?:\s+\S+)?)*\s+show(?=\s|$)"
        ),
        // output is safe
        safe_pattern!(
            "terraform-output",
            r"terraform\b(?:\s+--?\S+(?:\s+\S+)?)*\s+output(?=\s|$)"
        ),
        // state list/show are safe (read-only)
        safe_pattern!(
            "terraform-state-list",
            r"terraform\b(?:\s+--?\S+(?:\s+\S+)?)*\s+state\s+list(?=\s|$)"
        ),
        safe_pattern!(
            "terraform-state-show",
            r"terraform\b(?:\s+--?\S+(?:\s+\S+)?)*\s+state\s+show(?=\s|$)"
        ),
        // graph is safe
        safe_pattern!(
            "terraform-graph",
            r"terraform\b(?:\s+--?\S+(?:\s+\S+)?)*\s+graph(?=\s|$)"
        ),
        // version is safe
        safe_pattern!(
            "terraform-version",
            r"terraform\b(?:\s+--?\S+(?:\s+\S+)?)*\s+version(?=\s|$)"
        ),
        // providers is safe
        safe_pattern!(
            "terraform-providers",
            r"terraform\b(?:\s+--?\S+(?:\s+\S+)?)*\s+providers(?=\s|$)"
        ),
    ]
}

fn create_destructive_patterns() -> Vec<DestructivePattern> {
    vec![
        // destroy. Trailing `(?=\s|$)` so `terraform apply destroy-plan.tf`
        // (a plan file literally named `destroy-plan.tf`) doesn't false-match.
        destructive_pattern!(
            "destroy",
            r"terraform\b.*?\bdestroy(?=\s|$)",
            "terraform destroy removes ALL managed infrastructure. Use 'terraform plan -destroy' first.",
            Critical,
            "terraform destroy removes ALL managed infrastructure:\n\n\
             - Every resource in your state file is destroyed\n\
             - Cloud resources (VMs, databases, networks) deleted\n\
             - Cannot be undone without backups/recreation\n\
             - Use -target to destroy specific resources only\n\n\
             Preview first: terraform plan -destroy"
        ),
        // plan -destroy is a preview but can be scary
        destructive_pattern!(
            "plan-destroy",
            r"terraform\b.*?\bplan\s+.*-destroy",
            "terraform plan -destroy shows what would be destroyed. Review carefully before applying.",
            Medium,
            "terraform plan -destroy shows destruction preview:\n\n\
             - This is a read-only operation (safe to run)\n\
             - Shows what WOULD be destroyed if you apply\n\
             - Review output carefully before proceeding\n\n\
             This is actually the safe way to preview destroy."
        ),
        // apply with -auto-approve (skips confirmation)
        destructive_pattern!(
            "apply-auto-approve",
            r"terraform\b.*?\bapply\s+.*-auto-approve",
            "terraform apply -auto-approve skips confirmation. Remove -auto-approve for safety.",
            High,
            "terraform apply -auto-approve skips confirmation:\n\n\
             - No opportunity to review changes before applying\n\
             - Intended for CI/CD, not interactive use\n\
             - Changes may destroy or recreate resources\n\n\
             For safety: remove -auto-approve and review the plan"
        ),
        // taint marks resource for recreation
        destructive_pattern!(
            "taint",
            r"terraform\b.*?\btaint\b",
            "terraform taint marks a resource to be destroyed and recreated on next apply.",
            High,
            "terraform taint marks resource for recreation:\n\n\
             - Resource will be destroyed on next apply\n\
             - New resource created with same config\n\
             - May cause downtime during recreation\n\
             - IP addresses and identifiers may change\n\n\
             Use -replace in plan/apply instead (Terraform 0.15.2+)"
        ),
        // state rm removes from state (orphans resource)
        destructive_pattern!(
            "state-rm",
            r"terraform\b.*?\bstate\s+rm\b",
            "terraform state rm removes resource from state without destroying it. Resource becomes unmanaged.",
            High,
            "terraform state rm orphans resources:\n\n\
             - Resource removed from Terraform state\n\
             - Actual cloud resource still exists\n\
             - Resource becomes 'unmanaged' (Terraform ignores it)\n\
             - May cause drift between state and reality\n\n\
             Back up state first: terraform state pull > backup.tfstate"
        ),
        // state mv can cause issues if done incorrectly
        destructive_pattern!(
            "state-mv",
            r"terraform\b.*?\bstate\s+mv\b",
            "terraform state mv moves resources in state. Incorrect moves can cause resource recreation.",
            High,
            "terraform state mv moves resources in state:\n\n\
             - Renames resource address in state file\n\
             - Wrong move can cause destruction/recreation\n\
             - Use -dry-run to preview the move first\n\
             - Does not affect actual cloud resources\n\n\
             Preview first: terraform state mv -dry-run SOURCE DEST"
        ),
        // force-unlock
        destructive_pattern!(
            "force-unlock",
            r"terraform\b.*?\bforce-unlock\b",
            "terraform force-unlock removes state lock. Only use if lock is stale.",
            High,
            "terraform force-unlock removes state locks:\n\n\
             - Forces removal of a state lock\n\
             - May cause corruption if another process is running\n\
             - Only use when you're sure no other operation is active\n\
             - Lock ID required to prevent accidents\n\n\
             Verify no other operations: check CI/CD pipelines, other users"
        ),
        // workspace delete
        destructive_pattern!(
            "workspace-delete",
            r"terraform\b.*?\bworkspace\s+delete\b",
            "terraform workspace delete removes a workspace. Ensure it's not in use.",
            Medium,
            "terraform workspace delete removes workspace:\n\n\
             - Workspace and its state file deleted\n\
             - Does NOT destroy actual infrastructure\n\
             - Resources become unmanaged (orphaned)\n\
             - Cannot be undone without state backup\n\n\
             Destroy resources first: terraform destroy, then delete workspace"
        ),
    ]
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::packs::test_helpers::*;

    #[test]
    fn terraform_patterns_match_with_chdir_flag() {
        // Same class bug as cloud/container packs: terraform's
        // `-chdir=<path>` global flag goes BEFORE the subcommand:
        //
        //   terraform -chdir=./environments/prod destroy -auto-approve
        //
        // With `terraform\s+destroy` the `\s+` can't skip over `-chdir=…`,
        // so this destructive command escapes every rule. Enterprise
        // multi-environment setups use -chdir routinely.
        let pack = create_pack();
        assert_blocks(
            &pack,
            "terraform -chdir=./environments/prod destroy -auto-approve",
            "destroy",
        );
        assert_blocks(
            &pack,
            "terraform -chdir=./prod apply -auto-approve",
            "auto-approve",
        );
        assert_blocks(
            &pack,
            "terraform -chdir=./prod state rm aws_instance.important",
            "state",
        );
        assert_blocks(
            &pack,
            "terraform -chdir=./prod workspace delete prod-old",
            "workspace",
        );
        assert_blocks(
            &pack,
            "terraform -chdir=./prod force-unlock abc123",
            "force-unlock",
        );
    }

    #[test]
    fn terraform_safe_patterns_do_not_bypass_via_flag_value() {
        // `-chdir=./plan-output` or `--out=plan` must not falsely
        // match safe patterns and bypass destructive rules. `\s+<sub>\b`
        // form fixes this.
        let pack = create_pack();
        assert_allows(&pack, "terraform plan");
        assert_allows(&pack, "terraform -chdir=./prod plan");
        assert_allows(&pack, "terraform show");
        assert_allows(&pack, "terraform state list");
        // Genuine destructive still blocks
        assert_blocks(&pack, "terraform destroy -auto-approve", "destroy");
    }
}