From 6f51700740b1b2be4a01da96a9e281132addaa27 Mon Sep 17 00:00:00 2001 From: AKSHAT TANDON Date: Mon, 31 Aug 2026 12:00:56 +0100 Subject: [PATCH 01/11] docs: promote the new landing pages to the published site The pages built in tirith-internal as an isolated preview under documentation/new-landing-page/ become documentation/ itself, replacing the single-page landing site. Only documentation/ is built by deploy_docs.yml, so this is what makes them the published site rather than a preview nothing runs. The preview read the real site across a folder boundary. Those paths are now the site's own: staticDirectories drops to the default single root with the brand assets moved into documentation/static/img/, and the docs preset takes ./docs and ./sidebars.js directly rather than ../docs and ../sidebars.js. Five pages, replacing one: the landing page, Learn with the browser playground, Skills, At scale (the commercial page, previously Fleet), and the logo page reachable only from the landing footer. The Skills page instructs a reader to curl .claude/skills/tirith-policies/ and .cursor/rules/tirith-policies.mdc from this repository's main branch, and every file it lists links into that folder. Neither existed here, so the page shipped install instructions that 404. The skill pack is self-contained and meant to be copied into any project, so it moves here with the page that advertises it. docs/Tirith Logo.html comes across for the same reason: brand/README.md links to it as the source of truth for the mark's geometry, and three source files cite it. Its relative link is repointed for the shallower location. Also brings the public documentation the pages link to: editor-and-local.md, a link target the build would otherwise reject under onBrokenLinks: 'throw', plus the Bitbucket, Jenkins and pre-commit CI sections, the 1.2.0 install pins, and the issue-template links. deploy_docs.yml gains the PostHog and HubSpot environment. Every value is optional and unset means the analytics script never loads and the At scale form disables itself and says why -- which is the right behaviour on a fork. The documentation README was the stock Docusaurus scaffold telling readers to use yarn, which is wrong: package-lock.json is the committed lockfile and both docs workflows say so. It now describes the pages, the environment, the brand assets and the playground's documented limits. Co-Authored-By: Claude Opus 5 --- .claude/skills/tirith-policies/SKILL.md | 83 + .../tirith-policies/reference/debug-ci.md | 72 + .../tirith-policies/reference/install.md | 55 + .../reference/other-providers.md | 78 + .../tirith-policies/reference/pipelines.md | 158 + .../tirith-policies/reference/platform.md | 61 + .../tirith-policies/reference/schema.md | 78 + .../reference/terraform-plan.md | 88 + .../tirith-policies/reference/validate.md | 50 + .../tirith-policies/reference/variables.md | 60 + .../tirith-policies/reference/verdicts.md | 61 + .cursor/rules/tirith-policies.mdc | 53 + .github/workflows/deploy_docs.yml | 13 + docs/Tirith Logo.html | 391 +++ documentation/DESIGN-NOTES.md | 278 ++ documentation/README.md | 121 +- documentation/brand/README.md | 44 + documentation/brand/social-card.html | 42 + .../docs/getting-started-with-tirith.md | 5 +- .../docs/tirith-usage/ci-integration.md | 113 +- .../docs/tirith-usage/editor-and-local.md | 116 + .../tirith-usage/interactive-interface.md | 3 +- documentation/docusaurus.config.js | 121 +- documentation/sidebars.js | 1 + documentation/src/analytics.js | 113 + documentation/src/clientModules/posthog.js | 34 + documentation/src/components/BackMenu.js | 12 - documentation/src/components/brand/Glyph.js | 37 + .../src/components/brand/TirithMark.js | 42 + .../src/components/landing/CopyField.js | 68 + .../src/components/landing/PhaseJourney.js | 99 + .../src/components/landing/PlatformSetup.js | 55 + .../src/components/landing/Specimen.js | 394 +++ documentation/src/components/learn/Bench.js | 160 + documentation/src/css/chrome.module.css | 145 + documentation/src/css/custom.css | 1 - documentation/src/data/demoPhases.js | 341 ++ documentation/src/data/lessons.js | 318 ++ documentation/src/data/logoStory.js | 95 + documentation/src/data/specimens.js | 442 +++ documentation/src/data/tirithLite.js | 478 +++ documentation/src/pages/at-scale.js | 619 ++++ documentation/src/pages/at-scale.module.css | 887 +++++ documentation/src/pages/index.js | 718 ++-- documentation/src/pages/index.module.css | 2962 ++++++++++++++++- documentation/src/pages/learn.js | 259 ++ documentation/src/pages/learn.module.css | 767 +++++ documentation/src/pages/logo.js | 256 ++ documentation/src/pages/logo.module.css | 432 +++ documentation/src/pages/skills.js | 363 ++ documentation/src/pages/skills.module.css | 761 +++++ .../static/img/tirith-lockup-dark.svg | 6 + documentation/static/img/tirith-lockup.svg | 6 + documentation/static/img/tirith-mark.svg | 10 + .../static/img/tirith-social-card.png | Bin 0 -> 42051 bytes 55 files changed, 12654 insertions(+), 371 deletions(-) create mode 100644 .claude/skills/tirith-policies/SKILL.md create mode 100644 .claude/skills/tirith-policies/reference/debug-ci.md create mode 100644 .claude/skills/tirith-policies/reference/install.md create mode 100644 .claude/skills/tirith-policies/reference/other-providers.md create mode 100644 .claude/skills/tirith-policies/reference/pipelines.md create mode 100644 .claude/skills/tirith-policies/reference/platform.md create mode 100644 .claude/skills/tirith-policies/reference/schema.md create mode 100644 .claude/skills/tirith-policies/reference/terraform-plan.md create mode 100644 .claude/skills/tirith-policies/reference/validate.md create mode 100644 .claude/skills/tirith-policies/reference/variables.md create mode 100644 .claude/skills/tirith-policies/reference/verdicts.md create mode 100644 .cursor/rules/tirith-policies.mdc create mode 100644 docs/Tirith Logo.html create mode 100644 documentation/DESIGN-NOTES.md create mode 100644 documentation/brand/README.md create mode 100644 documentation/brand/social-card.html create mode 100644 documentation/docs/tirith-usage/editor-and-local.md create mode 100644 documentation/src/analytics.js create mode 100644 documentation/src/clientModules/posthog.js delete mode 100644 documentation/src/components/BackMenu.js create mode 100644 documentation/src/components/brand/Glyph.js create mode 100644 documentation/src/components/brand/TirithMark.js create mode 100644 documentation/src/components/landing/CopyField.js create mode 100644 documentation/src/components/landing/PhaseJourney.js create mode 100644 documentation/src/components/landing/PlatformSetup.js create mode 100644 documentation/src/components/landing/Specimen.js create mode 100644 documentation/src/components/learn/Bench.js create mode 100644 documentation/src/css/chrome.module.css create mode 100644 documentation/src/data/demoPhases.js create mode 100644 documentation/src/data/lessons.js create mode 100644 documentation/src/data/logoStory.js create mode 100644 documentation/src/data/specimens.js create mode 100644 documentation/src/data/tirithLite.js create mode 100644 documentation/src/pages/at-scale.js create mode 100644 documentation/src/pages/at-scale.module.css create mode 100644 documentation/src/pages/learn.js create mode 100644 documentation/src/pages/learn.module.css create mode 100644 documentation/src/pages/logo.js create mode 100644 documentation/src/pages/logo.module.css create mode 100644 documentation/src/pages/skills.js create mode 100644 documentation/src/pages/skills.module.css create mode 100644 documentation/static/img/tirith-lockup-dark.svg create mode 100644 documentation/static/img/tirith-lockup.svg create mode 100644 documentation/static/img/tirith-mark.svg create mode 100644 documentation/static/img/tirith-social-card.png diff --git a/.claude/skills/tirith-policies/SKILL.md b/.claude/skills/tirith-policies/SKILL.md new file mode 100644 index 00000000..19072bb4 --- /dev/null +++ b/.claude/skills/tirith-policies/SKILL.md @@ -0,0 +1,83 @@ +--- +name: tirith-policies +description: Write, validate, run and debug Tirith IaC governance policies, install Tirith, and add it to a CI pipeline. 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. + +## The one rule + +**Never hand back a policy you have not run.** A policy that matches nothing looks identical to +one that works — same shape, same silence, and it reports `final_result: null`, which is not a +pass. Lint it, then evaluate it against a document that *should* fail it. + +```bash +tirith lint .tirith/policies +tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error +``` + +## 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. That fixes `meta.required_provider`. +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?** 13 of them, 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. + +## Before you hand it back + +1. Does `eval_expression` reference every evaluator you wrote? +2. Is every `condition.type` in the closed list? +3. Did `tirith lint` pass? +4. Did you **run it** against a document that should fail it? + +## Reference + +| File | Use it for | +| --- | --- | +| `reference/schema.md` | The closed vocabulary: conditions, providers, operations, argument keys | +| `reference/validate.md` | `tirith lint`, and the traps it catches | +| `reference/verdicts.md` | Running a policy, exit codes, and finding the resource behind a failure | +| `reference/terraform-plan.md` | The plan provider's seven operations, for OpenTofu and Terraform | +| `reference/other-providers.md` | Kubernetes, Infracost and arbitrary JSON | +| `reference/variables.md` | One policy across environments with `-var` | +| `reference/install.md` | Installing Tirith | +| `reference/pipelines.md` | Adding Tirith to GitHub Actions, GitLab, or any container CI | +| `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..377fcec1 --- /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`. Run `tirith lint .tirith/policies`; if it reports + an error, the fault is in the policy, not the 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 lint .tirith/policies # is the policy well-formed? +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..8106b3da --- /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.0.5" +``` + +`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..3692d606 --- /dev/null +++ b/.claude/skills/tirith-policies/reference/pipelines.md @@ -0,0 +1,158 @@ +# Add Tirith to a pipeline + +Three pieces make a gate: a policy committed to the repository, the plan as JSON, and Tirith +running with `--fail-on-error`. + +## Produce the input first + +```bash +tofu plan -out=tfplan -input=false # or: terraform plan -out=tfplan -input=false +tofu show -json tfplan > plan.json # or: terraform show -json tfplan > plan.json +``` + +Either binary works. They emit the same plan JSON, and Tirith does not inspect which one +produced it. + +Everything below assumes `plan.json` exists. + +## 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 + - run: terraform show -json tfplan > plan.json + - uses: StackGuardian/tirith-iac-governance-action@v2 + with: + fail-on-error: true +``` + +With a `plan.json` in the working directory and policies under `.tirith/policies`, that is the +whole integration — no other `with:` keys are required. Without `fail-on-error` the action reports +findings but does not block the job. + +## GitLab CI, or any container-based CI + +There is no GitLab-native equivalent, so call the CLI directly — which is all the action does +underneath. + +```yaml +policy: + image: python:3.12 + needs: [plan] + script: + - pip install "git+https://github.com/StackGuardian/tirith.git@1.0.5" + - tirith lint .tirith/policies + - tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error +``` + +Nothing here is GitLab-specific. Any runner that can execute a container and produce a plan works +the same way. + +## Bitbucket Pipelines + +No native integration; call the CLI. Plan in one step, gate in the next, pass `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, tfplan] + - step: + name: Policy gate + script: + - pip install "git+https://github.com/StackGuardian/tirith.git@1.0.5" + - tirith lint .tirith/policies + - tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error +``` + +## Jenkins + +Declarative pipeline, gate as a stage. 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: '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}).") } + } + } +} +``` + +## As a pre-commit hook + +Tirith publishes a `tirith-lint` hook, so a broken policy is caught at commit time: + +```yaml +repos: + - repo: https://github.com/StackGuardian/tirith + rev: 1.0.5 + hooks: + - id: tirith-lint +``` + +It lints only; evaluating needs a plan, which means running `terraform plan` — too slow for a +commit hook and it would need cloud credentials. The hook triggers only when a file under +`.tirith/policies/` or a `*.tirith.json` changes, and lints the directory rather than the changed +files, because `tirith lint` takes a single path. + +## In an editor, while writing + +```bash +tirith lint .tirith/policies # shape +tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error # meaning +``` + +Wire those two as VS Code tasks (`.vscode/tasks.json`) with the second as the default test task, +and the whole loop is one keystroke. If you are drafting the policy for someone, run both before +handing it back, against a document that *should* fail — an exit code of `0` there means the +policy matched nothing. + +## 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 it as a **tool or input problem**, not a violation | + +Collapsing `1` and `3` is the most common mistake: it reports an outage as a policy violation, and +a job that cannot tell them apart cannot tell a working gate from a broken one. + +## 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 the resource address, the planned action and the +before/after values for every finding, which is what makes a failure explainable after the fact. + +## Order of operations + +1. `tirith lint .tirith/policies` — catches a broken policy without touching infrastructure. +2. `terraform show -json` — produce the document. +3. `tirith … --fail-on-error` — the gate. + +Lint first: it is the cheapest step and it fails on the class of mistake that would otherwise look +like a real violation. 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..fc422727 --- /dev/null +++ b/.claude/skills/tirith-policies/reference/schema.md @@ -0,0 +1,78 @@ +# 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 registries rather than this file: + +```bash +tirith lint --gotchas +``` + +## 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..c6c1f2cd --- /dev/null +++ b/.claude/skills/tirith-policies/reference/validate.md @@ -0,0 +1,50 @@ +# Validate a policy + +`tirith lint` reads the engine's own registries and reports mistakes that would otherwise surface +as a confusing verdict. It needs no extra, makes no network call, and runs before anything is +evaluated. + +```bash +tirith lint .tirith/policies # a file, a directory, or a glob +tirith lint --json # the same report, for a script +tirith lint --gotchas # the traps below, from the source of truth +``` + +## Exit codes + +| Exit | Meaning | +| --- | --- | +| `0` | Every policy is clean | +| `3` | A policy has an error | +| `1` | A path could not be read | + +Warnings alone do not fail the run. `3` rather than `1` for a bad policy is deliberate and matches +the rest of Tirith: the linter saying no about a policy is a verdict, not a tool failure. That +makes it usable as a CI step as much as a self-check. + +## What it catches + +| 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. | +| 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. | +| An operation that does not ship | `jmespath` and `jq_query` appear in test fixtures. Neither exists. | +| `error_tolerance` in the wrong place | It belongs inside `condition`. Anywhere else it has no effect and the policy quietly loses its tolerance. | +| 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 supported operators. | + +## Add it to CI + +Linting is cheap and catches a broken policy before it is ever evaluated against real +infrastructure: + +```yaml +- run: pip install "git+https://github.com/StackGuardian/tirith.git@1.0.5" +- run: tirith lint .tirith/policies +``` + +## What it cannot tell you + +Linting checks the **shape**. Only evaluation checks the **meaning**. A policy whose +`provider_args` match no resource at all is structurally perfect and lints clean — and it gates +nothing. Always follow a lint with a run: `reference/verdicts.md`. 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..64c20d2e --- /dev/null +++ b/.claude/skills/tirith-policies/reference/verdicts.md @@ -0,0 +1,61 @@ +# 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. Run +`tirith lint` first and this class disappears. diff --git a/.cursor/rules/tirith-policies.mdc b/.cursor/rules/tirith-policies.mdc new file mode 100644 index 00000000..e65e738d --- /dev/null +++ b/.cursor/rules/tirith-policies.mdc @@ -0,0 +1,53 @@ +--- +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 lint .tirith/policies # check the shape +tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error # check the verdict +``` + +**There is no `stackguardian/terraform_state` provider.** Read a state file with +`stackguardian/json` and `key_path`. + +Worked examples: `src/tirith/tui/examples/`. Deeper reference, if the repository has it: +`.claude/skills/tirith-policies/reference/` covers the schema, linting, verdicts, each provider, +policy variables, installing Tirith, adding it to a pipeline, and debugging a red CI check. +Online: https://stackguardian.github.io/tirith/docs/tirith-policies/tirith-policy-reference/ diff --git a/.github/workflows/deploy_docs.yml b/.github/workflows/deploy_docs.yml index c18f02dc..bb37fb0a 100644 --- a/.github/workflows/deploy_docs.yml +++ b/.github/workflows/deploy_docs.yml @@ -29,7 +29,20 @@ jobs: cd documentation npm ci + # 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 At scale 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 diff --git a/docs/Tirith Logo.html b/docs/Tirith Logo.html new file mode 100644 index 00000000..0fa78c58 --- /dev/null +++ b/docs/Tirith Logo.html @@ -0,0 +1,391 @@ + + + + + Bundled Page + + + + +
+ + + + Tirith + +
+
Unpacking...
+ + + + + + + + + + + + \ No newline at end of file diff --git a/documentation/DESIGN-NOTES.md b/documentation/DESIGN-NOTES.md new file mode 100644 index 00000000..a38ad459 --- /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` | The mark — why the logo is what it is | `/tirith/logo/` | +| `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. + +### The mark — `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 mark 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 `