Skip to main content
TirithThree providers, twenty-three lessons

Learn to write a Tirith policy.

Pick the thing you actually need to gate and learn on that. The syntax is the same for all three, so the Terraform track teaches it in full and the other two teach only what changes. Every step is editable: change a value, run it, and watch the verdict, the messages and the exit code move with it.

Browser playground

This runs a Tirith-compatible teaching subset in your browser, not the Python package. Edit either pane and press Run check. It implementsstackguardian/json, stackguardian/terraform_plan andstackguardian/kubernetes, and its verdicts are checked against the real engine. Install Tirith for Infracost and StackGuardian Workflow inputs, and for anything you intend to trust.

Choose what you want to gate
stackguardian/terraform_plan

An OpenTofu or Terraform plan

The provider Tirith exists for, and the one to learn on. Nine lessons that build one policy a rule at a time, against a plan of the shape your pipeline already produces with terraform show -json.

Start here. The syntax you learn on a plan is the same syntax everywhere else.

01

A policy is a document

meta · evaluators · eval_expression

Every Tirith policy has the same three parts. meta names the provider that will read your input. evaluators is the list of checks. eval_expression says how their results combine into one verdict. Nothing here is a program: it is a description of what to look for in a plan you are about to apply.

A plan is not a tree you walk, so there is no path here. It is a list of resource changes, and you address one by naming a resource type and an attribute on it. One database matches, so there is one result.

Try itChange the value to false. The verdict, the message and the exit code all move together.

policy.json
input.json
showing the current policy
stdoutfinal_result true · exit 0
$ tirith --fail-on-error -policy-path policy.json -input-path input.jsonCheck: db_encrypted  PASSED    1. PASSED: `true` is equal to `true` Passed: 1 Failed: 0 Skipped: 0 Final expression used:-> db_encrypted$ echo $?0
02

One check, every matching resource

many results · every one must pass

Name a type that matches more than one resource and the check runs against each of them separately. The evaluator passes only if every result passes, so you write the rule once and it covers the bucket somebody adds next month without you editing anything.

Two buckets, two results, and the failing one names the value that failed rather than just the rule. That is the difference between a report you can act on and a red cross.

Try itChange acl on aws_s3_bucket.logs to private and the whole plan passes.

policy.json
input.json
showing the current policy
stdoutfinal_result false · exit 3
$ tirith --fail-on-error -policy-path policy.json -input-path input.jsonCheck: buckets_private  FAILED    1. PASSED: `"private"` is equal to `"private"`    2. FAILED: `"public-read"` is not equal to `"private"` Passed: 0 Failed: 1 Skipped: 0 Final expression used:-> buckets_private$ echo $?3
03

Every resource in the plan

"*" · exclude_resource_types

"*" as the resource type means every resource the plan touches, whatever it is. This is how you write the rules that are actually organisational policy rather than service-specific: everything must be owned, everything must be tagged, nothing may be created outside a region. exclude_resource_types carves out the ones that genuinely cannot comply.

Four resources, four results, in plan order. Note what the printed report does *not* say: results are numbered, not named, so 4. FAILED means the fourth resource rather than aws_instance.runner. The address is carried in the --json output under meta and shown by tirith ui; putting it in the printed message is on the roadmap and has not shipped.

Try itChange one resource’s Owner tag to "", then count down the list to find which resource line 4 is.

policy.json
input.json
showing the current policy
stdoutfinal_result true · exit 0
$ tirith --fail-on-error -policy-path policy.json -input-path input.jsonCheck: everything_owned  PASSED    1. PASSED: `"data"` is not empty    2. PASSED: `"platform"` is not empty    3. PASSED: `"platform"` is not empty    4. PASSED: `"ci"` is not empty Passed: 1 Failed: 0 Skipped: 0 Final expression used:-> everything_owned$ echo $?0
04

Combining checks

&& · || · grouping

Each evaluator has an id, and eval_expression combines those ids with &&, ||, ! and parentheses. That is the whole grammar. The evaluators do not know about each other; the expression is the only place their results meet.

