Skip to content

Policy

llms.txtlists every page for an agent
Optional: hand this page to your coding agentThe steps work by hand too.
Show the whole prompt
Read https://intentius.io/terragucci/reference/policy/.
Write a Rego policy under the policy directory that denies the rule I name, with tests, and open a pull request.
Never apply, approve (a pull request review or `terragucci approve`), override a policy denial (`terragucci override`), use `--mode apply`, or merge; never touch `.chant/allowed_signers` or `chant/lifecycle`.

With policy in terragucci.yml, tf-plan fails each planned root whose plan JSON your Rego denies.

policy:
engine: conftest
path: policy
Key Default Meaning
engine conftest conftest or opa
path policy the directory of Rego files, inside the repo
namespace every namespace for conftest; for opa, main, or every package under terraform.policies with input: hcp the Rego package whose rules count
input plan what input holds: plan, the bare plan JSON; hcp, {plan, run} as HCP Terraform’s OPA policies read it
source none: the repo’s own path a shared policy repo at a pinned ref, git+https://<host>/<path>@<ref>; see below
override none: no override counts the forge identities or signers who may let one denied plan through tf-apply, read at base; see below

Many repos can share one policy repo. With source set, path is the directory inside it:

policy:
source: git+https://github.com/acme/policy.git@v3
path: policy
Part Meaning
git+https://<host>/<path> the policy repo; git+http:// for a forge on a private network, git+file:// for a repo on the job’s disk
@<ref> required: a tag, a branch, or a commit, which pins it exactly
path the Rego directory inside the policy repo, default policy

The repo that sets source needs no policy directory of its own. Each tf-plan, tf-check and tf-apply job fetches the source at the ref with its own git credentials and logs the commit read. A private policy repo needs a credential the job’s git can use, such as a credential helper.

Case Result
the source cannot be fetched, or the ref is not there every planned root fails
the ref has no path directory every planned root fails
a pull request changes source checked against the base’s source, as for any policy key

Set policy under defaults in the control repo, or under one project:

defaults:
policy:
source: git+https://github.com/acme/policy.git@v3
projects:
github.com/acme/infra: {}
github.com/acme/network: {}

A project’s jobs read policy from its own terragucci.yml, so reconcile writes the key there, in a file whose first line says the control repo writes it. reconcile fails a project whose terragucci.yml sets a different policy and names the key to set.

A policy is Rego that denies with a message:

package main
import rego.v1
deny contains msg if {
some rc in input.resource_changes
rc.type == "aws_s3_bucket_public_access_block"
rc.change.after.block_public_acls == false
msg := sprintf("%s must block public ACLs", [rc.address])
}

input is the unredacted show -json of one root. Before a denial or warning reaches the report, note or log, every sensitive value in it, short ones such as 1 included, becomes (sensitive, redacted by terragucci).

Settings by where the policy came from:

Policy source Setting
written for terragucci input: plan, the default
HCP Terraform OPA policy set input: hcp, with engine: opa (below)
Spacelift input: plan, and change input.terraform to input in the policy
Scalr OPA policy group input: plan, and change input.tfplan to input in the policy; input.tfrun has no counterpart

HCP Terraform, Spacelift and Scalr policies read other paths and deny nothing as written.

In tf-plan and every tf-apply wave, input.cost holds the monthly cost of the root and of its wave when cost is set. Under input: plan it sits beside the plan’s keys (the plan JSON has no cost key of its own); with input: hcp, beside plan and run.

Field Value
cost.estimator, cost.currency the estimator’s name, such as infracost, and its currency
cost.root.monthly_delta the change this root’s plan makes to its monthly cost
cost.root.monthly_total, cost.root.past_monthly_total the root’s monthly cost after the plan and before it
cost.wave.number, cost.wave.monthly_delta, cost.wave.monthly_total, cost.wave.past_monthly_total the root’s wave and the same figures summed over its roots estimated; in tf-plan, over the roots the change reaches
cost.approve_above cost.approve_above in the config at base, or null

A figure the estimator could not give is null. Without cost, input.cost is absent. A denial on cost blocks the plan and the wave like any other, and an override can let one plan through.

package main
import rego.v1
deny contains msg if {
input.cost.wave.monthly_delta > 500
msg := sprintf("wave %d adds %v %s a month", [input.cost.wave.number, input.cost.wave.monthly_delta, input.cost.currency])
}

