Policy
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 |
A shared policy source
Section titled “A shared policy source”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 |
From a control repo
Section titled “From a control repo”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.
Writing a policy
Section titled “Writing a policy”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])
}Rego input
Section titled “Rego input”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])
}Review
Section titled “Review”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])
}Counted rules
Section titled “Counted rules”| 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.
Namespace matching
Section titled “Namespace matching”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.
Denials
Section titled “Denials”

| 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.
Overriding a denial
Section titled “Overriding a denial”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]-
The policy denies a root in a
tf-applywave. The wave applies nothing and exits 1. It records the denial onchant/lifecyclein_gates/policy-override.jsonland 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>" -
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--signunderapproval: sealed. -
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.
Override binding
Section titled “Override binding”| 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 |
Valid overrides
Section titled “Valid overrides”| 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).
Override records
Section titled “Override records”| 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 |
The base branch decides
Section titled “The base branch decides”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.
Finding the base branch
Section titled “Finding the base branch”terragucci reads origin/<branch>, and the checkout needs that ref fetched. TG_BASE wins on every forge.
GITHUB_BASE_REF
CI_MERGE_REQUEST_TARGET_BRANCH_NAME
GITHUB_BASE_REF
Tests and the apply check
Section titled “Tests and the apply check”tf-check
Section titled “tf-check”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.


tf-apply
Section titled “tf-apply”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.
The engines
Section titled “The engines”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.
HCP Terraform policy sets
Section titled “HCP Terraform policy sets”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: hcppackage 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])
}The run fields
Section titled “The run fields”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 |
policies.hcl
Section titled “policies.hcl”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.
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.