diff --git a/.claude/skills/tirith-policies/SKILL.md b/.claude/skills/tirith-policies/SKILL.md new file mode 100644 index 00000000..e0de797c --- /dev/null +++ b/.claude/skills/tirith-policies/SKILL.md @@ -0,0 +1,135 @@ +--- +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. Tirith does the traversal and returns resource-level evidence. + +## Install: not from PyPI + +`pip install tirith` installs an **unrelated project of the same name**. `pip install py-tirith` +finds nothing: that is the package name in `setup.py`, and it is not published. Install from git, +pinned to a tag: + +```bash +pip install "git+https://github.com/StackGuardian/tirith.git@1.2.0" +``` + +## 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: same shape, same silence. Run it +against input you expect to be refused. If that run exits `0`, the policy matched nothing and +gates nothing. + +```bash +tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error +``` + +## Exit codes are a contract + +| 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 | + +`ExitStatus.ERROR_TIMEOUT = 2` is declared in `status.py` and returned nowhere, including on the +platform path, which maps a timeout to `1`. Do not branch a pipeline on it. + +`3` is deliberately not `1`. Collapsing them reports an outage as a policy violation, and a job +that cannot tell them apart cannot tell a working gate from a broken one. + +**`final_result: null` is not a pass.** It means every check was skipped, so the policy evaluated +nothing. It exits `1`. + +## Write a policy + +Work in this order. Guessing any of the four is the main source of silently-broken policies. + +1. **Which document are you reading?** An OpenTofu or Terraform plan, a Kubernetes manifest, an + Infracost breakdown, or arbitrary JSON or YAML. That fixes `meta.required_provider`. There are + five providers and **no CloudFormation provider**: a CloudFormation template is arbitrary JSON, + read by `stackguardian/json`. +2. **Which operation?** Each provider exposes a closed set: see `reference/schema.md`. +3. **Which key names the value?** It differs per provider, and the wrong one is *ignored* rather + than rejected, so the check reads nothing and passes. See `reference/schema.md`. +4. **Which 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 +the expression never names cannot affect the verdict. `!` is the only negation mechanism: there +are no inverse conditions, so write the positive detector and invert it. + +## Four traps that cost the most time + +**`error_tolerance` goes inside `condition`, not on the evaluator.** On the evaluator it is +silently ignored: no warning, and the check still fails. + +```json +{"condition": {"type": "IsNotEmpty", "error_tolerance": 2}} +``` + +**One evaluator produces one result per matching resource.** A plan with three buckets gives three +results from one rule, and the check fails if any of them fails. That is the mechanism, not a +wildcard trick. + +**A missing attribute is severity 2, a missing resource type is severity 1.** With +`error_tolerance: 2` a resource lacking the attribute is *skipped* rather than failed, which can +turn the whole policy into `final_result: null`. Skipping is not passing. + +**`tirith lint` is not in the released package.** It is in development. The released CLI dispatches +`tirith`, `tirith ui` and `tirith platform check` and nothing else, so do not put it in a pipeline +you are writing for someone. Check policy shape by reading `reference/schema.md` and by running +the policy. See `reference/validate.md`. + +## 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 `error_tolerance`, if used, inside `condition`? +4. Did you **run it** against a document that should fail it, and did it exit `3`? + +## 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, and the traps to check by hand | +| `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 | + +Worked policy/input pairs live in `src/tirith/tui/examples/` in the Tirith repository. diff --git a/.claude/skills/tirith-policies/reference/debug-ci.md b/.claude/skills/tirith-policies/reference/debug-ci.md new file mode 100644 index 00000000..892bf9cd --- /dev/null +++ b/.claude/skills/tirith-policies/reference/debug-ci.md @@ -0,0 +1,72 @@ +# Debug a red CI check + +Start from a failed build and end at the rule and the resource. Work in this order — it is +ordered by how often each step is the answer. + +## 1. Which exit code? + +```bash +echo $? +``` + +| Exit | What it means | Where to look | +| --- | --- | --- | +| `3` | A policy ran and refused the change | Step 2 — this is a real verdict | +| `1` | No verdict was reached | Step 4 — this is not a violation | +| `0` but you expected a failure | Nothing was in scope, or `--fail-on-error` is missing | Step 5 | + +A job that collapses `1` and `3` will send you to step 2 for a problem that lives in step 4. Fix +the job's exit-code handling first if it does that. + +## 2. Which check failed, and on which resource? + +```bash +tirith --json -policy-path .tirith/policies -input-path plan.json > result.json +``` + +In `result.json`, find the evaluator with `"passed": false`. Each entry in its `result[]` array +carries a `meta` with the resource: `address`, `type`, and the `change` with `actions`, `before` +and `after`. Name the resource from `meta.address` rather than quoting the message — on a wildcard +policy every message reads identically. + +`tirith ui --result result.json` opens the same document in an explorer, if the extra is installed. + +## 3. Is the finding correct? + +Read `change.after` for the address and compare it against `condition.value`. Three outcomes: + +- The value really does violate the rule → fix the Terraform. +- The value is fine but the rule tests the wrong thing → fix the policy. +- The value is **absent** → the failure will arrive *without* a resource address, because there is + no value to attach one to. Search the plan for the resource lacking that attribute. + +## 4. Exit `1` — no verdict + +Check `final_result` in the result document. + +- **`final_result: null`** — every check was skipped, so nothing was evaluated. Almost always + `provider_args` matching nothing. Verify the resource type is in the plan, then the attribute + path, then whether `error_tolerance` is forgiving the very thing you meant to catch. +- **No `final_result` at all** — the policy could not be loaded. Usually an unresolved variable; + read the `errors` array. +- **A misconfigured policy** — an unsupported `condition.type` or unknown provider arrives as an + ordinary failed check and exits `3`, not `1`. Check `condition.type` and every `provider_args` + key against `reference/schema.md`: an unknown key is ignored rather than rejected, so the fault + is in the policy even though the failure points at infrastructure. + +## 5. Exit `0` when you expected a failure + +- Is `--fail-on-error` present? Without it the exit code is always `0` and the verdict is only in + the output. +- Did the policy match anything? A cost policy over a misspelled `resource_type` sums to `0` and + passes. A wildcard attribute policy with `error_tolerance: 2` skips every resource lacking the + attribute. +- Prove the gate works by running it against a document that **should** fail it. A guardrail only + ever seen passing is a guardrail nobody has tested. + +## The two commands + +```bash +tirith --json -policy-path .tirith/policies \ + -input-path plan.json | head -40 # what did it actually decide? +``` diff --git a/.claude/skills/tirith-policies/reference/install.md b/.claude/skills/tirith-policies/reference/install.md new file mode 100644 index 00000000..518055cf --- /dev/null +++ b/.claude/skills/tirith-policies/reference/install.md @@ -0,0 +1,55 @@ +# Install Tirith + +## Tirith is not on PyPI + +`pip install tirith` installs an **unrelated project of the same name**, and `pip install +py-tirith` finds nothing. Always install from git. + +```bash +pip install git+https://github.com/StackGuardian/tirith.git +``` + +Pin a tag rather than tracking the default branch, so a CI job cannot change behaviour underneath +you: + +```bash +pip install "git+https://github.com/StackGuardian/tirith.git@1.2.0" +``` + +`git ls-remote --tags https://github.com/StackGuardian/tirith.git` lists the available tags. + +## Verify + +```bash +tirith --version +``` + +## Python versions + +| | Needs | +| --- | --- | +| Tirith itself | Python 3.8 or newer | +| The `[tui]` extra (`tirith ui`) | Python 3.9 or newer | + +## The optional interface + +`tirith ui` is an interactive terminal interface — explore a failing evaluation down to the +resource, assemble a policy from a form, or experiment in a playground. It is an extra rather than +a dependency, because using Tirith as a CI gate should not pay for an interface it never opens. + +```bash +pip install 'py-tirith[tui] @ git+https://github.com/StackGuardian/tirith.git' +tirith ui +``` + +The extra is requested against the git URL for the same reason as above. On Python 3.8 the extra +installs and simply provides nothing. + +## Developing against a checkout + +```bash +git clone https://github.com/StackGuardian/tirith.git +cd tirith +python -m venv .venv && source .venv/bin/activate +pip install -e . +``` diff --git a/.claude/skills/tirith-policies/reference/other-providers.md b/.claude/skills/tirith-policies/reference/other-providers.md new file mode 100644 index 00000000..8afb189c --- /dev/null +++ b/.claude/skills/tirith-policies/reference/other-providers.md @@ -0,0 +1,78 @@ +# Kubernetes, Infracost and JSON + +Choosing a provider is choosing what document you have to feed it. Each names the value it reads +with a **different key** — the wrong key is ignored, not rejected, so the evaluator reads nothing +and the check does not measure what you think. + +## Kubernetes + +`"required_provider": "stackguardian/kubernetes"`, reading YAML or JSON manifests. Multi-document +YAML is supported. + +- `operation_type`: `attribute` +- Requires `kubernetes_kind` — `Pod`, `Deployment`, `Service`, … +- Names the value with **`attribute_path`** + +```json +{ + "id": "containers_have_liveness_probe", + "provider_args": { + "operation_type": "attribute", + "kubernetes_kind": "Pod", + "attribute_path": "spec.containers.*.livenessProbe" + }, + "condition": {"type": "Contains", "value": null, "error_tolerance": 2} +} +``` + +with `"eval_expression": "!containers_have_liveness_probe"`. + +**Why it is written as a detector.** `spec.containers.*.livenessProbe` returns a *list* — one entry +per container, `null` where the probe is missing. `IsNotEmpty` over that list is true as soon as +one container has a probe, which is the wrong question. Test for the presence of `null` and invert. + +## Infracost + +`"required_provider": "stackguardian/infracost"`, reading an `infracost breakdown --format json` +document. + +- `operation_type`: `total_monthly_cost` or `total_hourly_cost` +- `resource_type`: a list. `["*"]` totals everything. + +```json +{ + "id": "monthly_cost_ceiling", + "provider_args": {"operation_type": "total_monthly_cost", "resource_type": ["*"]}, + "condition": {"type": "LessThanEqualTo", "value": 500} +} +``` + +**The trap: it fails open.** A `resource_type` that matches nothing — a typo, or a type absent from +this plan — sums to `0`, and `0` is less than any ceiling, so the check **passes**. A cost policy +that always passes looks exactly like one that works. Verify against a breakdown that should +exceed the ceiling, and prefer `["*"]` unless you specifically need one type. + +## JSON — anything else + +`"required_provider": "stackguardian/json"` reads any JSON document: a Terraform state file, an API +response, a CI configuration, a lockfile. + +- `operation_type`: `get_value` +- Names the value with **`key_path`** + +```json +{ + "id": "approval_required", + "provider_args": {"operation_type": "get_value", "key_path": "settings.requireApproval"}, + "condition": {"type": "Equals", "value": true} +} +``` + +`key_path` accepts `*` across a list: `list_of_dicts.*.key1` returns one value per entry, and the +condition is applied to each. + +## StackGuardian workflows + +`"required_provider": "stackguardian/sg_workflow"` reads a workflow definition, naming the value +with **`workflow_attribute`**, for rules about the pipeline itself rather than the infrastructure — +for example that a Terraform workflow requires approval before apply. diff --git a/.claude/skills/tirith-policies/reference/pipelines.md b/.claude/skills/tirith-policies/reference/pipelines.md new file mode 100644 index 00000000..f118c995 --- /dev/null +++ b/.claude/skills/tirith-policies/reference/pipelines.md @@ -0,0 +1,212 @@ +# Add Tirith to a pipeline + +Three pieces make a gate, and they are the same three on every platform: + +1. a policy committed under `.tirith/policies/`, +2. the plan as JSON, +3. Tirith running with `--fail-on-error`. + +Only the way you invoke it changes. Everything below is that pattern. + +## Produce the input first + +This is the step people leave out, and without it `plan.json` never exists: + +```bash +terraform plan -out=tfplan -input=false # or: tofu plan -out=tfplan -input=false +terraform show -json tfplan > plan.json # or: tofu show -json tfplan > plan.json +``` + +Either binary works: they emit the same plan JSON and Tirith does not inspect which produced it. + +**`-input=false` matters in CI.** Without it, a missing variable waits for a prompt nobody will +answer and the job hangs instead of failing. Locally you can leave it off. + +## Install + +```bash +pip install "git+https://github.com/StackGuardian/tirith.git@1.2.0" +``` + +Not PyPI: `pip install tirith` fetches an unrelated project. Pin the tag so a job cannot change +behaviour underneath you. Python 3.8 or newer, so any `python:3.x` image works. + +Do **not** add `tirith lint` to a pipeline: it is not in the released package. See +`reference/validate.md`. + +--- + +## GitHub Actions + +Use the action. It finds the plan, posts a sticky pull-request comment, creates a check run and +sets the job's exit code. + +```yaml +permissions: + contents: read + pull-requests: write # the sticky comment + checks: write # the check run + +steps: + - uses: actions/checkout@v4 + - run: terraform plan -out=tfplan -input=false + + - uses: StackGuardian/tirith-iac-governance-action@v2 + with: + plan-file: tfplan + fail-on-error: true +``` + +`plan-file` takes the **binary** plan and renders it with `terraform show -json` in memory, so no +unmasked plan JSON is written to the workspace. Prefer it on GitHub: it is one step shorter than +exporting first, and a plan file on disk is a plan file something else can read. + +Exporting JSON yourself also works, and then no `with:` keys are needed at all: the action finds +the document by convention (`plan.json` or `tfplan.json`) and the policies under +`.tirith/policies`. `plan-file` and `input-path` cannot be combined. + +Without `fail-on-error` it reports findings but does not block. + +The two write permissions are the only setup the action cannot do for itself, and the usual cause +of a first install that runs but posts nothing. + +## GitLab CI + +No GitLab-native equivalent, so call the CLI directly, which is all the action does underneath. +Two jobs: one produces the artifact, the next gates on it. + +```yaml +plan: + stage: plan + script: + - terraform plan -out=tfplan -input=false + - terraform show -json tfplan > plan.json + artifacts: + paths: [plan.json] + +policy: + stage: test + image: python:3.12 + needs: [plan] + script: + - pip install "git+https://github.com/StackGuardian/tirith.git@1.2.0" + - tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error +``` + +If a plan job already exists, keep it: add `plan.json` to its `artifacts` and point the gate at it +with `needs`. + +## Bitbucket Pipelines + +Plan in one step, gate in the next, passing `plan.json` between them as an artifact. + +```yaml +image: python:3.12 + +pipelines: + pull-requests: + '**': + - step: + name: Terraform plan + script: + - terraform plan -out=tfplan -input=false + - terraform show -json tfplan > plan.json + artifacts: [plan.json] + - step: + name: Policy gate + script: + - pip install "git+https://github.com/StackGuardian/tirith.git@1.2.0" + - tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error +``` + +## Jenkins + +Capture the exit code rather than letting `sh` fail, so `3` and `1` can be reported differently: + +```groovy +stage('Policy gate') { + steps { + script { + def code = sh(returnStatus: true, script: ''' + pip install "git+https://github.com/StackGuardian/tirith.git@1.2.0" + tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error + ''') + if (code == 3) { error('Tirith: a policy refused this change.') } + else if (code != 0) { error("Tirith could not reach a verdict (exit ${code}).") } + } + } +} +``` + +## Azure DevOps + +```yaml +- script: | + terraform plan -out=tfplan -input=false + terraform show -json tfplan > plan.json + displayName: Terraform plan + +- script: | + pip install "git+https://github.com/StackGuardian/tirith.git@1.2.0" + tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error + displayName: Policy gate +``` + +`script` fails the task on any non-zero exit. To separate `3` from `1`, run the gate without +`--fail-on-error`, capture `$?`, and fail the task yourself. + +## CircleCI + +```yaml +jobs: + policy-gate: + docker: + - image: cimg/python:3.12 + steps: + - checkout + - attach_workspace: {at: .} + - run: pip install "git+https://github.com/StackGuardian/tirith.git@1.2.0" + - run: tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error +``` + +Persist `plan.json` to the workspace from the plan job. + +## Any other runner + +Anything that can run a container and produce a plan works the same way, including a cron job: + +```bash +pip install "git+https://github.com/StackGuardian/tirith.git@1.2.0" +tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error +``` + +Gate on the exit code, which every CI system already does. + +--- + +## Make the exit code mean something + +| Exit | What the job should do | +| --- | --- | +| `0` | Continue to `apply` | +| `3` | Fail the job: a policy refused the change | +| `1` | Fail the job, but report a **tool or input problem**, not a violation | + +Collapsing `1` and `3` is the most common mistake in a Tirith pipeline. Exit `1` includes +`final_result: null`, which means every check was skipped and the policy evaluated nothing: a gate +that is not gating, reported as if the infrastructure were at fault. + +## Keep the report + +```bash +tirith --json -policy-path .tirith/policies -input-path plan.json > tirith-result.json +``` + +Publish it as a build artifact. It carries every evaluator, its result and the value that produced +it, which is what makes a failure explainable after the fact. + +## Not yet available + +A `tirith-lint` pre-commit hook and a VS Code task loop are in development, and both depend on +`tirith lint`, which is not in the released package. Do not write either into a pipeline today. +`https://stackguardian.github.io/tirith/roadmap/` tracks them. diff --git a/.claude/skills/tirith-policies/reference/platform.md b/.claude/skills/tirith-policies/reference/platform.md new file mode 100644 index 00000000..cd06856c --- /dev/null +++ b/.claude/skills/tirith-policies/reference/platform.md @@ -0,0 +1,61 @@ +# Evaluate against an organization's policies + +`tirith platform check` evaluates against the policies a StackGuardian organization enforces, +instead of policy files committed to the repository — so policy lives in one place rather than +being copied into every repository that needs gating. + +This is the **only** Tirith surface that talks to a network. Everything else runs locally. + +## Minimum invocation + +```bash +export SG_API_TOKEN=sgo_... # an organization token +export SG_ORG=my-org + +tirith platform check --workflow-id my-repo --input-path plan.json --fail-on-error +``` + +`--input-path` is optional when a `plan.json` or `tfplan.json` is in the working directory. + +## What it does, in order + +1. Masks Terraform-sensitive values **on your machine**, before anything leaves it. +2. Packs the masked documents with your Terraform source into an archive. +3. Uploads it, creates a run, polls it. +4. Prints the verdict, using the same exit codes as local evaluation. + +## Flags worth knowing + +| Flag | Use | +| --- | --- | +| `--region {eu,us}` | Which region. Default `eu`, or `$SG_REGION` | +| `--api-key -` | Read the key from stdin instead of the environment | +| `--plan-file tfplan` | A binary plan, rendered through `terraform show -json` in memory | +| `--input-kind` | `terraform_plan`, `terraform_state`, `kubernetes` or `json` | +| `--state-path` / `--infracost-path` | Add a state document or a cost breakdown to the run | +| `--no-source` | Send only the documents, not the Terraform source | +| `--timeout` | Seconds to wait for the run. Default `1800` | +| `--output-json` / `--output-markdown` | Write the verdict to files for a later CI step | +| `--api-url` | Overrides `--region` for a self-hosted or dedicated host | + +## Exit codes + +The same contract as local evaluation, plus one: + +| Exit | Meaning | +| --- | --- | +| `0` | Passed | +| `1` | Could not reach a verdict — bad input, unreachable API | +| `2` | Timed out waiting for the run | +| `3` | A policy failed (with `--fail-on-error`) | + +## Credentials + +Never commit `SG_API_TOKEN`. In CI, supply it as a secret. `--api-key -` lets you pipe it in +without it appearing in a process list. + +## When not to use it + +If policies live in the repository and one team owns them, local evaluation is simpler, needs no +account, and sends nothing anywhere. Reach for `platform check` when the same policy has to apply +across many repositories and the results need to be visible in one place. diff --git a/.claude/skills/tirith-policies/reference/schema.md b/.claude/skills/tirith-policies/reference/schema.md new file mode 100644 index 00000000..44e9caf9 --- /dev/null +++ b/.claude/skills/tirith-policies/reference/schema.md @@ -0,0 +1,79 @@ +# Schema — the closed vocabulary + +Both registries are closed. Inventing a value does not raise an error: an unknown +`condition.type` reaches the engine as an **ordinary failed check with no error attached**, so it +is indistinguishable from a real violation and sends someone to debug infrastructure that is fine. + +Confirm against the live registry rather than this file. `tirith lint --gotchas` will do it once +lint ships; today the registry itself is the source of truth: + +```bash +python -c "from tirith.core.evaluators import EVALUATORS_DICT; print(sorted(EVALUATORS_DICT))" +``` + +## Policy shape + +| Key | Required | Notes | +| --- | --- | --- | +| `meta.version` | yes | `"v1"` | +| `meta.required_provider` | yes | One of the providers below | +| `meta.name` / `description` / `severity` / `tags` | no | Passed through to the result document | +| `evaluators[]` | yes | Each needs `id`, `provider_args`, `condition` | +| `eval_expression` | yes | Combines evaluator ids with `&&`, `\|\|`, `!`, parentheses | + +## Condition types — all 13 + +``` +ContainedIn Contains Equals GreaterThan GreaterThanEqualTo IsEmpty +IsNotEmpty LessThan LessThanEqualTo NotContainedIn NotContains +NotEquals RegexMatch +``` + +There is no `Exists`, no `Matches`, no `In`, and no `NotRegexMatch`. Use `IsNotEmpty` for +presence, and `!` in `eval_expression` for negation. + +`condition.value` keeps its JSON type: `true` and `"true"` are different questions. + +## Providers and operations + +| `required_provider` | Reads | `operation_type` | +| --- | --- | --- | +| `stackguardian/terraform_plan` | `terraform show -json` output | `action`, `attribute`, `count`, `direct_dependencies`, `direct_references`, `provider_config`, `terraform_version` | +| `stackguardian/kubernetes` | Kubernetes manifests (YAML or JSON) | `attribute` | +| `stackguardian/infracost` | An Infracost breakdown | `total_monthly_cost`, `total_hourly_cost` | +| `stackguardian/json` | Any JSON document, including a Terraform state file | `get_value` | +| `stackguardian/sg_workflow` | A StackGuardian workflow definition | `attribute` | + +## The argument key differs per provider + +This is the highest-cost mistake in the schema, because the wrong key is **ignored rather than +rejected** — the evaluator then reads nothing and the check does not measure what you think. + +| Provider | Key naming the value | Also needs | +| --- | --- | --- | +| `terraform_plan` | `terraform_resource_attribute` | `terraform_resource_type` (`"*"` = all) | +| `kubernetes` | `attribute_path` | `kubernetes_kind` | +| `json` | `key_path` | — | +| `sg_workflow` | `workflow_attribute` | — | + +Paths are dot-separated and accept `*` as a wildcard across a list: `spec.containers.*.image`. + +## `error_tolerance` + +Lives **inside `condition`**. Anywhere else it has no effect. It forgives *problems reading the +input*, not policy failures, and the severities are specific: + +| Severity | Means | Forgiven by | +| --- | --- | --- | +| `0` | The resource is being deleted (`change.after` is null) | `error_tolerance: 0` (the default forgives nothing) | +| `1` | The resource type is absent from the document | `error_tolerance: 1` or higher | +| `2` | The attribute is absent from the resource | `error_tolerance: 2` | + +A forgiven problem makes the check **skipped**, not passed. A skipped check is removed from +`eval_expression` before evaluation. If every check is skipped the policy reports +`final_result: null` — see `reference/verdicts.md`. + +## Not supported, despite appearances + +`jmespath` and `jq_query` appear in some repository test fixtures. Neither ships. The `json` +provider supports `get_value`. diff --git a/.claude/skills/tirith-policies/reference/terraform-plan.md b/.claude/skills/tirith-policies/reference/terraform-plan.md new file mode 100644 index 00000000..d3b546e1 --- /dev/null +++ b/.claude/skills/tirith-policies/reference/terraform-plan.md @@ -0,0 +1,88 @@ +# The plan provider — OpenTofu and Terraform + +`"required_provider": "stackguardian/terraform_plan"` reads the output of +`tofu show -json tfplan` or `terraform show -json tfplan`. The provider is named for +Terraform because it predates the fork, but it reads either tool's plan: both emit the same +`resource_changes` structure, and nothing in the provider inspects which binary produced it. + +**There is no `stackguardian/terraform_state` provider.** The registry holds five providers and +that is not one of them. To write a policy about a state file, read it with +`stackguardian/json` and `key_path` — a state document is ordinary JSON. (`tirith platform check +--input-kind terraform_state` is a different thing: it tells the uploader to mask the document as +state before it leaves your machine, and the evaluation still runs through the json provider.) + +Arguments: `terraform_resource_type` selects the resources (`"*"` = every type), and +`terraform_resource_attribute` names the value. Dot-separated, `*` wildcards a list. + +## The seven operations + +| `operation_type` | Returns | Use it for | +| --- | --- | --- | +| `attribute` | The value at `terraform_resource_attribute` | Most policies: tags, encryption flags, sizes | +| `action` | The planned actions for each resource | Blocking destroys and replacements | +| `count` | How many resources of the type exist | Ceilings on resource counts | +| `direct_references` | Whether resources reference a given type | "Every ELB has a security group" | +| `direct_dependencies` | The resource's declared dependencies | Ordering and coupling rules | +| `provider_config` | Provider-level configuration | Pinning a region or a provider setting | +| `terraform_version` | The version recorded in the plan | Requiring a minimum OpenTofu or Terraform version | + +## `attribute` cannot see a destroy + +`attribute` reads **`change.after` only**. A resource being destroyed has `after: null`, so +nothing about a destroy is visible through it. Use `action`: + +```json +{ + "id": "no_database_destroy", + "provider_args": { + "operation_type": "action", + "terraform_resource_type": "aws_db_instance" + }, + "condition": {"type": "ContainedIn", "value": ["destroy"]} +} +``` + +with `"eval_expression": "!no_database_destroy"` — the check *detects* a destroy, and `!` turns +detection into refusal. + +## Replacement is two actions, not one + +A replacement appears as `["delete", "create"]` or `["create", "delete"]`, and the order matters: +destroy-first means downtime, create-first does not. If the distinction matters to your rule, test +the ordering rather than the presence of `delete`. + +## `count` measures the module, not the change + +`count` has no action filter, and both tools report unchanged resources as `no-op`. So `count` with +`terraform_resource_type: "*"` measures **root-module size**, not the size of the change. Blast +radius is not expressible today — do not write a policy that claims to cap it. + +## `direct_references` + +Answers "is every X referenced by a Y", which attribute checks cannot express: + +```json +{ + "id": "elb_has_security_group", + "provider_args": { + "operation_type": "direct_references", + "terraform_resource_type": "aws_elb", + "references_to": "aws_security_group" + }, + "condition": {"type": "Equals", "value": true, "error_tolerance": 0} +} +``` + +`referenced_by` inverts the direction — "every bucket is referenced by a tiering configuration". + +## Wildcards and missing attributes + +`terraform_resource_type: "*"` with a specific attribute will hit resources that do not have that +attribute at all. Those raise severity `2`. Decide deliberately: + +- `error_tolerance: 0` — a resource without the attribute **fails**. Right for "everything must be + tagged". +- `error_tolerance: 2` — it is **skipped**. Right for "where this attribute exists, it must be X". + +On a wildcard policy every message reads identically, and only the resource address in the result +distinguishes one finding from another. diff --git a/.claude/skills/tirith-policies/reference/validate.md b/.claude/skills/tirith-policies/reference/validate.md new file mode 100644 index 00000000..9f5c599b --- /dev/null +++ b/.claude/skills/tirith-policies/reference/validate.md @@ -0,0 +1,85 @@ +# Validate a policy + +## `tirith lint` is not in the released package + +It is in development. The released CLI dispatches `tirith`, `tirith ui` and `tirith platform +check` and nothing else, so `tirith lint` in a pipeline you are writing for someone else is a step +that fails with an unrecognised argument. + +Until it ships there are two ways to validate, and both are available today. + +## The interactive validator does ship + +`tirith ui` carries one. `src/tirith/tui/validate.py` reads the live `EVALUATORS_DICT` and +`PROVIDERS_DICT` and returns errors and warnings as data, and the Playground runs it on every +keystroke while the Builder refuses to add a check that fails it. So the registry-checking that +`tirith lint` will do from the command line is already in the product, just interactively: + +```bash +pip install 'py-tirith[tui] @ git+https://github.com/StackGuardian/tirith.git' +tirith ui --policy .tirith/policies/my-policy.json +``` + +It is advisory by design: it reports a malformed policy rather than refusing to evaluate it, +because experimenting with a half-written policy is the point of a playground. + +## Without the interface + +Check the shape against the closed vocabulary by hand, then evaluate the policy against a document +that should fail it. The second is the one that matters. + +## Check the shape + +Every trap here produces a policy that is structurally plausible and gates nothing, or that fails +for a reason unrelated to your infrastructure. + +| Trap | Why it matters | +| --- | --- | +| An invented condition type | There is no `Exists`, `Matches` or `In`. The engine returns an unknown type as an ordinary failed check, so it reads as a real violation rather than a typo. | +| A key from the wrong provider | `terraform_plan` reads `terraform_resource_attribute`; `kubernetes` reads `attribute_path`. An unrecognised key is **ignored, not rejected**, so the evaluator reads nothing and the check passes. | +| An operation that does not ship | `jmespath` and `jq_query` appear in some test fixtures. Neither exists. | +| `error_tolerance` outside `condition` | It belongs **inside** `condition`. On the evaluator it is silently ignored: no warning, and the check still fails as though the tolerance were never written. | +| An evaluator nothing references | If `eval_expression` never names it, it cannot affect the verdict, however carefully it was written. | +| A single `&` where `&&` was meant | `&` and `\|` are not operators. | +| A provider that does not exist | Five ship. There is no `stackguardian/cloudformation`: a CloudFormation template is read by `stackguardian/json`. | + +The closed lists are in `reference/schema.md`. Read them rather than recalling them: the cost of a +wrong key is a policy that passes everything. + +## Then evaluate it + +Shape is not meaning. A policy whose `provider_args` match no resource at all is structurally +perfect and gates nothing. + +```bash +# Against input that SHOULD be refused. Exit 3 is the pass condition for this test. +tirith -policy-path .tirith/policies -input-path should-fail.json --fail-on-error +echo "exit: $?" +``` + +| Exit | Reading | +| --- | --- | +| `3` | The policy works. It refused a change it was supposed to refuse. | +| `0` | **The policy matched nothing.** Wrong provider, wrong operation, or a key the provider ignores. | +| `1` | Every check was skipped, so `final_result` is `null`. Check `error_tolerance` and whether the resource type exists in the document. | + +Then run it against input that should pass, and confirm `0`. A rule only ever seen failing is as +untested as one only ever seen passing. + +## Read the report rather than the summary + +```bash +tirith --json -policy-path .tirith/policies -input-path plan.json > result.json +``` + +The JSON carries every evaluator, its result, and the value that produced it. When a check +surprises you, the value it actually read is the fastest way to the cause: an evaluator reading +`None` on every resource is the signature of a key the provider ignored. + +## When lint ships + +It reads the engine's own registries, so it catches the invented condition type and the +wrong-provider key from the source of truth rather than from a table that can go stale. It will +exit `3` for a bad policy and `1` for an unreadable path, matching the rest of Tirith: the linter +saying no about a policy is a verdict, not a tool failure. Check +`https://stackguardian.github.io/tirith/roadmap/` before assuming it is available. diff --git a/.claude/skills/tirith-policies/reference/variables.md b/.claude/skills/tirith-policies/reference/variables.md new file mode 100644 index 00000000..72db5a72 --- /dev/null +++ b/.claude/skills/tirith-policies/reference/variables.md @@ -0,0 +1,60 @@ +# Parameterise a policy + +One policy, different thresholds per environment, without copying the file. + +A variable is referenced as `{{ var.NAME }}`. **The `var.` prefix is required.** A placeholder +written without it — `{{ max_epoch }}` — is not recognised as a variable and is compared as the +literal string, so the check quietly measures the wrong thing instead of failing. + +## From a file + +```json title="variables.prod.json" +{"max_monthly_cost": 500} +``` + +```json title="policy.json" +{ + "meta": {"version": "v1", "required_provider": "stackguardian/infracost"}, + "evaluators": [{ + "id": "within_budget", + "provider_args": {"operation_type": "total_monthly_cost", "resource_type": ["*"]}, + "condition": {"type": "LessThanEqualTo", "value": "{{ var.max_monthly_cost }}"} + }], + "eval_expression": "within_budget" +} +``` + +```bash +tirith -policy-path policy.json -input-path infracost.json \ + -var-path variables.prod.json --fail-on-error +``` + +`-var-path` may be repeated. Later files override earlier ones on the same key. + +## Inline + +```bash +tirith -policy-path policy.json -input-path infracost.json \ + -var 'max_monthly_cost=500' --fail-on-error +``` + +The value is parsed as JSON, so quote strings as JSON strings and pass lists and objects directly: + +```bash +-var 'environment="production"' +-var 'allowed_regions=["eu-west-1","eu-central-1"]' +``` + +## If a variable is not found + +The evaluation stops and returns an `errors` entry naming the missing variables, rather than +substituting an empty value and producing a verdict from a policy that was never fully resolved. + +## Use it for + +- One cost ceiling per environment. +- An allow-list of regions or instance types that differs per team. +- A tag key your organisation renames without editing every policy. + +Keep the *shape* of the rule in the policy and only the *values* in variables. A variable that +changes which attribute is read makes the policy unreadable. diff --git a/.claude/skills/tirith-policies/reference/verdicts.md b/.claude/skills/tirith-policies/reference/verdicts.md new file mode 100644 index 00000000..4d1ea67a --- /dev/null +++ b/.claude/skills/tirith-policies/reference/verdicts.md @@ -0,0 +1,62 @@ +# Run a policy and read the verdict + +```bash +tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error +echo $? +``` + +`-policy-path` takes a file or a directory. `-input-path` takes the document the provider expects. +Add `--json` to get the result document instead of the pretty printer. + +## Exit codes are a contract + +| Exit | Meaning | +| --- | --- | +| `0` | Policies passed, or nothing was in scope to gate on | +| `1` | Tirith could not tell you either way — bad input, an unevaluable policy, or every check skipped | +| `2` | Timed out waiting for a StackGuardian run (`platform check` only) | +| `3` | A policy ran and said no | +| `130` | Interrupted | + +**`3` is deliberately not `1`.** `3` means a check ran and refused the change. `1` means Tirith +could not reach a verdict. 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. + +**Without `--fail-on-error` the exit code is always `0`** and the verdict is only in the output. +That is the historical behaviour, kept so upgrading cannot turn a passing pipeline red. Any real +gate needs the flag. + +## `final_result: null` is not a pass + +It means **every check was skipped** — nothing was evaluated. Under `--fail-on-error` that exits +`1`, not `0` and not `3`. + +It almost always means `provider_args` matched nothing. Check, in this order: + +1. `terraform_resource_type` — is that type actually in the plan? +2. The attribute path — is it under `change.after`, and spelled as the plan spells it? +3. `error_tolerance` — is it forgiving the very problem you wanted to catch? + +Do not reach for the condition until the provider is returning values. + +## Reading the result document + +`--json` returns `final_result`, an `evaluators[]` array, `errors`, and the `eval_expression` that +was evaluated. Each evaluator carries `passed` (`true`, `false`, or `null` for skipped) and a +`result[]` of individual findings. + +Each finding's `meta` carries the resource behind it: `address`, `type`, `name`, and the `change` +with `actions`, `before`, `after` and `after_unknown`. That is how you name the resource that +failed rather than only quoting the message. + +**One asymmetry:** when a check fails because an attribute is *absent*, there is no value to +attach a resource to, so the failure arrives **without a resource address**. Find the culprit by +looking in the plan for the resource lacking that attribute. + +## A misconfigured policy fails closed + +An unsupported `condition.type` or an unknown `required_provider` comes back as an ordinary failed +check with no error attached — indistinguishable from a real violation, and it exits `3`. It fails +in the safe direction, but it points at your infrastructure when the fault is in the policy. +Check the condition type against the closed list in `reference/schema.md`: a typo there is the +usual cause, and it is not reported as one. diff --git a/.cursor/rules/tirith-policies.mdc b/.cursor/rules/tirith-policies.mdc new file mode 100644 index 00000000..120d4676 --- /dev/null +++ b/.cursor/rules/tirith-policies.mdc @@ -0,0 +1,112 @@ +--- +description: Authoring Tirith IaC governance policies, and adding Tirith to a CI pipeline +globs: ["**/.tirith/policies/**", "**/*.tirith.json", "**/.github/workflows/*.y*ml", "**/.gitlab-ci.yml", "**/bitbucket-pipelines.yml", "**/Jenkinsfile", "**/azure-pipelines.yml", "**/.circleci/config.yml"] +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` +- `stackguardian/sg_workflow` — StackGuardian workflow documents + +Five providers, and no CloudFormation provider: a CloudFormation template is arbitrary JSON, read +by `stackguardian/json`. There is no `stackguardian/terraform_state` either; read a state file the +same way, with `key_path`. + +**Structure:** `meta` (`version`, `required_provider`, `name`) · `evaluators[]` (each with `id`, +`provider_args`, `condition`) · `eval_expression`. + +**`eval_expression` operators are `&&`, `||`, `!` and parentheses.** Not `and` / `or`: the engine +rejects those with `Unsupported operator in eval_expression`. `!` is the only negation, so write +the positive detector and invert it. An evaluator the expression never references cannot affect +the verdict. + +**Verdicts:** exit `0` passed · `3` a policy failed · `1` no verdict was reached. (`2` is declared +in `status.py` but never returned.) `final_result: null` means every check was skipped: that is not a pass, it exits `1`, and +it usually means `provider_args` matched nothing. + +**Gotchas:** +- The key naming the value differs per provider: `terraform_resource_attribute` for + terraform_plan, `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 and + the check passes. +- `error_tolerance` goes **inside `condition`**, not on the evaluator. On the evaluator it is + silently ignored and the check still fails. Severity `0` = resource being deleted, `1` = type + absent, `2` = attribute absent. +- One evaluator yields one result per matching resource. Three buckets give three results from one + rule, and the check fails if any fails. +- `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. Run it against input that *should* be refused; exit `3` is the pass condition +for that test, and exit `0` means it matched nothing. + +```bash +tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error +``` + +`tirith lint` is **not in the released package**; it is in development. Do not put it in a +pipeline. The released CLI dispatches `tirith`, `tirith ui` and `tirith platform check`. + +## Installing, and adding it to CI + +**Not from PyPI.** `pip install tirith` installs an unrelated project of the same name, and +`pip install py-tirith` finds nothing. Install from git, pinned to a tag: + +```bash +pip install "git+https://github.com/StackGuardian/tirith.git@1.2.0" +``` + +Every pipeline is the same three pieces: a policy under `.tirith/policies/`, the plan as JSON, and +Tirith with `--fail-on-error`. Produce the input first, or `plan.json` never exists: + +```bash +terraform plan -out=tfplan -input=false # -input=false, or CI hangs on a prompt +terraform show -json tfplan > plan.json # tofu works identically +``` + +**GitHub Actions** uses the action, which adds the pull-request comment and check run. It needs +`pull-requests: write` and `checks: write`, the only setup it cannot do for itself: + +```yaml +permissions: {contents: read, pull-requests: write, checks: write} +steps: + - run: | + terraform plan -out=tfplan -input=false + terraform show -json tfplan > plan.json + - uses: StackGuardian/tirith-iac-governance-action@v2 + with: {fail-on-error: true} +``` + +**GitLab, Bitbucket, Jenkins, Azure DevOps, CircleCI and anything else** call the CLI directly, +which is all the action does underneath. Plan in one job, gate in the next, and pass `plan.json` +between them as an artifact: + +```yaml +- pip install "git+https://github.com/StackGuardian/tirith.git@1.2.0" +- tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error +``` + +**Do not collapse exit `1` into exit `3`.** `3` means the change was refused; `1` means no verdict +was reached. A job that cannot tell them apart reports an outage as a policy violation, and cannot +tell a working gate from a broken one. + +Worked examples: `src/tirith/tui/examples/`. Deeper reference, if the repository has it: +`.claude/skills/tirith-policies/reference/` covers the schema, validation, verdicts, each +provider, policy variables, installing Tirith, six CI platforms, and debugging a red check. +Online: https://stackguardian.github.io/tirith/llms.txt diff --git a/.github/workflows/deploy_docs.yml b/.github/workflows/deploy_docs.yml index c18f02dc..f9e738f5 100644 --- a/.github/workflows/deploy_docs.yml +++ b/.github/workflows/deploy_docs.yml @@ -29,7 +29,13 @@ jobs: cd documentation npm ci + # POSTHOG_KEY is optional. Unset -- on a fork, or in any build that is not this one -- + # the client module never loads the script and src/analytics.js no-ops, so the published + # site is the only place anything is reported. - name: Build documentation site + env: + POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }} + POSTHOG_HOST: ${{ vars.POSTHOG_HOST }} run: | cd documentation npm run build diff --git a/documentation/.gitignore b/documentation/.gitignore index b2d6de30..054aec5d 100644 --- a/documentation/.gitignore +++ b/documentation/.gitignore @@ -18,3 +18,9 @@ npm-debug.log* yarn-debug.log* yarn-error.log* + +# Docusaurus's package-manager detection writes a yarn.lock here on build. +# This project installs with npm -- package-lock.json is the committed lockfile +# and both docs workflows say so explicitly -- so a stray yarn.lock is a side +# effect to discard, not a lockfile to keep. +yarn.lock diff --git a/documentation/DESIGN-NOTES.md b/documentation/DESIGN-NOTES.md new file mode 100644 index 00000000..7912262b --- /dev/null +++ b/documentation/DESIGN-NOTES.md @@ -0,0 +1,278 @@ +# Tirith site — design notes + +The design system the site is built on, and the rules that must survive an edit. It was +written for a design review of the pages while they were a prototype; the pages have since +become the site itself and live in `documentation/` in the Tirith repository. + +The static export it refers to below was the review bundle — one self-contained HTML file per +page, JavaScript stripped so the files could be edited by hand. That bundle is not in the +repository. Sections 2 onwards describe the live pages and still hold. + +--- + +## 1. What you have, and what it cannot do + +| File | Page | Route in the real site | +| --- | --- | --- | +| `index.html` | Landing | `/tirith/` | +| `learn.html` | Learn — six lessons and a playground | `/tirith/learn/` | +| `ai.html` | Tirith with a coding agent | `/tirith/ai/` | +| `fleet.html` | Fleet governance (the commercial page) | `/tirith/fleet/` | +| `logo.html` | Origins — where the name and the mark come from | `/tirith/origins/` | +| `docs-example.html` | One representative documentation page | `/tirith/docs/...` | + +Each file is self-contained: CSS inlined, images embedded, no build step, no server. +Double-click it. + +**All JavaScript has been stripped**, which is what makes the files editable. The cost is +that anything script-driven renders in its opening state and does not respond: + +- **Landing** — the specimen's threshold slider, and the five-step demo walkthrough (you + see step 01 only). +- **Learn** — the six lesson benches and the playground. The panes show their starting + policy and document; nothing evaluates. +- **AI / Fleet** — copy buttons, and the Fleet form's submit. +- Still working, because they need no script: the Fleet FAQ accordions (native + `
`), every link, and both colour themes. + +**To see dark mode**, add `data-theme="dark"` to the `` tag at the top of any file. +The whole palette is token-driven, so nothing else needs changing. + +**Reworking these with Claude.** One file per conversation works best — they are ~160KB +each, mostly the inlined stylesheet. Worth telling it: the page is one flat HTML file, the +design system is described below, and section 5 lists the things that must not change. + +--- + +## 2. The design system + +The visual world is a **policy specimen sheet**. The subject is a tool that reads a +document and returns a verdict, so the page is built like the artefact it describes: paper +ground, ink type, hairline rules, and measurements shown rather than decorated. + +**The rules that produce the look** + +- **Hairline rules instead of cards.** Almost nothing on these pages is a box with a + shadow. Structure comes from 1px rules and shared column edges. There are no shadows and + no gradients anywhere. +- **Square corners.** No border radius, on anything. +- **Hierarchy from scale contrast**, not from colour or weight alone. The headline is very + large; nearly everything else is small. +- **Two hues, total.** An accent (`--tp-accent`, blue) for links and primary buttons, and + an alarm (`--tp-alarm`, red) for a negative verdict. Everything else is ink, paper and + rule. A pass is deliberately *not* green: colour never carries a verdict on its own, the + word does, so the pages stay readable for a colour-blind reader. +- **One width: 96rem.** The navbar's inner row, every page's `
`, and the docs layout + are all measured against it, so the logo, the section rules and the docs sidebar start on + the same vertical line on every page. + +**Type** + +| Role | Face | Used for | +| --- | --- | --- | +| Display | Martian Mono | Headlines, section titles, buttons, labels | +| Text | IBM Plex Sans | Body prose | +| Mono | JetBrains Mono | Code, data, measurements, micro-labels | + +Mono is used for things that genuinely *are* code, data or measurement — not as decoration. +Ligatures are disabled everywhere, because JetBrains Mono renders `--` as a single dash and +would turn `--fail-on-error` into a flag that does not exist. + +**Tokens.** Colours, fonts and spacing are CSS custom properties (`--tp-*`) defined once +per page on `.page`, with a dark-theme block that redefines the same names. To change the +palette, change the token block — do not hard-code colours in rules. + +> Note for whoever rebuilds this: the token block is currently duplicated across five page +> stylesheets. That was fine at two pages and is now the main thing worth refactoring — +> one shared file, imported everywhere. + +**Section grammar**, shared by every page: a two-digit number, a title, an optional lede, +then the content. Numbering is per page and runs `01`, `02`, `03`… + +--- + +## 3. The pages + +### Landing — `index.html` + +**Who it is for.** A cold, problem-aware visitor: they own a pipeline with nothing between +`plan` and `apply`, and they do *not* yet know that policy engines are a category. + +**Why it is ordered this way.** what → why → how → setup → proof → depth. The hero states +what the tool does and what it costs you before asking anyone to read further. The real +pull requests in section 04 prove the mechanism *after* it has been explained — they are +evidence, not the introduction. + +**Structure** + +1. Hero — headline, lede, a tabbed install command (GitHub Actions / local CLI), and a + four-item strip of what it costs you. +2. `01` Why add a policy gate — three failure modes. +3. `02` Put Tirith between plan and apply — the four-step flow. +4. `03` Add Tirith to GitHub Actions — the actual YAML. +5. `04` Watch it catch a real mistake — five public demo pull requests, as a stepper. +6. `05` See exactly what a policy checks — the interactive specimen. +7. Close — start with one rule, then four doorways into the docs. + +**The specimen** (section 05) is the page's set piece. One policy shown at display scale +with one draggable threshold; moving it re-evaluates a grid of resources and the verdict +readout changes with it. The verdict word is set in Martian Mono's variable *width* axis +driven by the same control, so dragging the threshold physically narrows and widens the +word. It is inert in this export. + +### Learn — `learn.html` + +**Job.** Take someone from "I have never written a policy" to a working one, in six steps +against a single document, adding one rule at a time. + +**The thing that makes it work.** Every lesson is a live editor. In the real site the +policy actually evaluates in the browser — a documented subset of Tirith's engine +reimplemented in JavaScript — so changing a value moves the verdict, the messages and the +exit code in front of you. The page states its own limits rather than hiding them, because +a teaching tool that quietly disagrees with the real evaluator is worse than no tool. + +**Structure.** Hero → a table of contents → six lessons, each *prose on the left, editor on +the right* → a free playground → an install close. + +**Design note.** The prose/editor split is the page's whole shape and it is the constraint +worth respecting: the explanation has to be readable while the thing it describes is on +screen. + +### AI — `ai.html` + +**Job.** Show that Tirith is usable from a coding agent, without becoming vague AI-first +copy. + +**The discipline that keeps it honest.** Every claim names a specific file or a specific +command, and everything on the page works today with nothing to install beyond Tirith. Each +claim was re-checked against the repository when the page was written. + +**The argument.** Ask an agent for a guardrail and it writes plausible JSON against a schema +it is guessing at — `"type": "Exists"`, which is not a condition Tirith has. The engine +returns an unknown condition as an ordinary failed check with no error attached, so it is +indistinguishable from a real violation: the build goes red and someone loses an afternoon +to infrastructure that was fine. The fix is not a better prompt. It is giving the agent the +closed list, and making it run the policy before claiming the policy works. + +**Structure.** Hero → `01` what goes wrong → `02` give it the vocabulary (three skill files ++ a copyable install command) → `03` give it a way to check itself (`tirith lint`, and a +table of the five traps) → `04` make it run the thing → `05` why there is no MCP server → +`06` what an agent cannot see across repositories → `07` what none of this does → close. + +**Design note.** Section 02 was a three-column table in the earlier design and is now one +ruled row per file, because the path column wrapped mid-token at every width worth +supporting. Section 07 is deliberately unglamorous: a page about AI that never states its +boundaries is not trustworthy. + +### Fleet governance — `fleet.html` + +**Job.** Explain the commercial progression for a team that needs to discover, standardise, +approve and evidence Tirith governance across many repositories. + +**Two rules that constrain the design and must survive any rework** + +1. The first viewport must state that Tirith OSS is free, independent, and needs no + StackGuardian account. It does so in the hero lede and again in the strip beside it. +2. The commercial CTA never outranks *Use Tirith OSS*. This is the one page where a + commercial CTA is legitimately primary, and even here the open-source route appears + beside it every time, never buried. + +**Structure.** Hero → `01` which one is your problem → `02` what each one costs → `03` what +connecting adds (an eight-rung ladder) → `04` what the platform looks like → `05` full +capability comparison → `06` five FAQs → `07` the enquiry form. + +**Two honesty devices worth keeping.** The eighth rung of the ladder is tagged **planned** +and dimmed, because Tirith calling the platform's workflow API is not shipped and the page +will not describe it in the present tense. And pricing says *Custom* rather than inventing +Free/Pro/Enterprise tiers. + +**Section 04 is four empty wells.** The screenshots do not exist yet. They are drawn as +hatched, dashed placeholders at the 16:9 the real images will occupy, so the page will not +reflow when they land and so the amount of missing material is obvious to anyone reviewing. +They are supposed to look unfinished. When the images are captured they must come from a +demo organisation, never a customer's — those screens carry repository names, cloud +resource identifiers and run history. + +**The form** posts directly to HubSpot from the browser. Only the enumerated fields +(repository band, CI systems, primary problem) are ever sent to analytics; the email, +organisation and free-text box are not, and must not be. + +### Origins — `logo.html` + +**Job.** Explain the idea behind the logo. Not a design record — it deliberately carries no +size specimens, no pixel thresholds, and no account of the candidate marks that were drawn +and rejected. + +**The argument.** Tirith is named for Minas Tirith, a city that held because nothing reached +the summit in one move: seven walls, one gate each, every gate on the far side of the one +below. The defence was never the stone — it was the order the gates were in. The mark is +that city seen from above, reduced in four moves to a closed ring with two opposed gates, +which is a policy engine drawn as a floor plan. + +**Structure.** Hero → `01` the city the name comes from → `02` the four-move reduction, with +diagrams → `03` opposed gates are the product. + +**Design note.** This page is linked only from footers. It is background for someone who has +finished a product page, not a step toward installing anything. + +### Docs — `docs-example.html` + +Standard Docusaurus documentation, wearing the site's chrome: same navbar, same 96rem +measure, same palette. Included so the review covers the moment a visitor crosses from the +designed pages into the documentation — historically the point where a site stops feeling +like one site. The page body itself is authored Markdown and is not part of this design +work. + +--- + +## 4. Navigation + +**Top bar:** Learn · AI · Fleet · Docs on the left; Policy Builder · GitHub on the right. + +Recently trimmed. It used to also carry Install, Providers and Tirith UI as direct links +into the documentation sidebar, which made the bar read as a sitemap rather than a route +through the product. Those three are reachable from **Docs**. + +**The Origins page** is intentionally absent from the bar and reachable only from footers. + +**Footers** are a single ruled colophon strip, not a multi-column sitemap. + +--- + +## 5. Constraints a redesign must not break + +Design is open. These are not. + +1. **No invented social proof.** There are no customers, logos, testimonials, benchmarks or + adoption numbers for this project. Do not add any, including as placeholders — a + greyed-out logo wall implies customers that do not exist. +2. **Claims are checked against the repository.** Every command, flag, file path and exit + code on these pages was verified against the Tirith source. If a redesign rewrites body + copy, the technical claims have to survive intact. +3. **The exit-code distinction.** `3` means a policy said no; `1` means Tirith could not + tell you either way. Anywhere this appears, both halves must stay. +4. **The OSS/commercial boundary.** Fleet's two rules in section 3 above. +5. **Colour never carries a verdict alone.** Always a word too. +6. **Contrast and focus.** Body text stays at the current contrast, and every interactive + element keeps a visible focus ring. The forms keep their real `