With review.agent set, input.review holds the model’s review of the pull request a tf-apply wave applies, beside cost. tf-plan runs before the review, so it has none.

Field Value
review.found whether a run of the default branch’s review workflow kept a review of the pull request’s head
review.risk low, medium or high from that review; unknown when none was found, the model gave none, or its command failed
review.pull_request, review.head the pull request the commit merged, or the one applied before merge, and its head; null for a commit no pull request made

The wave reads, through the forge’s API, the newest terragucci-review-<head> artifact kept by a run of the default branch’s review workflow:

Forge Run read
GitHub a workflow_run run of terragucci-review.yml; the apply jobs get actions: read for it
Forgejo a pull_request_target run of it whose event names the default branch as the base, and this pull request and head
GitLab none; there is no review job, so found is always false

The artifact must say it reviewed this pull request and head against the default branch. The wave passes over an artifact of that name from any other run, such as the pull request’s own pipeline, and logs why. It never reads the note on the pull request, since any run’s token can post one, a run of another branch included (threat model). An artifact the forge has expired reads as no review. Without review.agent, input.review is absent.

package main
import rego.v1
deny contains msg if {
input.review.risk == "high"
msg := sprintf("the review of pull request %d says risk high", [input.review.pull_request])
}
Rule Effect
deny, violation, deny_<name>, violation_<name> fail the root
warn, warn_<name> warn

A message is the rule’s string or its object’s msg. Both engines give the same verdict.

Each denial carries its rule’s id, which an override names:

Engine Rule id
conftest the package and rule of the query that denied, such as main.deny_public_bucket
opa the package and rule, such as terraform.policies.no_public_buckets.deny
opa with a policies.hcl the policy’s name, such as no_public_buckets

The job’s log and roots[].policy.rules in the report list them.

If no Rego file outside the tests declares the namespace and has a deny, violation or warn rule, every planned root is refused and the message lists the packages found.

The plan note on a pull request whose policy denies dev orders' change: envs/dev/orders refused to plan, with conftest's denial naming its jobs queue as keeping messages longer than four daysThe plan note on a pull request whose policy denies dev orders' change: envs/dev/orders refused to plan, with conftest's denial naming its jobs queue as keeping messages longer than four days
Case Result
a root’s policy denies it the root fails and the job exits 1; the denials and their rule ids go to roots[].policy in the report
the policy cannot run (no engine, Rego that does not compile) the root fails
a warning never blocks

A denial clears when the code changes until the policy passes, or when the policy changes in a pull request your reviewers approve and merge. A listed approver can also override one plan. Responses, agents and comments cannot waive it.

With override set, a person it lists can let one denied plan through tf-apply. The override is a line on the ledger beside the approvals, and it never edits the policy. tf-plan still fails the root.

policy:
path: policy
override: [github:alice, github:bob]
  1. The policy denies a root in a tf-apply wave. The wave applies nothing and exits 1. It records the denial on chant/lifecycle in _gates/policy-override.jsonl and prints the command:

    envs/prod/app: policy.override at base lists github:alice, github:bob; one of them can let this plan through with:
    terragucci override envs/prod/app --rule main.deny_public_bucket --reason "<why>"
  2. A listed approver reads the plan and the denial, then runs that command in a checkout, with the reason (override). It records the override with the reason, with --sign under approval: sealed.

  3. Run the wave’s job again. Before applying the root, it marks the override used in _gates/policy-override/applied.jsonl; its report and note name the override.

Part Effect
one root every other root’s denial stands
its plan digest a plan that changes before the override applied gives another digest: the override counts for nothing, the wave applies nothing and exits 4, and the next run asks again. Once a wave applied under the override, the root’s next denial is recorded as a new one and exits 1, waiting for its own override
the rule ids --rule names exactly the rules that denied the plan; a rule added or gone needs a new override
a reason required; kept on the ledger line and shown in the report
Case Counts
by someone override lists in the config at base, written after the wave recorded the denial, with a reason yes
the same under approval: sealed only when sealed by a key the signers file at base lists for that person
by someone not listed, or listed only by the commit being applied no
a line a job wrote, such as a pr-review record no
no override at base nothing counts, and no denial is recorded
a root the policy could not check (no engine, Rego that does not compile) never overridden

