---
name: tirith-policies
description: Write, validate, run and debug Tirith IaC governance policies, install Tirith, and add it to a CI pipeline (GitHub Actions, GitLab CI, Bitbucket Pipelines, Jenkins, Azure DevOps, CircleCI or any container runner). Use when writing or editing files under .tirith/policies, when a Tirith check fails in CI, when asked to add a guardrail to a Terraform or OpenTofu pipeline, or when reading a Tirith result document or exit code.
---

# Tirith

Tirith evaluates the plan a pipeline already produces against declarative JSON policies and exits
non-zero so a violating change never reaches `apply`. A policy is **JSON data, not a program**: it
names a provider, the value to inspect, and the condition that value must satisfy.

## Install

Not from PyPI. `pip install tirith` installs an **unrelated project**; `py-tirith` is the
`setup.py` name and is not published. Install from git, pinned to a tag:

```bash
pip install "git+https://github.com/StackGuardian/tirith.git@1.2.1"
tirith --version   # 1.2.1
```

## The one rule

**Never hand back a policy you have not run against a document that should fail it.**

A policy that matches nothing looks identical to one that works. Run it against input you expect
to be refused. Exit `0` means it matched nothing and gates nothing.

```bash
tirith -policy-path .tirith/policies -input-path should-fail.json --fail-on-error; echo $?   # want 3
```

## Exit codes

| Exit | Meaning | What CI should do |
| --- | --- | --- |
| `0` | Every check passed | Continue to `apply` |
| `3` | A policy failed | Fail the job: the change was refused |
| `1` | No verdict could be reached | Fail the job, but report a **tool or input** problem |

- Never collapse `3` into `1`. A job that cannot tell them apart reports an outage as a violation.
- `2` is only argparse rejecting a flag (`tirith lint --bogus`, `tirith platform check` with a
  missing flag). An unknown subcommand exits `1`, as does a platform timeout. Neither `1` nor `2`
  ever means a policy said no.
- **`final_result: null` is not a pass.** Every check was skipped, nothing was evaluated, exit `1`.
- Without `--fail-on-error` the exit is always `0`. Every real gate needs the flag.

## Write a policy

Decide these four in order. Guessing any of them is the main source of silently broken policies.

1. **Document.** Terraform or OpenTofu plan, Kubernetes manifest, Infracost breakdown, or arbitrary
   JSON or YAML. This fixes `meta.required_provider`. Five providers ship; there is **no
   CloudFormation provider**, a template is arbitrary JSON read by `stackguardian/json`.
2. **Operation.** Each provider exposes a closed set: `reference/schema.md`.
3. **Key naming the value.** It differs per provider, and a wrong key is *ignored, not rejected*,
   so the check reads nothing and passes: `reference/schema.md`.
4. **Condition.** Thirteen, listed in `reference/schema.md`. There is no `Exists`.

```json
{
  "meta": {
    "version": "v1",
    "required_provider": "stackguardian/terraform_plan",
    "name": "Every resource carries a costcenter tag"
  },
  "evaluators": [{
    "id": "costcenter_tag_present",
    "description": "Every taggable resource declares a costcenter tag",
    "provider_args": {
      "operation_type": "attribute",
      "terraform_resource_type": "*",
      "terraform_resource_attribute": "tags.costcenter"
    },
    "condition": {"type": "IsNotEmpty"}
  }],
  "eval_expression": "costcenter_tag_present"
}
```

`eval_expression` combines evaluator **ids** with `&&`, `||`, `!` and parentheses. An evaluator
it never names cannot affect the verdict. `!` is the only negation: write the positive detector
and invert it.

## Traps

- **`error_tolerance` goes inside `condition`.** On the evaluator it is silently ignored.
  `{"condition": {"type": "IsNotEmpty", "error_tolerance": 2}}`
- **One evaluator, one result per matching resource.** Three buckets give three results from one
  rule, and the check fails if any of them fails.
- **Missing attribute is severity 2, missing resource type is severity 1.** With
  `error_tolerance: 2` a resource lacking the attribute is *skipped*, not failed. If every
  evaluator is skipped the policy is `final_result: null`, exit `1`. Skipping is not passing.
- **A type-scoped policy refuses a plan with none of that type.** Severity 1 under the default
  tolerance is exit `3`; with `error_tolerance: 1` it is exit `1`. Neither is `0`; a `count`
  guard is. See `reference/verdicts.md`.
- **The delete action is spelled `delete`.** `"destroy"` matches nothing and the guard exits `0`.
  `action` emits one result per action: `NotEquals "delete"` blocks deletes and replacements,
  `ContainedIn ["delete"]` with `!` blocks only a pure delete. See `reference/terraform-plan.md`.
- **An unknown `condition.type` exits `3`, not `1`.** The message names it (`` `Exists` is not a
  supported evaluator ``) but `errors` is empty, so CI sees a violation. Check the type against
  the closed list, not your memory.
- **`tirith lint` catches most of the above before CI does**, and needs no plan document. It ships
  from `1.2.1`. `reference/validate.md` has the detail.

## Test it with the bundled example

`examples/required-tags/` holds the policy above, a plan that violates it and one that satisfies
it. Copy the pair and edit it when testing a new policy.

```bash
cd examples/required-tags
tirith -policy-path policy.json -input-path should-fail.json --fail-on-error; echo $?   # 3
tirith -policy-path policy.json -input-path should-pass.json --fail-on-error; echo $?   # 0
```

## Before you hand it back

1. Does `eval_expression` reference every evaluator you wrote?
2. Is every `condition.type` in the closed list of thirteen?
3. Is the argument key the one this provider reads?
4. Is `error_tolerance`, if used, inside `condition`?
5. Did you **run it** against a document that should fail it, and did it exit `3`?
6. Did you run it against a document that should pass, and did it exit `0`?

## Reference

| File | Use it for |
| --- | --- |
| `reference/schema.md` | The closed vocabulary: conditions, providers, operations, argument keys |
| `reference/validate.md` | Checking a policy is well-formed with `tirith lint`, `tirith fmt` and the interface |
| `reference/verdicts.md` | Running a policy, exit codes, and finding the resource behind a failure |
| `reference/terraform-plan.md` | The plan provider's operations, for OpenTofu and Terraform |
| `reference/other-providers.md` | Kubernetes, Infracost and arbitrary JSON or YAML |
| `reference/variables.md` | One policy across environments with `-var` |
| `reference/install.md` | Installing Tirith, and why the install is a git URL |
| `reference/pipelines.md` | GitHub Actions, GitLab CI, Bitbucket, Jenkins, Azure DevOps, CircleCI |
| `reference/platform.md` | Evaluating against an organization's central policies |
| `reference/debug-ci.md` | Starting from a red build and ending at the rule and the resource |
| `examples/required-tags/` | A policy, a plan that fails it, and a plan that passes it |

Translating existing Sentinel policies is a separate skill, `tirith-migrate`, installed alongside
this one.
