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.
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.
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.
A policy is a document
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.
$ 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 $?0One check, every matching resource
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.
$ 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 $?3Every resource in the plan
"*" 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.
$ 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 $?0Combining checks
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.
$ 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 $?3Gate the change, not the value
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.
$ 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 $?0Reaching inside a resource
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.
$ 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 $?3When the attribute is not there
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.
$ 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 $?1Zero is an answer
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.
$ 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 $?3The whole thing
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.
$ 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 $?3Playground
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.
$ 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 $?3Now run it for real.
The same policy file, against a terraform plan, in your own pipeline.
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