Base is the applied commit’s first parent, or the pull request’s base with apply.when: pull-request, as for approvals. Under approval: ledger the line names its approver without proving who wrote it; approval: sealed proves it (threat model).

Place Shows
the wave’s report roots[].policy.override: who, when, the rules, the reason, the plan digest; policy.overridden lists the roots
the pull request’s plan note, and the wave’s note.md the override that stands for each denied root, or the command to write one; the plan job still fails the root
the estate page the roots the newest apply waves applied under an override

A pull request’s plan is checked against the policy key and directory at the base branch, so editing, deleting or renaming them, or terragucci.yml, changes nothing until merged.

At the base Result
no policy key the pull request’s own policy applies
the key, but no directory, or an unreadable config every planned root fails
commit missing from the checkout the checkout’s key: every root fails if it has one, else no policy runs

A base terragucci.ts is folded to a value without running it.

terragucci reads origin/<branch>, and the checkout needs that ref fetched. TG_BASE wins on every forge.

GITHUB_BASE_REF

tf-check runs conftest verify or opa test on the base branch’s policy and fails on a failing test. A directory with no *_test.rego is skipped.

The failed check job in Forgejo, its check step open where conftest verify failed on the policy read from origin/main and named the test test_denies_an_instanceThe failed check job in Forgejo, its check step open where conftest verify failed on the policy read from origin/main and named the test test_denies_an_instance

tf-apply checks each wave’s plans before gating them. A denied wave applies nothing and records no approval; with override set it records the denial for an override, and a root an override stands for goes on to the gate. The policy comes from the default branch, or from TG_BASE when set.

terragucci downloads a missing engine once per job and refuses one whose SHA-256 differs from the pinned one.

Engine Packages read Pinned version tf-check runs
conftest every package, unless namespace names one 0.71.0 conftest verify
opa main, or every package under terraform.policies with input: hcp, unless namespace names one 1.21.1 opa test

Offline jobs need the engine installed; drift runs and provisional Terragrunt previews skip the check.

With input: hcp, terragucci wraps each plan as HCP Terraform does, so the Rego of an HCP Terraform OPA policy set reads the paths it reads there:

policy:
engine: opa
path: policies
input: hcp
package terraform.policies.no_public_buckets
import rego.v1
deny contains msg if {
some rc in input.plan.resource_changes
rc.type == "aws_s3_bucket_public_access_block"
rc.change.after.block_public_acls == false
msg := sprintf("%s must block public ACLs (workspace %s)", [rc.address, input.run.workspace.name])
}

input.plan is the plan; input.run holds what terragucci knows of the run, in HCP Terraform’s field names:

Field Value
run.workspace.name, run.workspace.working_directory the root’s path in the repo
run.organization.name the repo’s owner, from <host>/<owner>/<name>
run.project.name the repo’s name
run.commit_sha the commit planned
run.speculative true in tf-plan, false in a tf-apply wave
run.message pull request <n> for a pull request’s plan, else empty
run.is_destroy, run.refresh_only false
run.refresh true
run.replace_addrs, run.target_addrs, run.workspace.tags empty lists
run.variables empty
run.cost_estimate.prior_monthly_cost, proposed_monthly_cost, delta_monthly_cost with cost set, the root’s monthly cost before, after and the change, as decimal strings

With engine: opa and input: hcp, terragucci runs each policy in the set’s policies.hcl as HCP Terraform does:

policy "no_public_buckets" {
query = "data.terraform.policies.no_public_buckets.deny"
enforcement_level = "mandatory"
}
policy "owner_tags" {
query = "data.terraform.policies.owner_tags.deny"
enforcement_level = "advisory"
}

Mandatory messages fail the root, as does a missing or unmatched query or an unknown level; advisory ones, the default, warn.

namespace keeps only the policies whose query is in that package.

Without policies.hcl, opa runs each package under terraform.policies as a policy. conftest ignores the file and treats every policy as mandatory.

The pinned OPA reads Rego 1.0, so older policies need if and contains. terragucci does not run Sentinel or tfpolicy policies.

terragucci

These docs count page views and clicks with PostHog. They set no cookies, store nothing in your browser, and send nothing when your browser asks not to be tracked.