Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 133 additions & 0 deletions .claude/skills/tirith-policies/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
---
name: tirith-policies
description: Author, debug and review Tirith IaC governance policies. 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.
---

# Writing Tirith policies

A Tirith policy is **JSON data, not a program**. It names a provider, the value to inspect and
the condition that value must satisfy. Tirith does the traversal and returns resource-level
evidence.

Policies live under `.tirith/policies` and are evaluated against the plan a pipeline already
produces (`terraform show -json tfplan > plan.json`).

## Do not guess the vocabulary

The two mistakes that cost the most time are inventing a `condition.type` and inventing an
`operation_type`. Both are enumerated below. An unknown condition type is especially expensive:
**the engine reports it as an ordinary failed check with no error attached**, so it is
indistinguishable from a real violation and will send someone to debug infrastructure that is
fine.

If the `mcp` extra is installed, prefer the tools over this file — they read the live registries:

```
tirith mcp # describe_provider, lint_policy, evaluate, explain_result
```

## Shape

```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` references evaluators **by id** and combines them with `and` / `or`. An
evaluator the expression never names cannot affect the verdict.
- `terraform_resource_type: "*"` means every resource type is in scope.

## Condition types

`ContainedIn`, `Contains`, `Equals`, `GreaterThan`, `GreaterThanEqualTo`, `IsEmpty`,
`IsNotEmpty`, `LessThan`, `LessThanEqualTo`, `NotContainedIn`, `NotContains`, `NotEquals`,
`RegexMatch`

There is no `Exists`, no `Matches`, no `In`. Use `IsNotEmpty` for presence.

## Providers and their `operation_type` values

| `required_provider` | Reads | `operation_type` |
|---|---|---|
| `stackguardian/terraform_plan` | a `terraform show -json` plan | `action`, `attribute`, `count`, `direct_dependencies`, `direct_references`, `provider_config`, `terraform_version` |
| `stackguardian/infracost` | an Infracost breakdown | `total_monthly_cost`, `total_hourly_cost` |
| `stackguardian/kubernetes` | Kubernetes manifests | `attribute` |
| `stackguardian/json` | any JSON document | `get_value` |
| `stackguardian/terraform_state` | a state document | as for plans |

## Gotchas the schema does not tell you

Each of these produces a policy that looks correct and behaves wrongly. They cost real time to
discover.

- **The key naming the value to read differs per provider.** `terraform_plan` and
`terraform_state` use `terraform_resource_attribute`; `kubernetes` uses `attribute_path` (and
requires `kubernetes_kind`); `json` uses `key_path`. Using another provider's key is not an
error — it is *ignored*, so the evaluator reads `None` and tests the condition against nothing.
- **`error_tolerance` lives inside `condition`**, and its severities are specific:
`0` = the resource is being deleted (`change.after` is null), `1` = the type is absent from the
plan, `2` = the attribute is absent. Choose the tolerance from which of those you mean to
forgive, not by feel.
- **There is no `NotRegexMatch`**, and no inverse conditions generally. Write the positive
detector and invert it in `eval_expression` with `!`. That is the only negation mechanism.
- **`operation_type: attribute` reads `change.after` only.** Nothing about a resource being
*destroyed* is visible through it — use `action` for that.
- **`count` has no action filter**, and Terraform reports unchanged resources as `no-op`, so
`count(*)` measures root-module size, not the size of the change. Blast radius is not
expressible today.
- **`jmespath` and `jq_query` do not ship.** Some test fixtures in the repository reference them,
which makes them look supported. The `json` provider supports `get_value`.

## Reading the verdict

Exit codes are a contract. Never collapse them:

| Exit | Meaning |
|---|---|
| `0` | Policies passed, or nothing was in scope |
| `3` | A policy ran and said no — the change violates a rule |
| `1` | Tirith could not tell you either way: bad input, an unevaluable policy, or **every check skipped** |

`final_result: null` means every check was skipped. **That is not a pass.** It almost always
means `provider_args` matched nothing — check `terraform_resource_type` and the attribute path
before touching the condition.

One asymmetry worth knowing: when a check fails because an attribute is *absent*, there is no
value to attach a resource to, so that failure arrives **without a resource address**. Find the
culprit by looking in the plan for the resource lacking the attribute.

## Before you hand a policy back

1. Does `eval_expression` reference every evaluator you wrote?
2. Is every `condition.type` in the list above?
3. Did you **run it**? A policy that matches nothing looks identical to one that works.

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

Never claim a policy works without evaluating it against a document that should fail it. A
guardrail only ever seen passing is a guardrail nobody has tested.

## Reference

