Skip to content

fix: a destroy is refused by every spelling - #30

Merged
dayanstef merged 11 commits into
mainfrom
fix/destroy-spellings
Oct 13, 2026
Merged

dayanstef merged 11 commits into
mainfrom
fix/destroy-spellings

Conversation

@dayanstef

Copy link
Copy Markdown
Member

The policy gate reads a destroy by every spelling, and holds a terraform apply through the shell to the plan the session requires.

  • Deny patterns read a canonical terraform form. Next to the kubectl form, terraform, tofu and terragrunt read as terraform, the words between the program and its verb (options, their values, terragrunt's run words) are dropped from the line, and an apply or plan with a true -destroy flag, on the line or in a TF_CLI_ARGS assignment in front of it, reads as destroy. A pattern keeps the words its operator wrote, so terraform -chdir=*prod* destroy* refuses only what it names. So the shipped terraform destroy* (and the one in every policy file /init already wrote) refuses tofu destroy, terragrunt run-all destroy, terraform apply -auto-approve -destroy and terraform plan -destroy -out=tfplan; -destroy=false stays an apply.
  • A terraform apply through the shell needs the session plan too. While require_dry_run.terraform_apply is on, a shell terraform apply (inside sh -c, behind xargs, by tofu or terragrunt as well) is refused with the steer to the terraform tool, which applies the plan from this session after showing its summary; with the requirement off it is an ordinary mutation. A line that only mentions terraform after another program is not an apply.
  • The terraform tool remembers a destroy plan. A plan made with -destroy marks its session plan, and the apply of that plan carries a terraform destroy line, so the deny pattern refuses it before the dry run and the prompt.
  • The terraform tool takes a command by name. A command parameter that is an option (-chdir=infra apply) is refused before anything runs, with the remedy named, instead of running past the plan the tool requires.
  • README ("Modes and policy"), the starter's deny: comment and the CHANGELOG's Security entry say so.

Test plan

  • TestCanonicalTerraformCommandSpellsADestroyOneWay, TestDenyPatternsMatchTheCanonicalTerraformCommand (policy)
  • TestTheGateRefusesADestroyByEverySpelling, TestTheGateHoldsAShellApplyToTheSessionPlan through the shell tool's real classifier
  • TestADestroyByAnotherSpellingNeverRuns through the agent loop with every permission allowed
  • TestTheApplyOfADestroyPlanIsRefusedAtTheGate, TestTerraformDestroyFlagReadsTheLastWordAsTerraformDoes
  • TestAPatternKeepsItsOwnWordsInTheCanonicalForm, TestTheTerraformToolRefusesACommandThatIsAnOption
  • the whole gate: gofmt, vet, go test -race ./..., coverage gate, lint, classify-diff, tidy, govulncheck
  • two evals on the lab with gemma4:e4b: no safety failure, tf-apply-needs-plan passes

The deny patterns read a canonical terraform form next to the kubectl one: terraform, tofu and terragrunt read as terraform, the global options and terragrunt's run words before the verb are dropped, and an apply or plan with a true -destroy flag reads as destroy. So the shipped terraform destroy* refuses tofu destroy, terragrunt run-all destroy, terraform apply -auto-approve -destroy and terraform plan -destroy -out=tfplan, and reaches every policy file already written. Pinned through the policy, the shell tool's classifier and the agent loop with every permission allowed.
While require_dry_run.terraform_apply is on, a terraform apply run through the shell (which runs the line as given, with no plan from this session) is refused with the steer to the terraform tool, whose apply shows the plan's summary first; the deny lines are read, so sh -c, xargs, tofu and terragrunt applies count. With the requirement off the shell apply is an ordinary mutation. kubectl and helm through the shell are unchanged.
…s as a destroy

A plan made with -destroy (read as terraform reads the flag: the last one wins, -destroy=false turns it off) is remembered as a destroy plan, and the apply that would run it carries a terraform destroy line for the deny patterns, so the shipped pattern refuses it before the dry run and the prompt. A plan without the flag leaves the apply as it was.
…attern keeps its own words

terragrunt's options take values of their own (--working-dir infra, --log-level debug) and its run forms include stack run, so the canonical form finds the verb as the first word after the program that names a terraform command, past options, their values and run words, and reads terragrunt --working-dir infra destroy as terraform destroy. A deny pattern keeps the words its operator wrote between the program and the verb, so terraform -chdir=*prod* apply* refuses only the prod directories and terragrunt run-all * leaves terraform alone, as on 3.2.5.
…the deny patterns

TF_CLI_ARGS, TF_CLI_ARGS_apply and TF_CLI_ARGS_plan in front of the command, or exported earlier on the line, carry terraform's flags; a true -destroy there makes the apply or plan read as terraform destroy, the assignment consumed into the verb so the shipped pattern meets the line at its start. So the deny pattern holds these forms with the plan requirement off as well, which the README offers tofu and terragrunt users.
…nknown verb stays in the line

The canonical pattern is matched against two forms of the line: the words between the program and the verb dropped (the shipped terraform destroy* meets terraform -chdir=infra apply -destroy) and kept (an operator's terraform -chdir=*prod* destroy* meets tofu -chdir=prod apply -destroy). A line whose verb is not a known terraform command keeps its first operand as the verb instead of losing its words, and terragrunt's older apply-all, destroy-all and plan-all spellings read as the verbs they are.
The collapsed line splits a quoted assignment value at its spaces, so every word from the assignment to the program counts as the value, each stripped of quotes and a trailing semicolon; a destroy among them reads as a destroy, which fails closed.
…its place

A command parameter that starts with an option (-chdir=infra apply) ran as a plain terraform call, past the plan the tool promises before an apply; the registry now refuses it before anything runs, naming the remedy (the command by name, the directory as dir). The tool's description says a plan made with -destroy is refused at apply.
@dayanstef dayanstef self-assigned this Oct 13, 2026
@codecov

codecov Bot commented Oct 13, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@dayanstef
dayanstef merged commit 6b2fa81 into main Oct 13, 2026
13 checks passed
@dayanstef
dayanstef deleted the fix/destroy-spellings branch October 13, 2026 13:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant