Skip to main content

Exit Codes

Tirith's exit codes are a contract shared by both surfaces — local evaluation (tirith) and platform evaluation (tirith platform check) — so a caller scripting both only has to learn one vocabulary.

CodeMeaning
0Policies passed, or nothing was in scope to gate on
1Tirith could not complete the evaluation — bad input, a policy it could not evaluate, unreachable API
2Timed out waiting for a StackGuardian run (tirith platform check only; local evaluation never produces it)
3A policy failed. Only with --fail-on-error, on either surface
130Interrupted (Ctrl-C)

3 is deliberately not 1

3 means a check ran and said no: your infrastructure violates a policy. 1 means Tirith could not tell you either way — an unparseable policy file, an unresolved {{ var.x }} variable, an unreachable API, or a policy whose every check was skipped. A job that treats every non-zero code alike reports an outage as a policy violation, and cannot tell a working gate from a broken one. Keeping the two codes distinct lets a pipeline page the platform team on 1 and the change author on 3.

Both surfaces fail closed: anything that leaves the verdict unknown exits non-zero regardless of --fail-on-error. That flag governs policy verdicts, not tool health — a run that produced no verdict must never look like a pass.

Without --fail-on-error

The local command exits 0 whether the policy passed or failed, with the verdict in the output. That is how it has always behaved, and it is left alone so that upgrading Tirith cannot turn a passing pipeline red; the gate is opt-in. tirith platform check behaves the same way: without the flag a policy failure logs a message and still exits 0, and the verdict is in --output-json.

Errors are different: a missing input file, an unparseable policy or an unresolved variable exits 1 even without the flag.

What each local outcome produces

Under --fail-on-error, the exit code is decided by the result's tri-state final_result:

final_resultMeaningExit
trueevery check that ran passed0
falsea check ran and failed3
nullevery check was skipped — the policy evaluated nothing1
absentthe policy could not be evaluated at all (for example an unresolved variable)1

null is not a pass. A policy whose every check was skipped — an error_tolerance swallowing a provider that found nothing — checked precisely nothing, and reporting that as green is exactly what the flag exists to prevent. It is not a violation either, so it is 1 rather than 3.

One limit worth stating plainly: a misconfigured policy — an unsupported condition.type, an unknown required_provider — comes back from the engine as an ordinary failed check with no error attached, so it is indistinguishable from a real violation and exits 3. It fails closed, which is the safe direction, but it will point at your infrastructure when the fault is in the policy.

Gating a CI job

Most CI systems fail a job on any non-zero exit, so the minimal gate is one line:

tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error

To act differently on "policy failed" versus "Tirith broke", branch on the code:

tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error --json > result.json
code=$?
case "$code" in
0) echo "policies passed" ;;
3) echo "a policy failed — see result.json" ; exit 1 ;;
*) echo "Tirith could not evaluate (exit $code) — this is a tooling problem, not a verdict" ; exit "$code" ;;
esac

The same pattern works for tirith platform check unchanged — the codes mean the same things. Complete CI examples are on the CI integration page.