- Policy reference — https://stackguardian.github.io/tirith/docs/tirith-policies/tirith-policy-reference/
- Conditions — https://stackguardian.github.io/tirith/docs/tirith-policies/tirith-policy-conditions/
- Exit codes — https://stackguardian.github.io/tirith/docs/tirith-usage/exit-codes/
- Worked examples — `src/tirith/tui/examples/`
47 changes: 47 additions & 0 deletions .cursor/rules/tirith-policies.mdc
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
---
description: Authoring and debugging Tirith IaC governance policies
globs: ["**/.tirith/policies/**", "**/*.tirith.json"]
alwaysApply: false
---

Tirith policies are JSON data, not programs. Do not invent vocabulary — both registries are
closed, and an unknown `condition.type` reaches the engine as an ordinary failed check with no
error attached, so it reads as a real infrastructure violation and sends someone to debug
working infrastructure.

**Condition types** — the complete list:
`ContainedIn`, `Contains`, `Equals`, `GreaterThan`, `GreaterThanEqualTo`, `IsEmpty`,
`IsNotEmpty`, `LessThan`, `LessThanEqualTo`, `NotContainedIn`, `NotContains`, `NotEquals`,
`RegexMatch`. There is no `Exists` — use `IsNotEmpty` for presence.

**`operation_type` by provider:**
- `stackguardian/terraform_plan` — `action`, `attribute`, `count`, `direct_dependencies`,
`direct_references`, `provider_config`, `terraform_version`
- `stackguardian/infracost` — `total_monthly_cost`, `total_hourly_cost`
- `stackguardian/kubernetes` — `attribute`
- `stackguardian/json` — `get_value`

**Structure:** `meta` (`version`, `required_provider`, `name`) · `evaluators[]` (each with `id`,
`provider_args`, `condition`) · `eval_expression` combining evaluator ids with `and` / `or`. An
evaluator the expression never references cannot affect the verdict.

**Verdicts:** exit `0` passed · `3` a policy failed · `1` no verdict was reached. `final_result:
null` means every check was skipped — that is not a pass, and it usually means `provider_args`
matched nothing.

**Gotchas:** the key naming the value differs per provider — `terraform_resource_attribute`
for terraform_plan/state, `attribute_path` plus `kubernetes_kind` for kubernetes, `key_path` for
json. Another provider's key is ignored rather than rejected, so the evaluator reads nothing. `error_tolerance` goes inside
`condition` — severity `0` = resource being deleted, `1` = type absent, `2` = attribute absent.
`attribute` reads `change.after` only, so use `action` for destroys. `count(*)` is root-module
size, not change size. `jmespath` and `jq_query` do not ship despite appearing in test fixtures.

**Always evaluate before claiming a policy works.** A policy that matches nothing looks identical
to one that works:

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

Worked examples: `src/tirith/tui/examples/`. Reference:
https://stackguardian.github.io/tirith/docs/tirith-policies/tirith-policy-reference/
34 changes: 34 additions & 0 deletions .github/ISSUE_TEMPLATE/first-pipeline-help.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
name: Help me govern my first IaC pipeline
about: Get help adding a local, credential-free first check to your pipeline
title: 'Help: first pipeline — '
labels: ['help wanted', 'first-pipeline']
assignees: ''
---

A Tirith maintainer or community member can help you add a local,
credential-free first check.

**Do not include secrets, plan files or private source code in this issue.**
Redact anything you paste, or reduce it to a minimal public example.

### CI system

<!-- GitHub Actions / GitLab CI / other (which?) -->

### IaC tool and version

<!-- Terraform or OpenTofu, and the version -->

### How the plan is produced, and where the plan JSON ends up

<!-- e.g. "terraform plan -out=tfplan in a plan job, artifact passed to the next job" -->

### The first guardrail you want to enforce

<!-- In plain words is fine: "every S3 bucket must have an Owner tag", "no
destroy of anything tagged production" -->

### A public example repository, or a redacted workflow snippet

<!-- Optional, but it is the difference between a general answer and a specific one -->
26 changes: 26 additions & 0 deletions .github/ISSUE_TEMPLATE/policy-request.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
name: Policy request
about: A rule you cannot express in a Tirith policy today
title: 'Policy: '
labels: ['policy', 'enhancement']
assignees: ''
---

### The rule, in plain words

<!-- "Every RDS instance must have deletion protection on" -->

### What you tried

<!-- The policy JSON you wrote, redacted. Say which provider and operation_type. -->

### What happened instead

<!-- Passed when it should have failed? Matched nothing? An error? Paste the
output, or the relevant part of `--json`. -->

### The input document

<!-- terraform_plan / terraform_state / kubernetes / infracost / json.
A minimal, redacted fragment showing the shape you need to reach is
enormously helpful — no real values. -->
36 changes: 36 additions & 0 deletions .github/ISSUE_TEMPLATE/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
name: Proposal / RFC
about: Propose a design change before writing it
title: 'RFC: '
labels: ['rfc']
assignees: ''
---