Both checks run whatever the expression says, so you always see every result. The expression decides the single verdict at the end, and only that verdict reaches the exit code.

Try itSwap && for ||. One passing check is now enough to carry the whole policy, which is usually not what you want.

policy.json
input.json
showing the current policy
stdoutfinal_result false · exit 3
$ tirith --fail-on-error -policy-path policy.json -input-path input.jsonCheck: buckets_private  FAILED    1. PASSED: `"private"` is equal to `"private"`    2. FAILED: `"public-read"` is not equal to `"private"` Check: db_encrypted  PASSED    1. PASSED: `true` is equal to `true` Passed: 1 Failed: 1 Skipped: 0 Final expression used:-> buckets_private && db_encrypted$ echo $?3
05

Gate the change, not the value

operation_type: action

This is the operation with no equivalent in a document, and it is the reason a plan is worth reading at all. action does not ask what a resource *is*. It asks what Terraform is about to do to it: create, update, delete, no-op. A policy over actions gates the change itself, which is the only moment the damage is still preventable.

This is the shape of "no pull request may destroy a database". A resource can carry more than one action, and every one of them is checked, so a replacement, which terraform reports as delete then create, cannot slip past a rule written about creation.

Try itChange an actions array to ["delete"] and watch a green plan turn red.

policy.json
input.json
showing the current policy
stdoutfinal_result true · exit 0
$ tirith --fail-on-error -policy-path policy.json -input-path input.jsonCheck: nothing_destroyed  PASSED    1. PASSED: `"update"` is not equal to `"delete"`    2. PASSED: `"create"` is not equal to `"delete"`    3. PASSED: `"create"` is not equal to `"delete"`    4. PASSED: `"update"` is not equal to `"delete"` Passed: 1 Failed: 0 Skipped: 0 Final expression used:-> nothing_destroyed$ echo $?0
06

Reaching inside a resource

nested attributes · one result per element

Real resources are not flat. A block that repeats, like ebs_block_device, arrives as a list, and .*. walks into it. Each element becomes its own result, so one attached volume that is unencrypted fails the check even though its neighbour on the same instance is fine.

Remember this shape. The Kubernetes provider spells its wildcard the same way and means something different by it, which is the subject of the last lesson in that track and the easiest way on this whole site to write a policy that gates nothing.

Try itSet both encrypted values to true. Then delete the .*. and see the attribute stop resolving.

policy.json
input.json
showing the current policy
stdoutfinal_result false · exit 3
$ tirith --fail-on-error -policy-path policy.json -input-path input.jsonCheck: volumes_encrypted  FAILED    1. PASSED: `true` is equal to `true`    2. FAILED: `false` is not equal to `true` Passed: 0 Failed: 1 Skipped: 0 Final expression used:-> volumes_encrypted$ echo $?3
07

When the attribute is not there

error_tolerance · a skip is not a pass

Nothing in this plan sets server_side_encryption, so the provider cannot produce a value to judge. That is not a violation, and it is not compliance either. Tirith reports it as an error with a severity, and error_tolerance decides whether that error fails the check or skips it.

Severity 1 means the resource type is not in the plan. Severity 2 means the resource is there but the attribute is not. severity > tolerance fails, anything else skips. With every check skipped the verdict is neither true nor false, and the exit code is 1, not 0: a policy that evaluated nothing must never look like a policy that passed.

Try itDrop error_tolerance to 1. The skip becomes a failure and the exit code changes from 1 to 3.

policy.json
input.json
showing the current policy
stdoutfinal_result null · exit 1
$ tirith --fail-on-error -policy-path policy.json -input-path input.jsonCheck: bucket_encryption  SKIPPED    1. SKIPPED: attribute: 'server_side_encryption' is not found    2. SKIPPED: attribute: 'server_side_encryption' is not found Passed: 0 Failed: 0 Skipped: 1 Final expression used:-> bucket_encryption$ echo $?1
08

Zero is an answer

operation_type: count · and the error that does not happen

count returns one number: how many resources of a type this change touches. The detail worth knowing is what it does not do. Every other operation reports an error when the resource type is absent from the plan, and that error can fail your check. count reports 0, because zero of something is a real answer and usually the one you are gating on.