Substantial changes should start here rather than as a pull request — it is
cheaper to disagree about an approach in a paragraph than in a diff. See
[GOVERNANCE.md](../../GOVERNANCE.md) for how these get decided.

### The problem

<!-- What is hard or impossible today. Concrete cases, not abstractions. -->

### Proposed change

<!-- What you would do about it. -->

### Contracts this touches

<!-- Tick anything that applies — these need two maintainer approvals:
- [ ] policy schema, or the meaning of an existing field
- [ ] CLI flags, output shape or exit codes
- [ ] the action's inputs, outputs or default behaviour
- [ ] what leaves the machine, in either mode
-->

### Alternatives considered

<!-- Including doing nothing. -->

### Who this affects, and how they migrate

<!-- If it is breaking, say so plainly and describe the upgrade. -->
77 changes: 77 additions & 0 deletions .github/repository-metadata.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# Repository metadata

GitHub's About panel, topics and social preview cannot be set from a file in
the repository — a maintainer has to apply them in **Settings** and in the
sidebar's **About** gear. This file is the source of truth for what they should
say, so the values are reviewable in a pull request rather than living only in
one person's browser.

Keep it in step with the landing page (`documentation/src/pages/index.js`) and
the README opening.

## About

**Description**

> Open-source IaC governance for any Terraform or OpenTofu pipeline. Evaluate
> plans locally, explain failures and stop unsafe changes before apply.

**Website**

> https://stackguardian.github.io/tirith/

**Topics**

```
terraform
opentofu
infrastructure-as-code
policy-as-code
devops
platform-engineering
ci-cd
compliance
cloud-security
github-actions
gitlab-ci
```

## Social preview

Settings → General → Social preview. 1280×640.

**Title**

> Tirith — open-source IaC governance

**Subtitle**

> Put governance in front of every Terraform or OpenTofu plan.

> [!NOTE]
> **Open item:** the image itself does not exist yet. Until it is uploaded,
> GitHub falls back to the repository owner's avatar, which reads as a
> StackGuardian link rather than a project link.

## Settings to confirm before launch

- **Private vulnerability reporting** enabled (Settings → Security). Without
it, the reporting link in [SECURITY.md](../SECURITY.md) does not work.
- **Discussions** enabled, if the "tell us how the first plan went" link on the
landing page is to resolve; otherwise repoint it at Issues.
- A **`good first issue`** label with 8–12 genuinely scoped issues behind it.
Both the README and the landing page link to that label.
- **`POSTHOG_KEY`** repository secret and optional **`POSTHOG_HOST`** variable,
read by `.github/workflows/deploy_docs.yml`. Unset means the published site
ships no analytics, which is a safe default rather than a failure.
- **`HUBSPOT_PORTAL_ID`** and **`HUBSPOT_FORM_GUID`** repository variables, for
the Fleet enquiry form. The HubSpot form needs properties named `email`,
`company`, `repository_band`, `ci_systems`, `primary_problem` and `context`.
Unset, the form disables itself and points at GitHub Issues instead.

## Naming

Search results and social cards use **Tirith IaC Governance** (the Docusaurus
site title) rather than bare "Tirith", to distinguish the project from the
unrelated Tirith terminal-security tool and the unrelated `tirith` package on
PyPI. Keep that qualifier in any new SEO-facing copy.
13 changes: 13 additions & 0 deletions .github/workflows/deploy_docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,22 @@
- name: Install dependencies
run: |
cd documentation
npm ci

Check warning on line 30 in .github/workflows/deploy_docs.yml

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Omitting "--ignore-scripts" allows lifecycle scripts to run during package installation.

See more on https://sonarcloud.io/project/issues?id=StackGuardian_policy-framework&issues=AaA5VFA8l93g0l1fDQoX&open=AaA5VFA8l93g0l1fDQoX&pullRequest=285

# POSTHOG_KEY is optional. Unset -- on a fork, or in a pull-request build --
# the site ships no analytics script at all, which is the behaviour we want
# everywhere except the published site.
- name: Build documentation site
env:
POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }}
POSTHOG_HOST: ${{ vars.POSTHOG_HOST }}
# The Fleet enquiry form. Neither is a secret -- a HubSpot portal id
# and form guid are public by design, since the browser posts to them
# directly -- but they are supplied here rather than committed so a
# fork's build does not point at StackGuardian's CRM. Unset, the form
# disables itself and explains why.
HUBSPOT_PORTAL_ID: ${{ vars.HUBSPOT_PORTAL_ID }}
HUBSPOT_FORM_GUID: ${{ vars.HUBSPOT_FORM_GUID }}
run: |
cd documentation
npm run build
Expand Down
Loading
Loading