Two buckets, so this fails. The same policy against a plan with no buckets at all returns 0 and passes, with no error and no skip. Worth knowing before you reach for count as a safety net: it cannot tell you that it looked and found nothing.

Try itRaise the value to 2 and it passes. Then delete both buckets from the plan: still passing, on 0.

policy.json
input.json
showing the current policy
stdoutfinal_result false · exit 3
$ tirith --fail-on-error -policy-path policy.json -input-path input.jsonCheck: bucket_budget  FAILED    1. FAILED: `2` is not less than or equal to `1` Passed: 0 Failed: 1 Skipped: 0 Final expression used:-> bucket_budget$ echo $?3
09

The whole thing

a policy you would actually commit

Four rules over one plan: everything is owned, buckets are private, attached volumes are encrypted, and nothing is destroyed. This is the size of a real starting policy, and it is the file you would put in .tirith/policies and point a pipeline at.

The report tells you which rule refused and which value refused it, which is what makes a red build actionable rather than a thing to re-run. Under --fail-on-error this exits 3, and the pipeline stops before apply.

Try itFix the plan until it passes: logs to private, and the second volume encrypted. The exit code goes to 0.

policy.json
input.json
showing the current policy
stdoutfinal_result false · exit 3
$ tirith --fail-on-error -policy-path policy.json -input-path input.jsonCheck: everything_owned  PASSED    1. PASSED: `"data"` is not empty    2. PASSED: `"platform"` is not empty    3. PASSED: `"platform"` is not empty    4. PASSED: `"ci"` is not empty Check: buckets_private  FAILED    1. PASSED: `"private"` is equal to `"private"`    2. FAILED: `"public-read"` is not equal to `"private"` Check: volumes_encrypted  FAILED    1. PASSED: `true` is equal to `true`    2. FAILED: `false` is not equal to `true` Check: nothing_destroyed  PASSED    1. PASSED: `"update"` is not equal to `"delete"`    2. PASSED: `"create"` is not equal to `"delete"`    3. PASSED: `"create"` is not equal to `"delete"`    4. PASSED: `"update"` is not equal to `"delete"` Passed: 2 Failed: 2 Skipped: 0 Final expression used:-> everything_owned && buckets_private && volumes_encrypted && nothing_destroyed$ echo $?3
10

Playground

both panes yours

Both panes are yours. Edit, run, and read the report — a half-written policy is reported in the output rather than as a crash, because while you are editing, the broken state is the normal state.

Conditions available hereEquals · NotEquals · GreaterThan · GreaterThanEqualTo · LessThan · LessThanEqualTo · IsEmpty · IsNotEmpty · RegexMatch · ContainedIn · NotContainedIn · Contains · NotContains
policy.json
input.json
showing the current policy
stdoutfinal_result false · exit 3
$ tirith --fail-on-error -policy-path policy.json -input-path input.jsonCheck: everything_owned  PASSED    1. PASSED: `"data"` is not empty    2. PASSED: `"platform"` is not empty    3. PASSED: `"platform"` is not empty    4. PASSED: `"ci"` is not empty Check: buckets_private  FAILED    1. PASSED: `"private"` is equal to `"private"`    2. FAILED: `"public-read"` is not equal to `"private"` Check: volumes_encrypted  FAILED    1. PASSED: `true` is equal to `true`    2. FAILED: `false` is not equal to `true` Check: nothing_destroyed  PASSED    1. PASSED: `"update"` is not equal to `"delete"`    2. PASSED: `"create"` is not equal to `"delete"`    3. PASSED: `"create"` is not equal to `"delete"`    4. PASSED: `"update"` is not equal to `"delete"` Passed: 2 Failed: 2 Skipped: 0 Final expression used:-> everything_owned && buckets_private && volumes_encrypted && nothing_destroyed$ echo $?3

Now run it for real.

The same policy file, against a terraform plan, in your own pipeline.

Install
pip install "git+https://github.com/StackGuardian/tirith.git@1.2.0"
Getting started Put it in your pipeline Run it as you write Governing many repositories