From 25eb65241bd1f9b6e62a086cce0c37323bd3426c Mon Sep 17 00:00:00 2001
From: AKSHAT TANDON {c.detail}
{CLIENTS.map((c) => (
@@ -221,6 +221,7 @@ export default function Skills() {
{c.name}
, so a rule written for inline code reaches inside it too. The
+ * `white-space: nowrap` below then collapses every run of spaces in the block, because
+ * `nowrap` collapses whitespace exactly like `normal` and only changes wrapping, and a JSON
+ * policy loses its entire indentation. Inline code in markdown is always a direct child of
+ * the element around it, so the child combinator separates the two cleanly.
+ */
+html.plugin-docs .theme-doc-markdown :not(pre) > code {
+ padding: 0.1em 0.35em;
+ border: 1px solid var(--tp-rule);
+ background: var(--tp-surface);
+ /*
+ * Never break a flag across two lines.
+ *
+ * `--fail-on-error` wrapped after the double dash and rendered as two fragments that each
+ * read like a different flag. Same class of problem as the ligature rule above: the text
+ * is still correct and a reader copying what they see gets something that does not exist.
+ * Safe because inline code in these pages is a flag, a key or a short value; a long one
+ * has the article's own measure to run into, not the viewport.
+ */
+ white-space: nowrap;
+}
+
+/* Inside a heading the chip competes with the heading. Keep the face, drop the box. */
+html.plugin-docs .theme-doc-markdown :is(h1, h2, h3, h4) > code,
+html.plugin-docs .theme-doc-markdown :is(h1, h2, h3, h4) a > code {
+ padding: 0;
+ border: 0;
+ background: transparent;
+ font-size: 0.94em;
+}
+
+/* An inline code span inside a link should not draw a second, competing box. */
+html.plugin-docs .theme-doc-markdown a > code {
+ border-color: transparent;
+ color: inherit;
+}
+
+html.plugin-docs .theme-code-block,
+html.plugin-docs div[class*='codeBlockContainer'] {
+ margin-bottom: 1.5rem;
+ border: 1px solid var(--tp-rule);
+ border-radius: 0;
+ box-shadow: none;
+}
+
+html.plugin-docs div[class*='codeBlockTitle'] {
+ border-bottom: 1px solid var(--tp-rule);
+ background: var(--tp-sunk);
+ color: var(--tp-soft);
+ font-family: var(--tp-mono);
+ font-size: 0.72rem;
+ letter-spacing: 0.02em;
+}
+
+html.plugin-docs div[class*='codeBlockContent'] > pre,
+html.plugin-docs .theme-code-block pre {
+ background: var(--tp-surface);
+ font-size: 0.8rem;
+ line-height: 1.65;
+}
+
+/*
+ * The copy button is square, hairline and quiet until you reach for it. Infima's version is
+ * a rounded translucent chip, which is the one control on the page that looked like it came
+ * from a different toolkit.
+ */
+html.plugin-docs button[class*='copyButton'] {
+ border: 1px solid var(--tp-rule);
+ border-radius: 0;
+ background: var(--tp-paper);
+ color: var(--tp-soft);
+}
+
+html.plugin-docs button[class*='copyButton']:hover {
+ border-color: var(--tp-accent);
+ background: var(--tp-paper);
+ color: var(--tp-accent);
+}
+
+/* -------------------------------------------------------------------- table ---- */
+
+html.plugin-docs .theme-doc-markdown table {
+ display: table;
+ width: 100%;
+ border: 0;
+ border-collapse: collapse;
+ font-size: 0.88rem;
+}
+
+html.plugin-docs .theme-doc-markdown table tr {
+ border: 0;
+ background: transparent;
+}
+
+html.plugin-docs .theme-doc-markdown table th {
+ padding: 0.5rem 1rem 0.5rem 0;
+ border: 0;
+ border-bottom: 1px solid var(--tp-ink);
+ color: var(--tp-faint);
+ font-family: var(--tp-display);
+ font-size: 0.58rem;
+ font-weight: 700;
+ letter-spacing: 0.13em;
+ text-align: left;
+ text-transform: uppercase;
+}
+
+html.plugin-docs .theme-doc-markdown table td {
+ padding: 0.6rem 1rem 0.6rem 0;
+ border: 0;
+ border-bottom: 1px solid var(--tp-rule);
+ vertical-align: top;
+}
+
+html.plugin-docs .theme-doc-markdown table th:last-child,
+html.plugin-docs .theme-doc-markdown table td:last-child {
+ padding-right: 0;
+}
+
+/* --------------------------------------------------------------- admonition ---- */
+
+/*
+ * A left rule and the page's own ground, not a tinted rounded panel.
+ *
+ * Two hues, so the five admonition types resolve to two: the accent for anything
+ * informational, the alarm for anything that can cost you something. A note in green and a
+ * tip in a third colour would put more hues in one box than the whole rest of the site uses.
+ */
+html.plugin-docs .theme-admonition {
+ margin-bottom: 1.5rem;
+ padding: 0.9rem 1.1rem;
+ border: 0;
+ border-left: 2px solid var(--tp-rule);
+ border-radius: 0;
+ background: var(--tp-surface);
+ box-shadow: none;
+ font-size: 0.92rem;
+}
+
+html.plugin-docs .theme-admonition div[class*='admonitionHeading'] {
+ margin-bottom: 0.4rem;
+ color: var(--tp-ink);
+ font-family: var(--tp-display);
+ font-size: 0.6rem;
+ font-weight: 700;
+ letter-spacing: 0.13em;
+ text-transform: uppercase;
+}
+
+html.plugin-docs .theme-admonition div[class*='admonitionContent'] > :last-child {
+ margin-bottom: 0;
+}
+
+html.plugin-docs .theme-admonition-info,
+html.plugin-docs .theme-admonition-note,
+html.plugin-docs .theme-admonition-tip {
+ border-left-color: var(--tp-accent);
+}
+
+html.plugin-docs .theme-admonition-warning,
+html.plugin-docs .theme-admonition-danger {
+ border-left-color: var(--tp-alarm);
+ background: var(--tp-alarm-wash);
+}
+
+html.plugin-docs .theme-admonition-warning div[class*='admonitionHeading'],
+html.plugin-docs .theme-admonition-danger div[class*='admonitionHeading'] {
+ color: var(--tp-alarm);
+}
+
+/* The swizzled icons in src/theme/Admonition are flat SVGs; tint them from the border. */
+html.plugin-docs .theme-admonition svg {
+ color: inherit;
+}
+
+/* ---------------------------------------------------------------------- toc ---- */
+
+/*
+ * A labelled column, not an unannounced list of fragments.
+ *
+ * Docusaurus renders the table of contents with no heading, which on a page whose sections
+ * are themselves short leaves four grey lines floating in the right margin with nothing
+ * saying what they are. The label is a ::before on the outer list rather than a swizzle:
+ * `table-of-contents__left-border` is only ever on the root, so it cannot repeat on the
+ * nested lists the way `.table-of-contents` would.
+ */
+html.plugin-docs .table-of-contents {
+ font-size: 0.8rem;
+}
+
+html.plugin-docs .table-of-contents__left-border {
+ padding-left: 1rem;
+ border-left: 1px solid var(--tp-rule);
+}
+
+html.plugin-docs .table-of-contents__left-border::before {
+ content: 'On this page';
+ display: block;
+ margin-bottom: 0.7rem;
+ color: var(--tp-faint);
+ font-family: var(--tp-display);
+ font-size: 0.58rem;
+ font-weight: 700;
+ letter-spacing: 0.13em;
+ text-transform: uppercase;
+}
+
+html.plugin-docs .table-of-contents__link {
+ color: var(--tp-soft);
+}
+
+html.plugin-docs .table-of-contents__link:hover,
+html.plugin-docs .table-of-contents__link--active {
+ color: var(--tp-accent);
+ text-decoration: none;
+}
+
+/*
+ * A heading that contains a flag arrives here as a bordered chip inside a 12px line, which
+ * turns the column into a row of boxes. In the margin the entry only has to be recognisable
+ * as the heading it points at, so the code keeps its face and loses its box.
+ */
+html.plugin-docs .table-of-contents code {
+ padding: 0;
+ border: 0;
+ background: transparent;
+ color: inherit;
+ font-size: 0.95em;
+}
+
+/* --------------------------------------------------------------- pagination ---- */
+
+/*
+ * Two links under one rule, not two cards.
+ *
+ * src/theme/PaginatorNavLink supplies the arrows and the labels; the sizing that used to be
+ * inline on that component now lives here, so the two themes can differ without a second
+ * JavaScript branch.
+ */
+html.plugin-docs .pagination-nav {
+ margin-top: 3.5rem;
+ padding-top: 1.25rem;
+ border-top: 1px solid var(--tp-rule);
+ gap: 1.5rem;
+}
+
+html.plugin-docs .pagination-nav__link {
+ padding: 0;
+ border: 0;
+ border-radius: 0;
+ background: transparent;
+ transition: color 120ms ease-out;
+}
+
+html.plugin-docs .pagination-nav__link:hover {
+ background: transparent;
+}
+
+html.plugin-docs .pagination-nav__sublabel {
+ display: flex;
+ gap: 0.4rem;
+ align-items: center;
+ margin-bottom: 0.4rem;
+ color: var(--tp-faint);
+ font-family: var(--tp-display);
+ font-size: 0.58rem;
+ font-weight: 700;
+ letter-spacing: 0.13em;
+ text-transform: uppercase;
+}
+
+html.plugin-docs .pagination-nav__label {
+ color: var(--tp-ink);
+ font-family: var(--tp-display);
+ font-size: 0.85rem;
+ font-weight: 700;
+ letter-spacing: -0.01em;
+ line-height: 1.35;
+}
+
+html.plugin-docs .pagination-nav__link:hover .pagination-nav__label {
+ color: var(--tp-accent);
+}
+
+/* --------------------------------------------------------------------- misc ---- */
+
+html.plugin-docs .theme-doc-markdown blockquote {
+ border-left: 2px solid var(--tp-rule);
+ background: transparent;
+ color: var(--tp-soft);
+}
+
+html.plugin-docs hr {
+ border: 0;
+ border-top: 1px solid var(--tp-rule);
+}
+
+html.plugin-docs .theme-doc-version-badge,
+html.plugin-docs .badge {
+ border-radius: 0;
+}
+
+/* The back-to-top affordance is a circle with a shadow. Neither exists in this design. */
+html.plugin-docs button[class*='backToTopButton'] {
+ border: 1px solid var(--tp-rule);
+ border-radius: 0;
+ background-color: var(--tp-paper);
+ box-shadow: none;
+}
+
+html.plugin-docs .pagination-nav__item--next .pagination-nav__sublabel {
+ justify-content: flex-end;
+}
+
+html.plugin-docs .pagination-nav__item--next {
+ text-align: right;
+}
diff --git a/documentation/src/data/agentBrief.js b/documentation/src/data/agentBrief.js
index 3ea263a6..651dd3e0 100644
--- a/documentation/src/data/agentBrief.js
+++ b/documentation/src/data/agentBrief.js
@@ -5,4 +5,4 @@
* static/llms.txt and re-run documentation/scripts/generate-llms-full.py.
*/
-export const AGENT_BRIEF = "# Tirith\n\n> Tirith is an Apache-2.0 policy gate for infrastructure-as-code. It reads the plan a\n> pipeline already produces, evaluates it against declarative JSON policies committed in\n> the repository, and returns one verdict with an exit code the pipeline can gate on. It\n> runs locally or in any CI, on your own runner, with no account and no network call.\n\nPolicies are data, not code: a policy is a JSON file, so it is diffable and reviewable by\nanyone on the team. There is no rule DSL and no embedded language.\n\n## Facts an answer about Tirith usually gets wrong\n\n- **Not on PyPI.** `pip install tirith` installs an unrelated project of the same name, and\n `pip install py-tirith` finds nothing. The package name in `setup.py` is `py-tirith` and it\n is not published. Install from git:\n `pip install \"git+https://github.com/StackGuardian/tirith.git@1.2.0\"`\n- **The interactive interface needs an extra**, and the same git URL:\n `pip install 'py-tirith[tui] @ git+https://github.com/StackGuardian/tirith.git'`, then\n `tirith ui`. Requires Python 3.9; Tirith itself supports 3.8 and newer.\n- **`tirith lint` is not in the released package.** It is in development. The released CLI\n dispatches `tirith`, `tirith ui` and `tirith platform check` and nothing else.\n- **Exit codes are a contract, not a convention.** `0` passed, `3` a policy failed, `1`\n Tirith could not reach a verdict. `3` is deliberately not `1`: a caller has to be able to page\n the platform team on one and the change author on the other. (`ERROR_TIMEOUT = 2` exists in\n `status.py` but is never returned; do not write a pipeline that branches on it.)\n- **`final_result: null` is not a pass.** It means every check was skipped, so the policy\n evaluated nothing, and it exits `1`. A check that could not run is reported as unevaluated\n rather than as success.\n- **Local mode makes no network call.** That is a published governance commitment. Only\n `tirith platform check` talks to a network, and it is optional.\n- **Five providers ship**, named in `meta.required_provider`: `stackguardian/terraform_plan`,\n `stackguardian/kubernetes`, `stackguardian/infracost`, `stackguardian/json`,\n `stackguardian/sg_workflow`. There is no CloudFormation provider; a CloudFormation template\n is read as a JSON document by the `json` provider.\n- **Thirteen condition types ship**: Equals, NotEquals, GreaterThan, GreaterThanEqualTo,\n LessThan, LessThanEqualTo, Contains, NotContains, ContainedIn, NotContainedIn, IsEmpty,\n IsNotEmpty, RegexMatch.\n- **`error_tolerance` belongs inside the `condition` object**, not on the evaluator. Placed on\n the evaluator it is silently ignored and the check still fails.\n\n## Minimal working example\n\n```bash\nterraform plan -out=tfplan -input=false\nterraform show -json tfplan > plan.json\ntirith -policy-path .tirith/policies -input-path plan.json --fail-on-error\n```\n\nWithout `--fail-on-error`, Tirith reports findings but does not fail the job.\n\n## Fetching this documentation\n\nEvery page is also served as plain markdown at its route plus `.md`, so there is no need to\nparse the rendered HTML. For example\nhttps://stackguardian.github.io/tirith/docs/tirith-usage/exit-codes.md\n\nThe whole documentation set as one file, in sidebar order:\nhttps://stackguardian.github.io/tirith/llms-full.txt\n\n## Start here\n\n- [Getting started](https://stackguardian.github.io/tirith/docs/getting-started-with-tirith/): what Tirith is and the shortest path to a first verdict.\n- [Quick installation](https://stackguardian.github.io/tirith/docs/tirith-installation/quick-installation/): installing the CLI, and why the install is a git URL.\n- [Learn](https://stackguardian.github.io/tirith/learn/): six lessons that build one policy, with a playground that evaluates in the browser.\n- [Creating your first policy](https://stackguardian.github.io/tirith/docs/tirith-policies/tirith-create-first-policy/): a policy written and evaluated step by step.\n\n## Writing policies\n\n- [Policy reference](https://stackguardian.github.io/tirith/docs/tirith-policies/tirith-policy-reference/): field-by-field reference for the policy file format.\n- [Policy structure](https://stackguardian.github.io/tirith/docs/tirith-policies/tirith-policy-structure/): meta, evaluators and eval_expression, and how they combine.\n- [Evaluators and conditions](https://stackguardian.github.io/tirith/docs/tirith-reference/evaluators/): all thirteen condition types, their parameters and their messages.\n- [Evaluation expressions](https://stackguardian.github.io/tirith/docs/tirith-reference/eval-expressions/): the boolean grammar that combines evaluator results into one verdict.\n- [Policy conditions](https://stackguardian.github.io/tirith/docs/tirith-policies/tirith-policy-conditions/): condition types by the kind of value they compare.\n- [Error tolerance](https://stackguardian.github.io/tirith/docs/tirith-policies/tirith-policy-error-tolerance/): how a missing key is handled, and why a skip is not a pass.\n- [Policy variables](https://stackguardian.github.io/tirith/docs/tirith-policies/tirith-policy-variables/): dynamic values in policies.\n- [Policy cookbook](https://stackguardian.github.io/tirith/docs/tirith-policies/tirith-policy-cookbook/): complete runnable policies for common checks.\n- [Example policies](https://stackguardian.github.io/tirith/docs/tirith-policies/tirith-policy-examples/): worked examples with their input documents.\n\n## Providers\n\n- [Providers overview](https://stackguardian.github.io/tirith/docs/tirith-providers/providers-overview/): what a provider is and how required_provider selects one.\n- [Terraform plan provider](https://stackguardian.github.io/tirith/docs/tirith-providers/terraform-plan-provider/): operations over a plan document, including attribute, action and count.\n- [JSON provider](https://stackguardian.github.io/tirith/docs/tirith-providers/json-provider/): get_value over any JSON or YAML document, including another tool's output.\n- [Kubernetes provider](https://stackguardian.github.io/tirith/docs/tirith-providers/kubernetes-provider/): the attribute operation over manifests.\n- [Infracost provider](https://stackguardian.github.io/tirith/docs/tirith-providers/infracost-provider/): cost policies over an Infracost breakdown.\n- [SG Workflow provider](https://stackguardian.github.io/tirith/docs/tirith-providers/sg-workflow-provider/): StackGuardian workflow documents.\n\n## Running it\n\n- [CLI reference](https://stackguardian.github.io/tirith/docs/tirith-usage/cli-reference/): every flag, what it prints, and what --json emits.\n- [Exit codes](https://stackguardian.github.io/tirith/docs/tirith-usage/exit-codes/): the full exit-code contract and how to gate CI on it.\n- [CI integration](https://stackguardian.github.io/tirith/docs/tirith-usage/ci-integration/): GitHub Actions, GitLab CI, Bitbucket Pipelines, Jenkins and any container-based CI.\n- [The interactive interface](https://stackguardian.github.io/tirith/docs/tirith-usage/interactive-interface/): tirith ui, in beta.\n- [Platform check](https://stackguardian.github.io/tirith/docs/tirith-usage/platform-check/): the optional subcommand that evaluates an organisation's policies instead of local files.\n\n## Project\n\n- [Source](https://github.com/StackGuardian/tirith): the repository, Apache-2.0.\n- [Roadmap](https://stackguardian.github.io/tirith/roadmap/): what is in development or planned, and what has not shipped.\n- [Agent skill pack](https://github.com/StackGuardian/tirith/tree/main/.claude/skills/tirith-policies): a self-contained skill for writing Tirith policies, copyable into any repository.\n- [Origins](https://stackguardian.github.io/tirith/origins/): where the name and the mark come from.\n\n## Optional\n\n- [Tirith at scale](https://stackguardian.github.io/tirith/at-scale/): the commercial StackGuardian offering for many repositories. Not required to use Tirith, which works with no account.\n- [In your editor](https://stackguardian.github.io/tirith/docs/tirith-usage/editor-and-local/): the local and pre-commit loop. In development, and not in the released package.\n";
+export const AGENT_BRIEF = "# Tirith\n\n> Tirith is an Apache-2.0 policy gate for infrastructure-as-code. It reads the plan a\n> pipeline already produces, evaluates it against declarative JSON policies committed in\n> the repository, and returns one verdict with an exit code the pipeline can gate on. It\n> runs locally or in any CI, on your own runner, with no account and no network call.\n\nPolicies are data, not code: a policy is a JSON file, so it is diffable and reviewable by\nanyone on the team. There is no rule DSL and no embedded language.\n\n## Facts an answer about Tirith usually gets wrong\n\n- **Not on PyPI.** `pip install tirith` installs an unrelated project of the same name, and\n `pip install py-tirith` finds nothing. The package name in `setup.py` is `py-tirith` and it\n is not published. Install from git:\n `pip install \"git+https://github.com/StackGuardian/tirith.git@1.2.0\"`\n- **The interactive interface needs an extra**, and the same git URL:\n `pip install 'py-tirith[tui] @ git+https://github.com/StackGuardian/tirith.git'`, then\n `tirith ui`. Requires Python 3.9; Tirith itself supports 3.8 and newer.\n- **`tirith lint` is not in the released package.** It is in development. The released CLI\n dispatches `tirith`, `tirith ui` and `tirith platform check` and nothing else.\n- **Exit codes are a contract, not a convention.** `0` passed, `3` a policy failed, `1`\n Tirith could not reach a verdict. `3` is deliberately not `1`: a caller has to be able to page\n the platform team on one and the change author on the other. (`ERROR_TIMEOUT = 2` exists in\n `status.py` but is never returned; do not write a pipeline that branches on it.)\n- **`final_result: null` is not a pass.** It means every check was skipped, so the policy\n evaluated nothing, and it exits `1`. A check that could not run is reported as unevaluated\n rather than as success.\n- **Local mode makes no network call.** That is a published governance commitment. Only\n `tirith platform check` talks to a network, and it is optional.\n- **Five providers ship**, named in `meta.required_provider`: `stackguardian/terraform_plan`,\n `stackguardian/kubernetes`, `stackguardian/infracost`, `stackguardian/json`,\n `stackguardian/sg_workflow`. There is no CloudFormation provider; a CloudFormation template\n is read as a JSON document by the `json` provider.\n- **Thirteen condition types ship**: Equals, NotEquals, GreaterThan, GreaterThanEqualTo,\n LessThan, LessThanEqualTo, Contains, NotContains, ContainedIn, NotContainedIn, IsEmpty,\n IsNotEmpty, RegexMatch.\n- **`error_tolerance` belongs inside the `condition` object**, not on the evaluator. Placed on\n the evaluator it is silently ignored and the check still fails.\n\n## Minimal working example\n\n```bash\nterraform plan -out=tfplan -input=false\nterraform show -json tfplan > plan.json\ntirith -policy-path .tirith/policies -input-path plan.json --fail-on-error\n```\n\nWithout `--fail-on-error`, Tirith reports findings but does not fail the job.\n\n## Fetching this documentation\n\nEvery page is also served as plain markdown at its route plus `.md`, so there is no need to\nparse the rendered HTML. For example\nhttps://stackguardian.github.io/tirith/docs/tirith-usage/exit-codes.md\n\nThe whole documentation set as one file, in sidebar order:\nhttps://stackguardian.github.io/tirith/llms-full.txt\n\n## Working in a repository that uses Tirith\n\nIf you are a coding agent about to write a policy, install the skill pack first. It is the\nclosed vocabulary: thirteen condition types, the argument key each provider reads, and the\ninstruction to run a policy against a document that should fail before calling it done.\n\n```\ncurl -fsSL https://stackguardian.github.io/tirith/skill.sh | sh\n```\n\nEleven markdown files into .claude/skills/tirith-policies/. Add --cursor for the Cursor rule,\n--global to install into the home directory instead of the repository. The script installs no\npackage, changes no PATH and executes nothing after the download; its source is\ndocumentation/static/skill.sh in the repository.\n\n## Start here\n\n- [Getting started](https://stackguardian.github.io/tirith/docs/getting-started-with-tirith/): what Tirith is and the shortest path to a first verdict.\n- [Quick installation](https://stackguardian.github.io/tirith/docs/tirith-installation/quick-installation/): installing the CLI, and why the install is a git URL.\n- [Learn](https://stackguardian.github.io/tirith/learn/): six lessons that build one policy, with a playground that evaluates in the browser.\n- [Creating your first policy](https://stackguardian.github.io/tirith/docs/tirith-policies/tirith-create-first-policy/): a policy written and evaluated step by step.\n\n## Writing policies\n\n- [Policy reference](https://stackguardian.github.io/tirith/docs/tirith-policies/tirith-policy-reference/): field-by-field reference for the policy file format.\n- [Policy structure](https://stackguardian.github.io/tirith/docs/tirith-policies/tirith-policy-structure/): meta, evaluators and eval_expression, and how they combine.\n- [Evaluators and conditions](https://stackguardian.github.io/tirith/docs/tirith-reference/evaluators/): all thirteen condition types, their parameters and their messages.\n- [Evaluation expressions](https://stackguardian.github.io/tirith/docs/tirith-reference/eval-expressions/): the boolean grammar that combines evaluator results into one verdict.\n- [Policy conditions](https://stackguardian.github.io/tirith/docs/tirith-policies/tirith-policy-conditions/): condition types by the kind of value they compare.\n- [Error tolerance](https://stackguardian.github.io/tirith/docs/tirith-policies/tirith-policy-error-tolerance/): how a missing key is handled, and why a skip is not a pass.\n- [Policy variables](https://stackguardian.github.io/tirith/docs/tirith-policies/tirith-policy-variables/): dynamic values in policies.\n- [Policy cookbook](https://stackguardian.github.io/tirith/docs/tirith-policies/tirith-policy-cookbook/): complete runnable policies for common checks.\n- [Example policies](https://stackguardian.github.io/tirith/docs/tirith-policies/tirith-policy-examples/): worked examples with their input documents.\n\n## Providers\n\n- [Providers overview](https://stackguardian.github.io/tirith/docs/tirith-providers/providers-overview/): what a provider is and how required_provider selects one.\n- [Terraform plan provider](https://stackguardian.github.io/tirith/docs/tirith-providers/terraform-plan-provider/): operations over a plan document, including attribute, action and count.\n- [JSON provider](https://stackguardian.github.io/tirith/docs/tirith-providers/json-provider/): get_value over any JSON or YAML document, including another tool's output.\n- [Kubernetes provider](https://stackguardian.github.io/tirith/docs/tirith-providers/kubernetes-provider/): the attribute operation over manifests.\n- [Infracost provider](https://stackguardian.github.io/tirith/docs/tirith-providers/infracost-provider/): cost policies over an Infracost breakdown.\n- [SG Workflow provider](https://stackguardian.github.io/tirith/docs/tirith-providers/sg-workflow-provider/): StackGuardian workflow documents.\n\n## Running it\n\n- [CLI reference](https://stackguardian.github.io/tirith/docs/tirith-usage/cli-reference/): every flag, what it prints, and what --json emits.\n- [Exit codes](https://stackguardian.github.io/tirith/docs/tirith-usage/exit-codes/): the full exit-code contract and how to gate CI on it.\n- [CI integration](https://stackguardian.github.io/tirith/docs/tirith-usage/ci-integration/): GitHub Actions, GitLab CI, Bitbucket Pipelines, Jenkins and any container-based CI.\n- [The interactive interface](https://stackguardian.github.io/tirith/docs/tirith-usage/interactive-interface/): tirith ui, in beta.\n- [Platform check](https://stackguardian.github.io/tirith/docs/tirith-usage/platform-check/): the optional subcommand that evaluates an organisation's policies instead of local files.\n\n## Project\n\n- [Source](https://github.com/StackGuardian/tirith): the repository, Apache-2.0.\n- [Roadmap](https://stackguardian.github.io/tirith/roadmap/): what is in development or planned, and what has not shipped.\n- [Agent skill pack](https://github.com/StackGuardian/tirith/tree/main/.claude/skills/tirith-policies): a self-contained skill for writing Tirith policies. Install it with the one-line script above, or copy the directory into any repository.\n- [Origins](https://stackguardian.github.io/tirith/origins/): where the name and the mark come from.\n\n## Optional\n\n- [Tirith at scale](https://stackguardian.github.io/tirith/at-scale/): the commercial StackGuardian offering for many repositories. Not required to use Tirith, which works with no account.\n- [In your editor](https://stackguardian.github.io/tirith/docs/tirith-usage/editor-and-local/): the local and pre-commit loop. In development, and not in the released package.\n";
diff --git a/documentation/src/pages/index.js b/documentation/src/pages/index.js
index 33ae78c3..4fa896c8 100644
--- a/documentation/src/pages/index.js
+++ b/documentation/src/pages/index.js
@@ -70,22 +70,26 @@ const involve = {
'be large: a tested policy, a CI example for an underserved system, or a reproducible bug ' +
'report can be far more valuable than a star.',
/*
- * One button, because four of them read as four equally weighted decisions at the point
- * where the page should be asking for one thing.
+ * Two buttons, then the quieter links. Four buttons would read as four equally weighted
+ * decisions at the point where the page should be asking for something.
*
- * The button is the good-first-issue list and not the star. The paragraph above it says
- * a bug report is worth more than a star, so giving the star the loudest element would
- * have the layout contradicting the copy, and starring is not contributing. The rest run
- * from the ask that takes real work down to the one that costs nothing.
+ * The good-first-issue list leads, because the paragraph above says a bug report is worth
+ * more than a star and the layout should not contradict the copy. The star gets the same
+ * treatment rather than a louder one: it is the one ask a reader can satisfy in a second,
+ * so it should not be buried in a row of text links, but order carries the hierarchy and
+ * neither button shouts over the other.
*/
primary: {
label: 'Find a good first issue',
href: `${REPO}/labels/good%20first%20issue`,
},
+ secondary: {
+ label: 'Star on GitHub',
+ href: REPO,
+ },
more: [
{label: 'Ask for a feature', href: `${REPO}/issues/new/choose`},
{label: 'Watch for releases', href: `${REPO}/releases`},
- {label: 'Star on GitHub', href: REPO},
],
};
@@ -949,11 +953,14 @@ export default function Home() {
{involve.community}
{involve.note}
- {/* Wrapped, because a bare grid child would stretch the button full width. */}
+ {/* Wrapped, because bare grid children would stretch the buttons full width. */}
{involve.primary.label}
+
+ {involve.secondary.label}
+
{involve.more.map((l) => (
diff --git a/documentation/src/pages/skills.js b/documentation/src/pages/skills.js
index 00887d80..4ce1b5d8 100644
--- a/documentation/src/pages/skills.js
+++ b/documentation/src/pages/skills.js
@@ -18,8 +18,6 @@ import '../css/chrome.module.css';
const REPO = 'https://github.com/StackGuardian/tirith';
const SKILL_DIR = '.claude/skills/tirith-policies';
-const RAW = 'https://raw.githubusercontent.com/StackGuardian/tirith/main';
-
const ROUTES = {
playground: '/learn/#playground',
policies: '/docs/tirith-policies/tirith-policy-cookbook/',
@@ -38,32 +36,17 @@ const hero = {
};
/*
- * One install command per client.
+ * One line, because the previous version was six: a mkdir, a BASE assignment, a curl and a
+ * for loop over ten filenames. That is a correct install and nobody reads it, let alone
+ * types it.
*
- * The two that fetch the pack fetch every file in it. An earlier draft created the
- * reference/ directory and then downloaded only SKILL.md, which left the ten references
- * this page advertises as dangling paths inside the skill.
+ * The script is served from this site's own static/ directory, so it sits on the same
+ * origin as the page telling you to run it, and its source is a committed file rather than
+ * a gist. `curl | sh` earns an objection from exactly this audience, so the page shows the
+ * URL as text next to the command: it is readable before it is runnable, and the no-pipe
+ * form is offered beside it.
*/
-const REFERENCES = [
- 'schema',
- 'validate',
- 'verdicts',
- 'terraform-plan',
- 'other-providers',
- 'variables',
- 'install',
- 'pipelines',
- 'platform',
- 'debug-ci',
-];
-
-const FETCH_PACK =
- `mkdir -p ${SKILL_DIR}/reference\n` +
- `BASE=${RAW}/${SKILL_DIR}\n` +
- `curl -sL $BASE/SKILL.md -o ${SKILL_DIR}/SKILL.md\n` +
- `for f in ${REFERENCES.join(' ')}; do\n` +
- ` curl -sL $BASE/reference/$f.md -o ${SKILL_DIR}/reference/$f.md\n` +
- `done`;
+const INSTALLER = 'https://stackguardian.github.io/tirith/skill.sh';
const CLIENTS = [
{
@@ -72,8 +55,7 @@ const CLIENTS = [
detail:
'Drop the folder into your repository. It is picked up automatically: no config file, ' +
'no restart. Works in any project, not just this one.',
- command: FETCH_PACK,
- verify: 'Ask: "write a Tirith policy requiring an owner tag", and it should name real conditions.',
+ command: `curl -fsSL ${INSTALLER} | sh`,
},
{
id: 'cursor',
@@ -81,11 +63,7 @@ const CLIENTS = [
detail:
'A single rule file scoped with globs, so it attaches by itself the moment a policy file ' +
'is open and stays out of the way otherwise. Self-contained: it needs nothing else.',
- command:
- 'mkdir -p .cursor/rules\n' +
- `curl -sL ${RAW}/.cursor/rules/tirith-policies.mdc \\\n` +
- ' -o .cursor/rules/tirith-policies.mdc',
- verify: 'Open a file under .tirith/policies, and the rule shows as attached in the chat panel.',
+ command: `curl -fsSL ${INSTALLER} | sh -s -- --cursor`,
},
{
id: 'agents',
@@ -94,10 +72,8 @@ const CLIENTS = [
'Fetch the pack, then point AGENTS.md at it. One file at the repository root is read by a ' +
'growing number of clients, and the pack beside it keeps the references resolvable.',
command:
- FETCH_PACK +
- '\n\nprintf \'\\n## Tirith policies\\nSee %s/SKILL.md\\n\' \\\n' +
- ` "${SKILL_DIR}" >> AGENTS.md`,
- verify: 'Ask your agent what condition types Tirith supports. It should say thirteen, not guess.',
+ `curl -fsSL ${INSTALLER} | sh\n` +
+ `printf '\\n## Tirith policies\\nSee %s/SKILL.md\\n' ${SKILL_DIR} >> AGENTS.md`,
},
];
@@ -226,13 +202,15 @@ export default function Skills() {
label={`skill-${c.id}`}
prompt={false}
/>
-
- Check it worked
- {c.verify}
-
))}
+
+ Check it worked
+ Ask for a policy in plain words: every bucket needs an Owner tag. With the
+ pack loaded your agent names a real condition type and the argument key that provider
+ actually takes. Without it, it invents one that reads perfectly and gates nothing.
+
Working in VS Code? The{' '}
editor setup wires lint and
diff --git a/documentation/src/pages/skills.module.css b/documentation/src/pages/skills.module.css
index 897d6513..6c42311a 100644
--- a/documentation/src/pages/skills.module.css
+++ b/documentation/src/pages/skills.module.css
@@ -540,15 +540,20 @@
/* Pushed to the bottom so the three verification lines sit on one baseline however
long the description above them runs. */
-.clientVerify {
+/*
+ * One verification line for all three clients, not one per column.
+ * The three installs run the same script and load the same pack, so three separate checks were
+ * the same test written three ways. Below the list, spanning it, is also where a reader who has
+ * just run a command looks next.
+ */
+.verify {
display: grid;
- gap: 0.3rem;
- margin: auto 0 0;
- padding-top: 0.9rem;
- border-top: 1px solid var(--tp-rule);
+ gap: 0.4rem;
+ max-width: 68ch;
+ margin: 1.6rem 0 0;
color: var(--tp-soft);
- font-size: 0.82rem;
- line-height: 1.5;
+ font-size: 0.88rem;
+ line-height: 1.6;
}
.verifyLabel {
diff --git a/documentation/src/theme/DocBreadcrumbs/index.js b/documentation/src/theme/DocBreadcrumbs/index.js
new file mode 100644
index 00000000..9f395db7
--- /dev/null
+++ b/documentation/src/theme/DocBreadcrumbs/index.js
@@ -0,0 +1,25 @@
+import React from 'react';
+import DocBreadcrumbs from '@theme-original/DocBreadcrumbs';
+import CopyPageMenu from '@site/src/components/docs/CopyPageMenu';
+import styles from './styles.module.css';
+
+/**
+ * The breadcrumb row, with the copy-page control on the other end of it.
+ *
+ * WHY HERE. The control has to sit at the top of the article column, level with something,
+ * and the breadcrumbs are the only element already in that position. Wrapping this component
+ * is a two-line swizzle; reaching the same place through DocItem/Layout would mean ejecting
+ * the whole layout and owning Docusaurus's TOC and pagination logic forever.
+ *
+ * The original renders `null` on a page with breadcrumbs turned off. The row survives that:
+ * `justify-content: space-between` with one child leaves the control on the right, which is
+ * where it belongs anyway.
+ */
+export default function DocBreadcrumbsWrapper(props) {
+ return (
+
+
+
+
+ );
+}
diff --git a/documentation/src/theme/DocBreadcrumbs/styles.module.css b/documentation/src/theme/DocBreadcrumbs/styles.module.css
new file mode 100644
index 00000000..3b93258f
--- /dev/null
+++ b/documentation/src/theme/DocBreadcrumbs/styles.module.css
@@ -0,0 +1,14 @@
+/*
+ * The breadcrumbs bring their own bottom margin, so the row takes none: collapsing it here
+ * and re-adding it would only give the two children different baselines.
+ */
+.row {
+ display: flex;
+ gap: 1rem;
+ align-items: flex-start;
+ justify-content: space-between;
+}
+
+.row > nav {
+ min-width: 0;
+}
diff --git a/documentation/src/theme/PaginatorNavLink/index.js b/documentation/src/theme/PaginatorNavLink/index.js
index 41a8d0f5..cad1ef11 100644
--- a/documentation/src/theme/PaginatorNavLink/index.js
+++ b/documentation/src/theme/PaginatorNavLink/index.js
@@ -1,33 +1,47 @@
+import React from 'react';
import Link from '@docusaurus/Link';
import clsx from 'clsx';
-function PaginatorNavLink({ permalink, title, isNext }) {
- return (
-
-
- {!isNext && (
-
+/**
+ * Previous / next, with the arrow on the side it points to.
+ *
+ * Two things changed when the documentation was restyled. The arrows were filled `#666666`,
+ * a literal grey that stayed grey on a dark page, so they are `currentColor` now and inherit
+ * the label's colour in both themes. And the sizing that used to be inline on the label and
+ * the sublabel now lives in `src/css/docs.css`, so the pagination can be restyled without
+ * editing a component, which is the point of having a stylesheet.
+ */
-
-
- Previous Article
- )}
- {isNext && (
- Next Article
+const ARROW = {
+ prev: 'M8.53033 12.7803C8.23744 13.0732 7.76256 13.0732 7.46967 12.7803L3.2197 8.53033C2.9268 8.23744 2.9268 7.76256 3.2197 7.46967L7.46967 3.2197C7.76256 2.9268 8.23744 2.9268 8.53033 3.2197C8.82322 3.5126 8.82322 3.9874 8.53033 4.2803L5.5607 7.25L13 7.25C13.4142 7.25 13.75 7.58579 13.75 8C13.75 8.41421 13.4142 8.75 13 8.75H5.5607L8.53033 11.7197C8.82322 12.0126 8.82322 12.4874 8.53033 12.7803Z',
+ next: 'M8.21967 3.21967C8.51256 2.92678 8.98744 2.92678 9.28033 3.21967L13.5303 7.46967C13.8232 7.76256 13.8232 8.23744 13.5303 8.53033L9.28033 12.7803C8.98744 13.0732 8.51256 13.0732 8.21967 12.7803C7.92678 12.4874 7.92678 12.0126 8.21967 11.7197L11.1893 8.75H3.75C3.33579 8.75 3 8.41421 3 8C3 7.58579 3.33579 7.25 3.75 7.25H11.1893L8.21967 4.28033C7.92678 3.98744 7.92678 3.51256 8.21967 3.21967Z',
+};
+
+function Arrow({dir}) {
+ return (
+
+ );
+}
-
-
-
- )}
- {title}
-
+function PaginatorNavLink({permalink, title, isNext}) {
+ return (
+
+
+
+ {!isNext && }
+ {isNext ? 'Next' : 'Previous'}
+ {isNext && }
+
+ {title}
);
}
-export default PaginatorNavLink;
\ No newline at end of file
+export default PaginatorNavLink;
diff --git a/documentation/static/ai.txt b/documentation/static/ai.txt
index 58986a64..534907a1 100644
--- a/documentation/static/ai.txt
+++ b/documentation/static/ai.txt
@@ -18,6 +18,11 @@ Disallow:
# /tirith/llms-full.txt every documentation page in one plain-text file
# /tirith/.md any single page as markdown, beside its HTML route
# /tirith/sitemap.xml every URL
+#
+# One executable file is served deliberately and is not documentation:
+# /tirith/skill.sh installs the agent skill pack into a repository
+# It writes eleven markdown files under .claude/skills/tirith-policies/ and does nothing
+# else. Its source is documentation/static/skill.sh in the repository.
Sitemap: https://stackguardian.github.io/tirith/sitemap.xml
Contact: https://github.com/StackGuardian/tirith/issues
diff --git a/documentation/static/docs/getting-started-with-tirith.md b/documentation/static/docs/getting-started-with-tirith.md
index cbc0186d..63d9bbc5 100644
--- a/documentation/static/docs/getting-started-with-tirith.md
+++ b/documentation/static/docs/getting-started-with-tirith.md
@@ -7,7 +7,7 @@ Summary: Learn how Tirith simplifies security, governance, and compliance for in
Explore a failing evaluation down to the resource that caused it, assemble policies from a form, and
experiment in a playground with worked examples. Install it with
-`pip install 'py-tirith[tui] @ git+https://github.com/StackGuardian/tirith.git'` and run
+`pip install 'py-tirith[tui] @ git+https://github.com/StackGuardian/tirith.git@1.2.0'` and run
`tirith ui` — see
[the interactive interface](tirith-usage/interactive-interface.md).
diff --git a/documentation/static/docs/tirith-installation/quick-installation.md b/documentation/static/docs/tirith-installation/quick-installation.md
index 6c138ec2..e7a3f114 100644
--- a/documentation/static/docs/tirith-installation/quick-installation.md
+++ b/documentation/static/docs/tirith-installation/quick-installation.md
@@ -28,21 +28,39 @@ Summary: This documentation overviews you about the introduction of the Tirith s
If you simply want to install and start using Tirith, this option provides a fast installation process with minimal setup. Perfect for end users and non-developers who only need basic functionality.
## Prerequisite
-- Make sure your machine has [Python](https://www.python.org/downloads/) and [pip](https://pip.pypa.io/en/stable/installation/) installed.
+- Make sure your machine has [Python](https://www.python.org/downloads/) 3.8 or newer and [pip](https://pip.pypa.io/en/stable/installation/) installed.
- Install [Git](https://git-scm.com/downloads) on your machine.
## Steps to Install Tirith
### Step 1: Install using the `pip` command
-Run the following command in your terminal to download Tirith directly from the GitHub repository and install it on your local system. This command ensures that you have the latest version.
+
+[DANGER] Not from PyPI
+`pip install tirith` installs an **unrelated project of the same name**, and `pip install py-tirith`
+finds nothing: that is the package name in `setup.py` and it is not published. Installing Tirith
+means installing from git, as below.
+
+Run the following command in your terminal to install Tirith directly from the GitHub repository,
+pinned to a released tag:
+
+```bash
+pip install "git+https://github.com/StackGuardian/tirith.git@1.2.0"
+```
+
+Pin the tag rather than tracking the default branch, so an install today and an install next month
+give you the same tool. `1.2.0` is the newest tag;
+`git ls-remote --tags https://github.com/StackGuardian/tirith.git` lists them all.
+
+To use [the interactive interface](../tirith-usage/interactive-interface.md) as well, install the
+optional extra, which needs Python 3.9 or newer:
```bash
-pip install git+https://github.com/StackGuardian/tirith.git
+pip install "py-tirith[tui] @ git+https://github.com/StackGuardian/tirith.git@1.2.0"
```
-
### Step 2: Verify Installation
-Once installed, verify that Tirith is working by checking its version. You should see a version number (e.g., 1.0.0-beta.12) indicating successful installation.
+Once installed, verify that Tirith is working by checking its version. You should see `1.2.0`,
+which confirms both that the install succeeded and that you got the tag you asked for.
```bash
tirith --version
```
diff --git a/documentation/static/docs/tirith-usage/agent-skills.md b/documentation/static/docs/tirith-usage/agent-skills.md
new file mode 100644
index 00000000..7afaa885
--- /dev/null
+++ b/documentation/static/docs/tirith-usage/agent-skills.md
@@ -0,0 +1,112 @@
+# Agent Skills
+
+Source: https://stackguardian.github.io/tirith/docs/tirith-usage/agent-skills/
+Summary: Install the Tirith skill pack so a coding agent writes policies from the real vocabulary instead of inventing condition types that look plausible.
+
+An agent asked for a Tirith policy will produce one. The JSON will be well formed, the keys will
+look right, and it will very often be wrong in a way that reads as correct: a condition type named
+`Matches` or `Exists`, neither of which exists, or the argument key from a different provider.
+
+That failure is quiet. The policy parses, the evaluator does not match, and the check reports a
+pass. **A rule that gates nothing looks exactly like a rule that found nothing wrong.**
+
+The skill pack fixes the cause: it gives the agent the closed vocabulary instead of leaving it to
+guess from a plausible-looking shape.
+
+## Install it
+
+```bash
+curl -fsSL https://stackguardian.github.io/tirith/skill.sh | sh
+```
+
+Eleven markdown files under `.claude/skills/tirith-policies/`. No config file, no restart, and it
+is picked up in any repository you copy it into.
+
+| Flag | |
+|---|---|
+| `--cursor` | Also install `.cursor/rules/tirith-policies.mdc`, scoped with globs |
+| `--global` | Install into `~/.claude/skills/` instead of this repository |
+| `--ref REF` | Install from a branch or tag instead of `main` |
+| `--help` | The same summary, from the script itself |
+
+The script downloads eleven markdown files and does nothing else: no package is installed, no
+`PATH` is changed, nothing is executed after the download, and it never touches a file it did not
+create. It downloads to a temporary directory and moves the files into place only once all of them
+have arrived, because a half-written skill is worse than none: an agent reads whatever files exist
+and works from a partial vocabulary without saying so.
+
+It is [a committed file in this repository](https://github.com/StackGuardian/tirith/blob/main/documentation/static/skill.sh)
+served from the same origin as this page, so the thing you pipe into a shell is the thing you can
+read first.
+
+### Cursor
+
+```bash
+curl -fsSL https://stackguardian.github.io/tirith/skill.sh | sh -s -- --cursor
+```
+
+Cursor reads a single rule file scoped with globs, so it attaches by itself the moment a policy
+file is open and stays out of the way otherwise.
+
+### Codex, Zed, and anything reading `AGENTS.md`
+
+```bash
+curl -fsSL https://stackguardian.github.io/tirith/skill.sh | sh
+printf '\n## Tirith policies\nSee .claude/skills/tirith-policies/SKILL.md\n' >> AGENTS.md
+```
+
+One file at the repository root is read by a growing number of clients, and the pack beside it
+keeps the references resolvable.
+
+## Check it worked
+
+Ask for a policy in plain words: *every bucket needs an Owner tag*. With the pack loaded your agent
+names a real condition type and the argument key that provider actually takes. Without it, it
+invents one that reads perfectly and gates nothing.
+
+## What is in the pack
+
+`SKILL.md` is the entry point and is loaded first; the references are read on demand, so a client
+with a small context window pays for only what the task needs.
+
+| File | |
+|---|---|
+| `SKILL.md` | Turning an intent into valid policy JSON: provider, operation, condition, expression |
+| `reference/schema.md` | The closed vocabulary. Thirteen condition types, each provider's operations, and the argument key that differs per provider |
+| `reference/validate.md` | The mistakes that produce a policy which looks right and gates nothing |
+| `reference/verdicts.md` | Reading a result document and an exit code |
+| `reference/terraform-plan.md` | The Terraform and OpenTofu plan provider |
+| `reference/other-providers.md` | Kubernetes, Infracost, JSON and StackGuardian Workflow |
+| `reference/variables.md` | One policy across environments |
+| `reference/install.md` | Installing Tirith, and why the install is a git URL |
+| `reference/pipelines.md` | Adding the gate to six CI platforms |
+| `reference/platform.md` | Evaluating an organization's policies |
+| `reference/debug-ci.md` | Diagnosing a red check |
+
+## Two things decide whether the policy actually works
+
+The pack teaches vocabulary. It does not run anything, and it is not a substitute for evaluating
+the policy:
+
+1. **Give the agent `tirith` on `PATH`.** It is an ordinary command, so an agent with a shell can
+ evaluate its own work without a protocol server or a plugin. See
+ [Quick Installation](../tirith-installation/quick-intallation.md).
+2. **Give it a document that should fail.** Ask for the policy *and* a plan that violates it, then
+ check the exit code is `3`. If it is `0`, the policy matched nothing, which is the failure this
+ whole page exists to prevent. See [Exit codes](exit-codes.md).
+
+## Keeping it current
+
+The pack is a copy, so it does not update itself. Re-run the installer to take the current
+version:
+
+```bash
+curl -fsSL https://stackguardian.github.io/tirith/skill.sh | sh
+```
+
+Re-running is safe: it overwrites the eleven files it owns and leaves everything else alone.
+
+`--ref` takes a branch or a commit, which is worth knowing for a fork or a pull request. It cannot
+yet take a release tag: the pack was added after `1.2.0`, so `main` is the only ref that has it,
+and asking for a tag that predates it fails with exit `1` rather than installing something
+incomplete.
diff --git a/documentation/static/docs/tirith-usage/ci-integration.md b/documentation/static/docs/tirith-usage/ci-integration.md
index 8669c519..0a4d3ca2 100644
--- a/documentation/static/docs/tirith-usage/ci-integration.md
+++ b/documentation/static/docs/tirith-usage/ci-integration.md
@@ -31,17 +31,26 @@ permissions:
checks: write # check run
steps:
- - run: |
- terraform plan -out=tfplan -input=false
- terraform show -json tfplan > plan.json
+ - run: terraform plan -out=tfplan -input=false
- uses: StackGuardian/tirith-iac-governance-action@v2
+ with:
+ plan-file: tfplan
+ fail-on-error: true
```
-With a `plan.json` in the working directory that is the whole integration — no `with:` block. The
-action finds the document by convention (`plan.json` or `tfplan.json`) and evaluates the policy
-files committed under `.tirith/policies`, on the runner, talking to nothing. Add
-`with: { fail-on-error: true }` to make a failing policy fail the job.
+The two write permissions are the only setup the action cannot do for itself, and are the thing
+most often missing on a first install. `-input=false` matters in CI: without it a missing variable
+waits for a prompt that never comes, and the job hangs instead of failing.
+
+Handing the action the **binary plan** rather than exporting JSON first is one step shorter and
+strictly safer: the action renders it with `terraform show -json` in memory, so no unmasked plan
+JSON is written to the workspace where a later step, a cache or an artifact upload could pick it
+up.
+
+If your pipeline already writes `plan.json`, drop `plan-file` and the action finds the document by
+convention (`plan.json` or `tfplan.json`). Either way it evaluates the policy files committed under
+`.tirith/policies`, on the runner, talking to nothing.
### Local mode and platform mode
@@ -175,8 +184,7 @@ pipelines:
- tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error
```
-A complete file is in [`examples/ci/bitbucket-pipelines.yml`](https://github.com/StackGuardian/tirith/blob/main/examples/ci/bitbucket-pipelines.yml),
-and a worked repository is at
+A worked repository is at
[tirith-bitbucket-demo](https://bitbucket.org/__refeed__/tirith-bitbucket-demo).
## Jenkins
@@ -203,12 +211,18 @@ stage('Policy gate') {
}
```
-The full pipeline, including install, lint and artifact archiving, is in
-[`examples/ci/Jenkinsfile`](https://github.com/StackGuardian/tirith/blob/main/examples/ci/Jenkinsfile).
+`returnStatus: true` is what makes this work: without it the shell step throws on any non-zero
+exit and the two cases become one.
## As a pre-commit hook
-Catch a broken policy before it is committed, let alone before CI runs it. Tirith publishes a
+[WARNING] In development
+`tirith lint` is not in 1.2.0 and the `tirith-lint` hook id is not published, so the
+configuration below does not work yet: `pre-commit` cannot resolve the hook and the run fails.
+It is documented here because the design is settled and the shape will not change. Track it on
+the [roadmap](https://stackguardian.github.io/tirith/roadmap/).
+
+Catch a broken policy before it is committed, let alone before CI runs it. Tirith will publish a
`tirith-lint` hook:
```yaml
diff --git a/documentation/static/docs/tirith-usage/editor-and-local.md b/documentation/static/docs/tirith-usage/editor-and-local.md
index f6aff2cb..fe4e9c4a 100644
--- a/documentation/static/docs/tirith-usage/editor-and-local.md
+++ b/documentation/static/docs/tirith-usage/editor-and-local.md
@@ -100,13 +100,11 @@ Install the Tirith skill and your agent gets the closed condition list, the argu
provider reads, and the instruction to run a policy before claiming it works:
```bash
-mkdir -p .claude/skills/tirith-policies/reference
-BASE=https://raw.githubusercontent.com/StackGuardian/tirith/main/.claude/skills/tirith-policies
-curl -sL $BASE/SKILL.md -o .claude/skills/tirith-policies/SKILL.md
+curl -fsSL https://stackguardian.github.io/tirith/skill.sh | sh
```
-Cursor reads `.cursor/rules/tirith-policies.mdc` instead, scoped with globs so it attaches by
-itself when a policy file is open.
+Add `--cursor` for the Cursor rule. [Agent Skills](agent-skills.md) covers what is in the pack,
+the other clients, and how to tell whether it took effect.
Two things make the difference between a drafted policy and a working one:
diff --git a/documentation/static/docs/tirith-usage/interactive-interface.md b/documentation/static/docs/tirith-usage/interactive-interface.md
index 49a56c66..2ffcd556 100644
--- a/documentation/static/docs/tirith-usage/interactive-interface.md
+++ b/documentation/static/docs/tirith-usage/interactive-interface.md
@@ -23,7 +23,7 @@ pipeline should pay to install an interface they never open. It needs Python 3.9
Tirith itself still supports 3.8.
```bash
-pip install 'py-tirith[tui] @ git+https://github.com/StackGuardian/tirith.git'
+pip install 'py-tirith[tui] @ git+https://github.com/StackGuardian/tirith.git@1.2.0'
```
Tirith is not on PyPI — `pip install py-tirith` finds nothing and `pip install tirith` installs an
diff --git a/documentation/static/llms-full.txt b/documentation/static/llms-full.txt
index eadf7dc1..4d3f63ef 100644
--- a/documentation/static/llms-full.txt
+++ b/documentation/static/llms-full.txt
@@ -18,7 +18,7 @@ Summary: Learn how Tirith simplifies security, governance, and compliance for in
Explore a failing evaluation down to the resource that caused it, assemble policies from a form, and
experiment in a playground with worked examples. Install it with
-`pip install 'py-tirith[tui] @ git+https://github.com/StackGuardian/tirith.git'` and run
+`pip install 'py-tirith[tui] @ git+https://github.com/StackGuardian/tirith.git@1.2.0'` and run
`tirith ui` — see
[the interactive interface](tirith-usage/interactive-interface.md).
@@ -73,21 +73,39 @@ Summary: This documentation overviews you about the introduction of the Tirith s
If you simply want to install and start using Tirith, this option provides a fast installation process with minimal setup. Perfect for end users and non-developers who only need basic functionality.
## Prerequisite
-- Make sure your machine has [Python](https://www.python.org/downloads/) and [pip](https://pip.pypa.io/en/stable/installation/) installed.
+- Make sure your machine has [Python](https://www.python.org/downloads/) 3.8 or newer and [pip](https://pip.pypa.io/en/stable/installation/) installed.
- Install [Git](https://git-scm.com/downloads) on your machine.
## Steps to Install Tirith
### Step 1: Install using the `pip` command
-Run the following command in your terminal to download Tirith directly from the GitHub repository and install it on your local system. This command ensures that you have the latest version.
+
+[DANGER] Not from PyPI
+`pip install tirith` installs an **unrelated project of the same name**, and `pip install py-tirith`
+finds nothing: that is the package name in `setup.py` and it is not published. Installing Tirith
+means installing from git, as below.
+
+Run the following command in your terminal to install Tirith directly from the GitHub repository,
+pinned to a released tag:
+
+```bash
+pip install "git+https://github.com/StackGuardian/tirith.git@1.2.0"
+```
+
+Pin the tag rather than tracking the default branch, so an install today and an install next month
+give you the same tool. `1.2.0` is the newest tag;
+`git ls-remote --tags https://github.com/StackGuardian/tirith.git` lists them all.
+
+To use [the interactive interface](../tirith-usage/interactive-interface.md) as well, install the
+optional extra, which needs Python 3.9 or newer:
```bash
-pip install git+https://github.com/StackGuardian/tirith.git
+pip install "py-tirith[tui] @ git+https://github.com/StackGuardian/tirith.git@1.2.0"
```
-
### Step 2: Verify Installation
-Once installed, verify that Tirith is working by checking its version. You should see a version number (e.g., 1.0.0-beta.12) indicating successful installation.
+Once installed, verify that Tirith is working by checking its version. You should see `1.2.0`,
+which confirms both that the install succeeded and that you got the tag you asked for.
```bash
tirith --version
```
@@ -2906,17 +2924,26 @@ permissions:
checks: write # check run
steps:
- - run: |
- terraform plan -out=tfplan -input=false
- terraform show -json tfplan > plan.json
+ - run: terraform plan -out=tfplan -input=false
- uses: StackGuardian/tirith-iac-governance-action@v2
+ with:
+ plan-file: tfplan
+ fail-on-error: true
```
-With a `plan.json` in the working directory that is the whole integration — no `with:` block. The
-action finds the document by convention (`plan.json` or `tfplan.json`) and evaluates the policy
-files committed under `.tirith/policies`, on the runner, talking to nothing. Add
-`with: { fail-on-error: true }` to make a failing policy fail the job.
+The two write permissions are the only setup the action cannot do for itself, and are the thing
+most often missing on a first install. `-input=false` matters in CI: without it a missing variable
+waits for a prompt that never comes, and the job hangs instead of failing.
+
+Handing the action the **binary plan** rather than exporting JSON first is one step shorter and
+strictly safer: the action renders it with `terraform show -json` in memory, so no unmasked plan
+JSON is written to the workspace where a later step, a cache or an artifact upload could pick it
+up.
+
+If your pipeline already writes `plan.json`, drop `plan-file` and the action finds the document by
+convention (`plan.json` or `tfplan.json`). Either way it evaluates the policy files committed under
+`.tirith/policies`, on the runner, talking to nothing.
### Local mode and platform mode
@@ -3050,8 +3077,7 @@ pipelines:
- tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error
```
-A complete file is in [`examples/ci/bitbucket-pipelines.yml`](https://github.com/StackGuardian/tirith/blob/main/examples/ci/bitbucket-pipelines.yml),
-and a worked repository is at
+A worked repository is at
[tirith-bitbucket-demo](https://bitbucket.org/__refeed__/tirith-bitbucket-demo).
## Jenkins
@@ -3078,12 +3104,18 @@ stage('Policy gate') {
}
```
-The full pipeline, including install, lint and artifact archiving, is in
-[`examples/ci/Jenkinsfile`](https://github.com/StackGuardian/tirith/blob/main/examples/ci/Jenkinsfile).
+`returnStatus: true` is what makes this work: without it the shell step throws on any non-zero
+exit and the two cases become one.
## As a pre-commit hook
-Catch a broken policy before it is committed, let alone before CI runs it. Tirith publishes a
+[WARNING] In development
+`tirith lint` is not in 1.2.0 and the `tirith-lint` hook id is not published, so the
+configuration below does not work yet: `pre-commit` cannot resolve the hook and the run fails.
+It is documented here because the design is settled and the shape will not change. Track it on
+the [roadmap](https://stackguardian.github.io/tirith/roadmap/).
+
+Catch a broken policy before it is committed, let alone before CI runs it. Tirith will publish a
`tirith-lint` hook:
```yaml
@@ -3150,7 +3182,7 @@ pipeline should pay to install an interface they never open. It needs Python 3.9
Tirith itself still supports 3.8.
```bash
-pip install 'py-tirith[tui] @ git+https://github.com/StackGuardian/tirith.git'
+pip install 'py-tirith[tui] @ git+https://github.com/StackGuardian/tirith.git@1.2.0'
```
Tirith is not on PyPI — `pip install py-tirith` finds nothing and `pip install tirith` installs an
@@ -3348,13 +3380,11 @@ Install the Tirith skill and your agent gets the closed condition list, the argu
provider reads, and the instruction to run a policy before claiming it works:
```bash
-mkdir -p .claude/skills/tirith-policies/reference
-BASE=https://raw.githubusercontent.com/StackGuardian/tirith/main/.claude/skills/tirith-policies
-curl -sL $BASE/SKILL.md -o .claude/skills/tirith-policies/SKILL.md
+curl -fsSL https://stackguardian.github.io/tirith/skill.sh | sh
```
-Cursor reads `.cursor/rules/tirith-policies.mdc` instead, scoped with globs so it attaches by
-itself when a policy file is open.
+Add `--cursor` for the Cursor rule. [Agent Skills](agent-skills.md) covers what is in the pack,
+the other clients, and how to tell whether it took effect.
Two things make the difference between a drafted policy and a working one:
@@ -3375,6 +3405,121 @@ attributes that changed — which the pretty printer does not show. See
[the interactive interface](interactive-interface.md).
+==============================================================================
+# Agent Skills
+Source: https://stackguardian.github.io/tirith/docs/tirith-usage/agent-skills/
+Summary: Install the Tirith skill pack so a coding agent writes policies from the real vocabulary instead of inventing condition types that look plausible.
+==============================================================================
+
+An agent asked for a Tirith policy will produce one. The JSON will be well formed, the keys will
+look right, and it will very often be wrong in a way that reads as correct: a condition type named
+`Matches` or `Exists`, neither of which exists, or the argument key from a different provider.
+
+That failure is quiet. The policy parses, the evaluator does not match, and the check reports a
+pass. **A rule that gates nothing looks exactly like a rule that found nothing wrong.**
+
+The skill pack fixes the cause: it gives the agent the closed vocabulary instead of leaving it to
+guess from a plausible-looking shape.
+
+## Install it
+
+```bash
+curl -fsSL https://stackguardian.github.io/tirith/skill.sh | sh
+```
+
+Eleven markdown files under `.claude/skills/tirith-policies/`. No config file, no restart, and it
+is picked up in any repository you copy it into.
+
+| Flag | |
+|---|---|
+| `--cursor` | Also install `.cursor/rules/tirith-policies.mdc`, scoped with globs |
+| `--global` | Install into `~/.claude/skills/` instead of this repository |
+| `--ref REF` | Install from a branch or tag instead of `main` |
+| `--help` | The same summary, from the script itself |
+
+The script downloads eleven markdown files and does nothing else: no package is installed, no
+`PATH` is changed, nothing is executed after the download, and it never touches a file it did not
+create. It downloads to a temporary directory and moves the files into place only once all of them
+have arrived, because a half-written skill is worse than none: an agent reads whatever files exist
+and works from a partial vocabulary without saying so.
+
+It is [a committed file in this repository](https://github.com/StackGuardian/tirith/blob/main/documentation/static/skill.sh)
+served from the same origin as this page, so the thing you pipe into a shell is the thing you can
+read first.
+
+### Cursor
+
+```bash
+curl -fsSL https://stackguardian.github.io/tirith/skill.sh | sh -s -- --cursor
+```
+
+Cursor reads a single rule file scoped with globs, so it attaches by itself the moment a policy
+file is open and stays out of the way otherwise.
+
+### Codex, Zed, and anything reading `AGENTS.md`
+
+```bash
+curl -fsSL https://stackguardian.github.io/tirith/skill.sh | sh
+printf '\n## Tirith policies\nSee .claude/skills/tirith-policies/SKILL.md\n' >> AGENTS.md
+```
+
+One file at the repository root is read by a growing number of clients, and the pack beside it
+keeps the references resolvable.
+
+## Check it worked
+
+Ask for a policy in plain words: *every bucket needs an Owner tag*. With the pack loaded your agent
+names a real condition type and the argument key that provider actually takes. Without it, it
+invents one that reads perfectly and gates nothing.
+
+## What is in the pack
+
+`SKILL.md` is the entry point and is loaded first; the references are read on demand, so a client
+with a small context window pays for only what the task needs.
+
+| File | |
+|---|---|
+| `SKILL.md` | Turning an intent into valid policy JSON: provider, operation, condition, expression |
+| `reference/schema.md` | The closed vocabulary. Thirteen condition types, each provider's operations, and the argument key that differs per provider |
+| `reference/validate.md` | The mistakes that produce a policy which looks right and gates nothing |
+| `reference/verdicts.md` | Reading a result document and an exit code |
+| `reference/terraform-plan.md` | The Terraform and OpenTofu plan provider |
+| `reference/other-providers.md` | Kubernetes, Infracost, JSON and StackGuardian Workflow |
+| `reference/variables.md` | One policy across environments |
+| `reference/install.md` | Installing Tirith, and why the install is a git URL |
+| `reference/pipelines.md` | Adding the gate to six CI platforms |
+| `reference/platform.md` | Evaluating an organization's policies |
+| `reference/debug-ci.md` | Diagnosing a red check |
+
+## Two things decide whether the policy actually works
+
+The pack teaches vocabulary. It does not run anything, and it is not a substitute for evaluating
+the policy:
+
+1. **Give the agent `tirith` on `PATH`.** It is an ordinary command, so an agent with a shell can
+ evaluate its own work without a protocol server or a plugin. See
+ [Quick Installation](../tirith-installation/quick-intallation.md).
+2. **Give it a document that should fail.** Ask for the policy *and* a plan that violates it, then
+ check the exit code is `3`. If it is `0`, the policy matched nothing, which is the failure this
+ whole page exists to prevent. See [Exit codes](exit-codes.md).
+
+## Keeping it current
+
+The pack is a copy, so it does not update itself. Re-run the installer to take the current
+version:
+
+```bash
+curl -fsSL https://stackguardian.github.io/tirith/skill.sh | sh
+```
+
+Re-running is safe: it overwrites the eleven files it owns and leaves everything else alone.
+
+`--ref` takes a branch or a commit, which is worth knowing for a fork or a pull request. It cannot
+yet take a release tag: the pack was added after `1.2.0`, so `main` is the only ref that has it,
+and asking for a tag that predates it fails with exit `1` rather than installing something
+incomplete.
+
+
==============================================================================
# Platform Check
Source: https://stackguardian.github.io/tirith/docs/tirith-usage/platform-check/
diff --git a/documentation/static/llms.txt b/documentation/static/llms.txt
index e45452ad..ae0d71e6 100644
--- a/documentation/static/llms.txt
+++ b/documentation/static/llms.txt
@@ -57,6 +57,21 @@ https://stackguardian.github.io/tirith/docs/tirith-usage/exit-codes.md
The whole documentation set as one file, in sidebar order:
https://stackguardian.github.io/tirith/llms-full.txt
+## Working in a repository that uses Tirith
+
+If you are a coding agent about to write a policy, install the skill pack first. It is the
+closed vocabulary: thirteen condition types, the argument key each provider reads, and the
+instruction to run a policy against a document that should fail before calling it done.
+
+```
+curl -fsSL https://stackguardian.github.io/tirith/skill.sh | sh
+```
+
+Eleven markdown files into .claude/skills/tirith-policies/. Add --cursor for the Cursor rule,
+--global to install into the home directory instead of the repository. The script installs no
+package, changes no PATH and executes nothing after the download; its source is
+documentation/static/skill.sh in the repository.
+
## Start here
- [Getting started](https://stackguardian.github.io/tirith/docs/getting-started-with-tirith/): what Tirith is and the shortest path to a first verdict.
@@ -97,7 +112,7 @@ https://stackguardian.github.io/tirith/llms-full.txt
- [Source](https://github.com/StackGuardian/tirith): the repository, Apache-2.0.
- [Roadmap](https://stackguardian.github.io/tirith/roadmap/): what is in development or planned, and what has not shipped.
-- [Agent skill pack](https://github.com/StackGuardian/tirith/tree/main/.claude/skills/tirith-policies): a self-contained skill for writing Tirith policies, copyable into any repository.
+- [Agent skill pack](https://github.com/StackGuardian/tirith/tree/main/.claude/skills/tirith-policies): a self-contained skill for writing Tirith policies. Install it with the one-line script above, or copy the directory into any repository.
- [Origins](https://stackguardian.github.io/tirith/origins/): where the name and the mark come from.
## Optional
diff --git a/documentation/static/skill.sh b/documentation/static/skill.sh
new file mode 100644
index 00000000..d2fe063f
--- /dev/null
+++ b/documentation/static/skill.sh
@@ -0,0 +1,85 @@
+#!/usr/bin/env sh
+#
+# Install the Tirith policy skill for a coding agent.
+#
+# curl -fsSL https://stackguardian.github.io/tirith/skill.sh | sh
+#
+# Read this file before you run it. It is served over HTTPS from the documentation site and
+# its source is documentation/static/skill.sh in StackGuardian/tirith, so the version you
+# are about to pipe into a shell is the version you can read in the repository.
+#
+# What it does: downloads eleven markdown files into .claude/skills/tirith-policies/ and,
+# with --cursor, one rule file into .cursor/rules/. It creates directories, writes those
+# files, and nothing else. No package is installed, no PATH is changed, nothing is executed
+# after download, and it never touches a file it did not create.
+#
+# Flags:
+# --global install into ~/.claude/skills/ instead of ./.claude/skills/
+# --cursor also install the Cursor rule into .cursor/rules/
+# --ref REF install from a branch or tag instead of main
+#
+# POSIX sh on purpose: it runs under dash, ash and busybox, which is what a slim CI image
+# gives you.
+
+set -eu
+
+REPO="StackGuardian/tirith"
+REF="main"
+PACK=".claude/skills/tirith-policies"
+DEST="."
+CURSOR=0
+
+REFERENCES="schema validate verdicts terraform-plan other-providers variables install pipelines platform debug-ci"
+
+while [ $# -gt 0 ]; do
+ case "$1" in
+ --global) DEST="$HOME" ;;
+ --cursor) CURSOR=1 ;;
+ --ref) REF="${2:?--ref needs a branch or tag}"; shift ;;
+ -h|--help)
+ sed -n '3,25p' "$0" 2>/dev/null | sed 's/^# \{0,1\}//'
+ exit 0
+ ;;
+ *) printf 'skill.sh: unknown option %s\n' "$1" >&2; exit 2 ;;
+ esac
+ shift
+done
+
+command -v curl >/dev/null 2>&1 || { echo "skill.sh: curl is required" >&2; exit 1; }
+
+BASE="https://raw.githubusercontent.com/$REPO/$REF/$PACK"
+TARGET="$DEST/$PACK"
+
+# Download to a temporary directory first, then move into place. A half-written skill is
+# worse than no skill: an agent will read whatever files exist and quietly work from a
+# partial vocabulary.
+TMP="$(mktemp -d)"
+trap 'rm -rf "$TMP"' EXIT INT TERM
+mkdir -p "$TMP/reference"
+
+fetch() {
+ curl -fsSL "$1" -o "$2" || { printf 'skill.sh: failed to download %s\n' "$1" >&2; exit 1; }
+}
+
+fetch "$BASE/SKILL.md" "$TMP/SKILL.md"
+for f in $REFERENCES; do
+ fetch "$BASE/reference/$f.md" "$TMP/reference/$f.md"
+done
+
+mkdir -p "$TARGET/reference"
+cp "$TMP/SKILL.md" "$TARGET/SKILL.md"
+for f in $REFERENCES; do
+ cp "$TMP/reference/$f.md" "$TARGET/reference/$f.md"
+done
+
+printf 'Installed the Tirith skill: %s\n' "$TARGET"
+
+if [ "$CURSOR" -eq 1 ]; then
+ mkdir -p "$DEST/.cursor/rules"
+ fetch "https://raw.githubusercontent.com/$REPO/$REF/.cursor/rules/tirith-policies.mdc" \
+ "$TMP/tirith-policies.mdc"
+ cp "$TMP/tirith-policies.mdc" "$DEST/.cursor/rules/tirith-policies.mdc"
+ printf 'Installed the Cursor rule: %s\n' "$DEST/.cursor/rules/tirith-policies.mdc"
+fi
+
+printf 'Ask your agent to write a Tirith policy. It should name real condition types.\n'
From c0adae5a2905465ccabd04069a190c8a46d90ea4 Mon Sep 17 00:00:00 2001
From: Akash S
Date: Fri, 4 Sep 2026 14:32:36 +0530
Subject: [PATCH 03/10] Ship a worked example with the skill pack, and fix
stale exit-code text
The skill's core instruction is "run the policy against a plan that should fail it",
but the pack pointed at src/tirith/tui/examples/, which does not exist in a project
that installed via skill.sh. examples/required-tags/ now ships with the pack: the
SKILL.md policy, a plan that fails it (exit 3) and one that passes (exit 0). skill.sh
downloads it alongside the markdown; all four exits in its README were run.
Exit 2 was described three ways, all stale: "declared in status.py but never returned"
(SKILL.md, Cursor rule), "platform check timeout" (verdicts.md, platform.md). status.py
no longer declares it, and a platform timeout raises CheckError, which exits 1. The
pack now says 2 is argparse's usage error and Tirith has no timeout code.
The `tirith lint` warning was explained in full in SKILL.md, schema.md and validate.md,
each with its own roadmap speculation. validate.md is now the one place; the others
are a sentence and a pointer. The "When lint ships" section is gone: a skill should
describe the present, and three copies of a forecast go wrong together.
SKILL.md is restructured around lists rather than paragraphs, with the same facts.
---
.claude/skills/tirith-policies/SKILL.md | 106 +++++++++---------
.../examples/required-tags/README.md | 27 +++++
.../examples/required-tags/policy.json | 22 ++++
.../examples/required-tags/should-fail.json | 48 ++++++++
.../examples/required-tags/should-pass.json | 53 +++++++++
.../tirith-policies/reference/pipelines.md | 5 +-
.../tirith-policies/reference/platform.md | 3 +-
.../tirith-policies/reference/schema.md | 3 +-
.../tirith-policies/reference/validate.md | 20 ++--
.../tirith-policies/reference/verdicts.md | 4 +-
.cursor/rules/tirith-policies.mdc | 6 +-
.../docs/tirith-usage/agent-skills.md | 12 +-
documentation/src/pages/skills.js | 1 +
.../static/docs/tirith-usage/agent-skills.md | 12 +-
documentation/static/llms-full.txt | 12 +-
documentation/static/skill.sh | 15 ++-
16 files changed, 253 insertions(+), 96 deletions(-)
create mode 100644 .claude/skills/tirith-policies/examples/required-tags/README.md
create mode 100644 .claude/skills/tirith-policies/examples/required-tags/policy.json
create mode 100644 .claude/skills/tirith-policies/examples/required-tags/should-fail.json
create mode 100644 .claude/skills/tirith-policies/examples/required-tags/should-pass.json
diff --git a/.claude/skills/tirith-policies/SKILL.md b/.claude/skills/tirith-policies/SKILL.md
index e0de797c..f73acd95 100644
--- a/.claude/skills/tirith-policies/SKILL.md
+++ b/.claude/skills/tirith-policies/SKILL.md
@@ -5,17 +5,14 @@ description: Write, validate, run and debug Tirith IaC governance policies, inst
# Tirith
-Tirith evaluates the plan a pipeline already produces against declarative JSON policies, and
-exits non-zero so a violating change never reaches `apply`.
+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.
-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
-## 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:
+Not from PyPI. `pip install tirith` installs an **unrelated project**; `py-tirith` is the
+`setup.py` name and is not published. Install from git, pinned to a tag:
```bash
pip install "git+https://github.com/StackGuardian/tirith.git@1.2.0"
@@ -25,15 +22,14 @@ pip install "git+https://github.com/StackGuardian/tirith.git@1.2.0"
**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.
+A policy that matches nothing looks identical to one that works. Run it against input you expect
+to be refused. Exit `0` means it matched nothing and gates nothing.
```bash
-tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error
+tirith -policy-path .tirith/policies -input-path should-fail.json --fail-on-error; echo $? # want 3
```
-## Exit codes are a contract
+## Exit codes
| Exit | Meaning | What CI should do |
| --- | --- | --- |
@@ -41,27 +37,23 @@ tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error
| `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`.
+- Never collapse `3` into `1`. A job that cannot tell them apart reports an outage as a violation.
+- `2` is argparse's usage error, not a Tirith verdict. There is no timeout code; a platform
+ timeout exits `1`.
+- **`final_result: null` is not a pass.** Every check was skipped, nothing was evaluated, exit `1`.
+- Without `--fail-on-error` the exit is always `0`. Every real gate needs the flag.
## Write a policy
-Work in this order. Guessing any of the four is the main source of silently-broken policies.
+Decide these four in order. Guessing any of them 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`.
+1. **Document.** Terraform or OpenTofu plan, Kubernetes manifest, Infracost breakdown, or arbitrary
+ JSON or YAML. This fixes `meta.required_provider`. Five providers ship; there is **no
+ CloudFormation provider**, a template is arbitrary JSON read by `stackguardian/json`.
+2. **Operation.** Each provider exposes a closed set: `reference/schema.md`.
+3. **Key naming the value.** It differs per provider, and a wrong key is *ignored, not rejected*,
+ so the check reads nothing and passes: `reference/schema.md`.
+4. **Condition.** Thirteen, listed in `reference/schema.md`. There is no `Exists`.
```json
{
@@ -85,44 +77,49 @@ Work in this order. Guessing any of the four is the main source of silently-brok
```
`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.
+it never names cannot affect the verdict. `!` is the only negation: write the positive detector
+and invert it.
-## Four traps that cost the most time
+## Traps
-**`error_tolerance` goes inside `condition`, not on the evaluator.** On the evaluator it is
-silently ignored: no warning, and the check still fails.
+- **`error_tolerance` goes inside `condition`.** On the evaluator it is silently ignored.
+ `{"condition": {"type": "IsNotEmpty", "error_tolerance": 2}}`
+- **One evaluator, one result per matching resource.** Three buckets give three results from one
+ rule, and the check fails if any of them fails.
+- **Missing attribute is severity 2, missing resource type is severity 1.** With
+ `error_tolerance: 2` a resource lacking the attribute is *skipped*, not failed, which can turn
+ the whole policy into `final_result: null`. Skipping is not passing.
+- **An unknown `condition.type` fails with no error attached.** It exits `3` and reads like a
+ real violation. Check the type against the closed list, not your memory.
+- **`tirith lint` does not ship.** Do not put it in a pipeline. `reference/validate.md` has
+ what to do instead.
-```json
-{"condition": {"type": "IsNotEmpty", "error_tolerance": 2}}
-```
+## Test it with the bundled example
-**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.
+`examples/required-tags/` holds the policy above, a plan that violates it and one that satisfies
+it. Copy the pair and edit it when testing a new policy.
-**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`.
+```bash
+cd examples/required-tags
+tirith -policy-path policy.json -input-path should-fail.json --fail-on-error; echo $? # 3
+tirith -policy-path policy.json -input-path should-pass.json --fail-on-error; echo $? # 0
+```
## Before you hand it back
1. Does `eval_expression` reference every evaluator you wrote?
2. Is every `condition.type` in the closed list of thirteen?
-3. Is `error_tolerance`, if used, inside `condition`?
-4. Did you **run it** against a document that should fail it, and did it exit `3`?
+3. Is the argument key the one this provider reads?
+4. Is `error_tolerance`, if used, inside `condition`?
+5. Did you **run it** against a document that should fail it, and did it exit `3`?
+6. Did you run it against a document that should pass, and did it exit `0`?
## Reference
| File | Use it for |
| --- | --- |
| `reference/schema.md` | The closed vocabulary: conditions, providers, operations, argument keys |
-| `reference/validate.md` | Checking a policy is well-formed, and the traps to check by hand |
+| `reference/validate.md` | Checking a policy is well-formed, and why `tirith lint` is not the way |
| `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 |
@@ -131,5 +128,4 @@ the policy. See `reference/validate.md`.
| `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.
+| `examples/required-tags/` | A policy, a plan that fails it, and a plan that passes it |
diff --git a/.claude/skills/tirith-policies/examples/required-tags/README.md b/.claude/skills/tirith-policies/examples/required-tags/README.md
new file mode 100644
index 00000000..37eff391
--- /dev/null
+++ b/.claude/skills/tirith-policies/examples/required-tags/README.md
@@ -0,0 +1,27 @@
+# Worked example: every resource carries a costcenter tag
+
+One policy and two plans. Use it to confirm Tirith is installed and to see what a working
+policy looks like before writing your own.
+
+| File | |
+| --- | --- |
+| `policy.json` | `IsNotEmpty` on `tags.costcenter` across every resource type |
+| `should-fail.json` | Two resources, one without the tag. Expect exit `3` |
+| `should-pass.json` | The same plan with both resources tagged. Expect exit `0` |
+
+```bash
+cd .claude/skills/tirith-policies/examples/required-tags
+tirith -policy-path policy.json -input-path should-fail.json --fail-on-error; echo "exit: $?" # 3
+tirith -policy-path policy.json -input-path should-pass.json --fail-on-error; echo "exit: $?" # 0
+```
+
+Copy the pair when testing a new policy: edit `policy.json` to the rule you want, then change
+`should-fail.json` until it violates it. A rule that has only ever been seen passing is untested.
+
+Things to try:
+
+- Add `"error_tolerance": 2` inside `condition` and run against `should-fail.json`. The check is
+ skipped rather than failed, `final_result` becomes `null`, and the exit is `1`, not `0`.
+- Change `IsNotEmpty` to `Equals` with `"value": "product-123"` to pin one exact value.
+- Change the type to `Exists`. It does not exist, and the run exits `3` with no error attached,
+ which is why an unknown condition type reads like a real violation.
diff --git a/.claude/skills/tirith-policies/examples/required-tags/policy.json b/.claude/skills/tirith-policies/examples/required-tags/policy.json
new file mode 100644
index 00000000..daa3485b
--- /dev/null
+++ b/.claude/skills/tirith-policies/examples/required-tags/policy.json
@@ -0,0 +1,22 @@
+{
+ "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"
+}
diff --git a/.claude/skills/tirith-policies/examples/required-tags/should-fail.json b/.claude/skills/tirith-policies/examples/required-tags/should-fail.json
new file mode 100644
index 00000000..01f573e4
--- /dev/null
+++ b/.claude/skills/tirith-policies/examples/required-tags/should-fail.json
@@ -0,0 +1,48 @@
+{
+ "format_version": "1.2",
+ "terraform_version": "1.5.7",
+ "resource_changes": [
+ {
+ "address": "aws_instance.web",
+ "mode": "managed",
+ "type": "aws_instance",
+ "name": "web",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": ["create"],
+ "before": null,
+ "after": {
+ "instance_type": "t3.micro",
+ "tags": {
+ "Name": "web-server",
+ "costcenter": "product-123"
+ }
+ },
+ "after_unknown": {
+ "arn": true,
+ "id": true
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.assets",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "assets",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": ["create"],
+ "before": null,
+ "after": {
+ "bucket": "example-assets",
+ "tags": {
+ "Name": "assets"
+ }
+ },
+ "after_unknown": {
+ "arn": true
+ }
+ }
+ }
+ ]
+}
diff --git a/.claude/skills/tirith-policies/examples/required-tags/should-pass.json b/.claude/skills/tirith-policies/examples/required-tags/should-pass.json
new file mode 100644
index 00000000..5e5a5e2b
--- /dev/null
+++ b/.claude/skills/tirith-policies/examples/required-tags/should-pass.json
@@ -0,0 +1,53 @@
+{
+ "format_version": "1.2",
+ "terraform_version": "1.5.7",
+ "resource_changes": [
+ {
+ "address": "aws_instance.web",
+ "mode": "managed",
+ "type": "aws_instance",
+ "name": "web",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "instance_type": "t3.micro",
+ "tags": {
+ "Name": "web-server",
+ "costcenter": "product-123"
+ }
+ },
+ "after_unknown": {
+ "arn": true,
+ "id": true
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.assets",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "assets",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "example-assets",
+ "tags": {
+ "Name": "assets",
+ "costcenter": "product-123"
+ }
+ },
+ "after_unknown": {
+ "arn": true
+ }
+ }
+ }
+ ]
+}
diff --git a/.claude/skills/tirith-policies/reference/pipelines.md b/.claude/skills/tirith-policies/reference/pipelines.md
index f118c995..9139298b 100644
--- a/.claude/skills/tirith-policies/reference/pipelines.md
+++ b/.claude/skills/tirith-policies/reference/pipelines.md
@@ -207,6 +207,5 @@ 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.
+A pre-commit hook and an editor loop depend on `tirith lint`, which does not ship. See
+`reference/validate.md`.
diff --git a/.claude/skills/tirith-policies/reference/platform.md b/.claude/skills/tirith-policies/reference/platform.md
index cd06856c..b3d197ed 100644
--- a/.claude/skills/tirith-policies/reference/platform.md
+++ b/.claude/skills/tirith-policies/reference/platform.md
@@ -45,8 +45,7 @@ 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 |
+| `1` | Could not reach a verdict — bad input, unreachable API, or the run timed out |
| `3` | A policy failed (with `--fail-on-error`) |
## Credentials
diff --git a/.claude/skills/tirith-policies/reference/schema.md b/.claude/skills/tirith-policies/reference/schema.md
index 44e9caf9..36de3c5b 100644
--- a/.claude/skills/tirith-policies/reference/schema.md
+++ b/.claude/skills/tirith-policies/reference/schema.md
@@ -4,8 +4,7 @@ Both registries are closed. Inventing a value does not raise an error: an unknow
`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:
+Confirm against the live registry rather than this file. It is the source of truth:
```bash
python -c "from tirith.core.evaluators import EVALUATORS_DICT; print(sorted(EVALUATORS_DICT))"
diff --git a/.claude/skills/tirith-policies/reference/validate.md b/.claude/skills/tirith-policies/reference/validate.md
index 9f5c599b..2ff2f6a1 100644
--- a/.claude/skills/tirith-policies/reference/validate.md
+++ b/.claude/skills/tirith-policies/reference/validate.md
@@ -2,11 +2,12 @@
## `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.
+This is the one place in the pack that explains it; the other files point here. The released CLI
+dispatches `tirith`, `tirith ui` and `tirith platform check` and nothing else, so `tirith lint`
+in a pipeline is a step that fails with an unrecognised argument. A linter is on the roadmap at
+`https://stackguardian.github.io/tirith/roadmap/`; do not assume it has shipped.
-Until it ships there are two ways to validate, and both are available today.
+Two ways to validate exist today.
## The interactive validator does ship
@@ -57,6 +58,9 @@ tirith -policy-path .tirith/policies -input-path should-fail.json --fail-on-erro
echo "exit: $?"
```
+`examples/required-tags/` in this pack has a policy with a failing and a passing plan. Copy the
+pair and edit it rather than starting from an empty file.
+
| Exit | Reading |
| --- | --- |
| `3` | The policy works. It refused a change it was supposed to refuse. |
@@ -75,11 +79,3 @@ 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/verdicts.md b/.claude/skills/tirith-policies/reference/verdicts.md
index 4d1ea67a..eef3c642 100644
--- a/.claude/skills/tirith-policies/reference/verdicts.md
+++ b/.claude/skills/tirith-policies/reference/verdicts.md
@@ -14,10 +14,12 @@ Add `--json` to get the result document instead of the pretty printer.
| --- | --- |
| `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 |
+`2` is argparse's usage error, so a caller seeing `2` passed a bad argument. Tirith has no timeout
+code: a `platform check` that times out is reported as `1`.
+
**`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.
diff --git a/.cursor/rules/tirith-policies.mdc b/.cursor/rules/tirith-policies.mdc
index 120d4676..823565b1 100644
--- a/.cursor/rules/tirith-policies.mdc
+++ b/.cursor/rules/tirith-policies.mdc
@@ -34,8 +34,8 @@ rejects those with `Unsupported operator in eval_expression`. `!` is the only ne
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
+**Verdicts:** exit `0` passed · `3` a policy failed · `1` no verdict was reached. (`2` is argparse's usage
+error, not a verdict.) `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:**
@@ -106,7 +106,7 @@ between them as an artifact:
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:
+Worked example, if the repository has it: `.claude/skills/tirith-policies/examples/required-tags/`. Deeper reference:
`.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/documentation/docs/tirith-usage/agent-skills.md b/documentation/docs/tirith-usage/agent-skills.md
index 33f32c61..9a7c6107 100644
--- a/documentation/docs/tirith-usage/agent-skills.md
+++ b/documentation/docs/tirith-usage/agent-skills.md
@@ -31,8 +31,8 @@ guess from a plausible-looking shape.
curl -fsSL https://stackguardian.github.io/tirith/skill.sh | sh
```
-Eleven markdown files under `.claude/skills/tirith-policies/`. No config file, no restart, and it
-is picked up in any repository you copy it into.
+Eleven markdown files and one worked example under `.claude/skills/tirith-policies/`. No config
+file, no restart, and it is picked up in any repository you copy it into.
| Flag | |
|---|---|
@@ -41,7 +41,7 @@ is picked up in any repository you copy it into.
| `--ref REF` | Install from a branch or tag instead of `main` |
| `--help` | The same summary, from the script itself |
-The script downloads eleven markdown files and does nothing else: no package is installed, no
+The script downloads those files and does nothing else: no package is installed, no
`PATH` is changed, nothing is executed after the download, and it never touches a file it did not
create. It downloads to a temporary directory and moves the files into place only once all of them
have arrived, because a half-written skill is worse than none: an agent reads whatever files exist
@@ -94,6 +94,7 @@ with a small context window pays for only what the task needs.
| `reference/pipelines.md` | Adding the gate to six CI platforms |
| `reference/platform.md` | Evaluating an organization's policies |
| `reference/debug-ci.md` | Diagnosing a red check |
+| `examples/required-tags/` | A policy, a plan that fails it and a plan that passes it, so the agent can prove its own work before it hands it back |
## Two things decide whether the policy actually works
@@ -105,7 +106,8 @@ the policy:
[Quick Installation](../tirith-installation/quick-intallation.md).
2. **Give it a document that should fail.** Ask for the policy *and* a plan that violates it, then
check the exit code is `3`. If it is `0`, the policy matched nothing, which is the failure this
- whole page exists to prevent. See [Exit codes](exit-codes.md).
+ whole page exists to prevent. The pack ships a starting pair in `examples/required-tags/`.
+ See [Exit codes](exit-codes.md).
## Keeping it current
@@ -116,7 +118,7 @@ version:
curl -fsSL https://stackguardian.github.io/tirith/skill.sh | sh
```
-Re-running is safe: it overwrites the eleven files it owns and leaves everything else alone.
+Re-running is safe: it overwrites the files it owns and leaves everything else alone.
`--ref` takes a branch or a commit, which is worth knowing for a fork or a pull request. It cannot
yet take a release tag: the pack was added after `1.2.0`, so `main` is the only ref that has it,
diff --git a/documentation/src/pages/skills.js b/documentation/src/pages/skills.js
index 4ce1b5d8..62787f62 100644
--- a/documentation/src/pages/skills.js
+++ b/documentation/src/pages/skills.js
@@ -86,6 +86,7 @@ const SKILLS = [
['The schema', 'reference/schema.md', 'The closed vocabulary. Thirteen condition types, each provider’s operations, and the argument key that differs per provider, which is the one an agent otherwise invents.'],
['Validate it', 'reference/validate.md', 'The trap classes that produce a policy which looks right and gates nothing, and why a clean shape is not a working rule. tirith lint is in development; tirith ui validates against the live registries today.'],
['Run it and read the verdict', 'reference/verdicts.md', 'Exit 0, 1 and 3 and what each should do to a job, why final_result: null is not a pass, and how to find the resource behind a failure.'],
+ ['Prove it works', 'examples/required-tags/', 'A policy, a plan that fails it and a plan that passes it. The agent runs both before it hands anything back, because a rule only ever seen passing is untested.'],
],
},
{
diff --git a/documentation/static/docs/tirith-usage/agent-skills.md b/documentation/static/docs/tirith-usage/agent-skills.md
index 7afaa885..d113f4d8 100644
--- a/documentation/static/docs/tirith-usage/agent-skills.md
+++ b/documentation/static/docs/tirith-usage/agent-skills.md
@@ -19,8 +19,8 @@ guess from a plausible-looking shape.
curl -fsSL https://stackguardian.github.io/tirith/skill.sh | sh
```
-Eleven markdown files under `.claude/skills/tirith-policies/`. No config file, no restart, and it
-is picked up in any repository you copy it into.
+Eleven markdown files and one worked example under `.claude/skills/tirith-policies/`. No config
+file, no restart, and it is picked up in any repository you copy it into.
| Flag | |
|---|---|
@@ -29,7 +29,7 @@ is picked up in any repository you copy it into.
| `--ref REF` | Install from a branch or tag instead of `main` |
| `--help` | The same summary, from the script itself |
-The script downloads eleven markdown files and does nothing else: no package is installed, no
+The script downloads those files and does nothing else: no package is installed, no
`PATH` is changed, nothing is executed after the download, and it never touches a file it did not
create. It downloads to a temporary directory and moves the files into place only once all of them
have arrived, because a half-written skill is worse than none: an agent reads whatever files exist
@@ -82,6 +82,7 @@ with a small context window pays for only what the task needs.
| `reference/pipelines.md` | Adding the gate to six CI platforms |
| `reference/platform.md` | Evaluating an organization's policies |
| `reference/debug-ci.md` | Diagnosing a red check |
+| `examples/required-tags/` | A policy, a plan that fails it and a plan that passes it, so the agent can prove its own work before it hands it back |
## Two things decide whether the policy actually works
@@ -93,7 +94,8 @@ the policy:
[Quick Installation](../tirith-installation/quick-intallation.md).
2. **Give it a document that should fail.** Ask for the policy *and* a plan that violates it, then
check the exit code is `3`. If it is `0`, the policy matched nothing, which is the failure this
- whole page exists to prevent. See [Exit codes](exit-codes.md).
+ whole page exists to prevent. The pack ships a starting pair in `examples/required-tags/`.
+ See [Exit codes](exit-codes.md).
## Keeping it current
@@ -104,7 +106,7 @@ version:
curl -fsSL https://stackguardian.github.io/tirith/skill.sh | sh
```
-Re-running is safe: it overwrites the eleven files it owns and leaves everything else alone.
+Re-running is safe: it overwrites the files it owns and leaves everything else alone.
`--ref` takes a branch or a commit, which is worth knowing for a fork or a pull request. It cannot
yet take a release tag: the pack was added after `1.2.0`, so `main` is the only ref that has it,
diff --git a/documentation/static/llms-full.txt b/documentation/static/llms-full.txt
index 4d3f63ef..037a0765 100644
--- a/documentation/static/llms-full.txt
+++ b/documentation/static/llms-full.txt
@@ -3427,8 +3427,8 @@ guess from a plausible-looking shape.
curl -fsSL https://stackguardian.github.io/tirith/skill.sh | sh
```
-Eleven markdown files under `.claude/skills/tirith-policies/`. No config file, no restart, and it
-is picked up in any repository you copy it into.
+Eleven markdown files and one worked example under `.claude/skills/tirith-policies/`. No config
+file, no restart, and it is picked up in any repository you copy it into.
| Flag | |
|---|---|
@@ -3437,7 +3437,7 @@ is picked up in any repository you copy it into.
| `--ref REF` | Install from a branch or tag instead of `main` |
| `--help` | The same summary, from the script itself |
-The script downloads eleven markdown files and does nothing else: no package is installed, no
+The script downloads those files and does nothing else: no package is installed, no
`PATH` is changed, nothing is executed after the download, and it never touches a file it did not
create. It downloads to a temporary directory and moves the files into place only once all of them
have arrived, because a half-written skill is worse than none: an agent reads whatever files exist
@@ -3490,6 +3490,7 @@ with a small context window pays for only what the task needs.
| `reference/pipelines.md` | Adding the gate to six CI platforms |
| `reference/platform.md` | Evaluating an organization's policies |
| `reference/debug-ci.md` | Diagnosing a red check |
+| `examples/required-tags/` | A policy, a plan that fails it and a plan that passes it, so the agent can prove its own work before it hands it back |
## Two things decide whether the policy actually works
@@ -3501,7 +3502,8 @@ the policy:
[Quick Installation](../tirith-installation/quick-intallation.md).
2. **Give it a document that should fail.** Ask for the policy *and* a plan that violates it, then
check the exit code is `3`. If it is `0`, the policy matched nothing, which is the failure this
- whole page exists to prevent. See [Exit codes](exit-codes.md).
+ whole page exists to prevent. The pack ships a starting pair in `examples/required-tags/`.
+ See [Exit codes](exit-codes.md).
## Keeping it current
@@ -3512,7 +3514,7 @@ version:
curl -fsSL https://stackguardian.github.io/tirith/skill.sh | sh
```
-Re-running is safe: it overwrites the eleven files it owns and leaves everything else alone.
+Re-running is safe: it overwrites the files it owns and leaves everything else alone.
`--ref` takes a branch or a commit, which is worth knowing for a fork or a pull request. It cannot
yet take a release tag: the pack was added after `1.2.0`, so `main` is the only ref that has it,
diff --git a/documentation/static/skill.sh b/documentation/static/skill.sh
index d2fe063f..92d5b8c2 100644
--- a/documentation/static/skill.sh
+++ b/documentation/static/skill.sh
@@ -8,7 +8,8 @@
# its source is documentation/static/skill.sh in StackGuardian/tirith, so the version you
# are about to pipe into a shell is the version you can read in the repository.
#
-# What it does: downloads eleven markdown files into .claude/skills/tirith-policies/ and,
+# What it does: downloads the skill into .claude/skills/tirith-policies/ -- eleven markdown
+# files plus a worked example (a policy, a plan that fails it, a plan that passes it) -- and,
# with --cursor, one rule file into .cursor/rules/. It creates directories, writes those
# files, and nothing else. No package is installed, no PATH is changed, nothing is executed
# after download, and it never touches a file it did not create.
@@ -30,6 +31,8 @@ DEST="."
CURSOR=0
REFERENCES="schema validate verdicts terraform-plan other-providers variables install pipelines platform debug-ci"
+EXAMPLE="examples/required-tags"
+EXAMPLE_FILES="README.md policy.json should-fail.json should-pass.json"
while [ $# -gt 0 ]; do
case "$1" in
@@ -55,7 +58,7 @@ TARGET="$DEST/$PACK"
# partial vocabulary.
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT INT TERM
-mkdir -p "$TMP/reference"
+mkdir -p "$TMP/reference" "$TMP/$EXAMPLE"
fetch() {
curl -fsSL "$1" -o "$2" || { printf 'skill.sh: failed to download %s\n' "$1" >&2; exit 1; }
@@ -65,12 +68,18 @@ fetch "$BASE/SKILL.md" "$TMP/SKILL.md"
for f in $REFERENCES; do
fetch "$BASE/reference/$f.md" "$TMP/reference/$f.md"
done
+for f in $EXAMPLE_FILES; do
+ fetch "$BASE/$EXAMPLE/$f" "$TMP/$EXAMPLE/$f"
+done
-mkdir -p "$TARGET/reference"
+mkdir -p "$TARGET/reference" "$TARGET/$EXAMPLE"
cp "$TMP/SKILL.md" "$TARGET/SKILL.md"
for f in $REFERENCES; do
cp "$TMP/reference/$f.md" "$TARGET/reference/$f.md"
done
+for f in $EXAMPLE_FILES; do
+ cp "$TMP/$EXAMPLE/$f" "$TARGET/$EXAMPLE/$f"
+done
printf 'Installed the Tirith skill: %s\n' "$TARGET"
From 6370783575a9e1f54df7d891ebfed482e9707bda Mon Sep 17 00:00:00 2001
From: Akash S
Date: Fri, 4 Sep 2026 15:27:34 +0530
Subject: [PATCH 04/10] Add tirith-migrate: a skill for translating Sentinel
policies into Tirith
A separate skill rather than a reference inside tirith-policies, because the shape is the same
for every source language (inventory, classify, translate, verify, report) and only the mapping
tables differ. Sentinel is the first; Checkov and Rego slot in as further reference files.
The mapping is measured, not guessed. All 110 policies in hashicorp/terraform-sentinel-policies
and the CIS AWS policy set were classified: 41 translate exactly, 40 approximately, 29 not at
all. The full table ships as reference/sentinel-corpus.md so an agent can look a public policy
up instead of re-deriving it. The two dominant losses are per-block conjunction (issue #316)
and instance-level pairing across resources; tfconfig-only policies account for most of the
cloud-agnostic set.
Five worked examples, one per fidelity story, each verified against the engine. Approximate
translations ship diverges.json, a plan where Sentinel and Tirith disagree, because a fidelity
note nobody can run is a fidelity note nobody reads. Three engine behaviours the translation
tables depend on were verified rather than assumed: Contains on a map tests keys; a
present-but-null attribute is evaluated, not skipped; and the action operation emits one result
per action, so a destroy guard does not fire on a replacement.
skill.sh installs both skills unconditionally. All downloads still complete before anything is
copied into place, so a failed fetch of either pack installs neither.
---
.claude/skills/tirith-migrate/SKILL.md | 88 ++++++++++++
.../examples/sentinel/README.md | 13 ++
.../examples/sentinel/mandatory-tags/notes.md | 14 ++
.../sentinel/mandatory-tags/policy.json | 18 +++
.../sentinel/mandatory-tags/should-fail.json | 49 +++++++
.../sentinel/mandatory-tags/should-pass.json | 50 +++++++
.../sentinel/mandatory-tags/source.sentinel | 19 +++
.../prevent-database-destroy/diverges.json | 28 ++++
.../prevent-database-destroy/notes.md | 15 +++
.../prevent-database-destroy/policy.json | 16 +++
.../prevent-database-destroy/should-fail.json | 24 ++++
.../prevent-database-destroy/should-pass.json | 29 ++++
.../prevent-database-destroy/source.sentinel | 7 +
.../require-private-registry-modules/notes.md | 14 ++
.../source.sentinel | 10 ++
.../sentinel/restrict-instance-type/notes.md | 13 ++
.../restrict-instance-type/policy.json | 20 +++
.../restrict-instance-type/should-fail.json | 46 +++++++
.../restrict-instance-type/should-pass.json | 46 +++++++
.../restrict-instance-type/source.sentinel | 12 ++
.../restrict-instance-type/variables.json | 1 +
.../restrict-ssh-ingress/diverges.json | 44 ++++++
.../sentinel/restrict-ssh-ingress/notes.md | 17 +++
.../sentinel/restrict-ssh-ingress/policy.json | 20 +++
.../restrict-ssh-ingress/should-fail.json | 35 +++++
.../restrict-ssh-ingress/should-pass.json | 44 ++++++
.../restrict-ssh-ingress/source.sentinel | 13 ++
.../reference/sentinel-corpus.md | 118 ++++++++++++++++
.../tirith-migrate/reference/sentinel.md | 126 ++++++++++++++++++
.claude/skills/tirith-policies/SKILL.md | 3 +
.../docs/tirith-usage/agent-skills.md | 16 ++-
.../static/docs/tirith-usage/agent-skills.md | 16 ++-
documentation/static/llms-full.txt | 16 ++-
documentation/static/skill.sh | 52 +++++++-
34 files changed, 1039 insertions(+), 13 deletions(-)
create mode 100644 .claude/skills/tirith-migrate/SKILL.md
create mode 100644 .claude/skills/tirith-migrate/examples/sentinel/README.md
create mode 100644 .claude/skills/tirith-migrate/examples/sentinel/mandatory-tags/notes.md
create mode 100644 .claude/skills/tirith-migrate/examples/sentinel/mandatory-tags/policy.json
create mode 100644 .claude/skills/tirith-migrate/examples/sentinel/mandatory-tags/should-fail.json
create mode 100644 .claude/skills/tirith-migrate/examples/sentinel/mandatory-tags/should-pass.json
create mode 100644 .claude/skills/tirith-migrate/examples/sentinel/mandatory-tags/source.sentinel
create mode 100644 .claude/skills/tirith-migrate/examples/sentinel/prevent-database-destroy/diverges.json
create mode 100644 .claude/skills/tirith-migrate/examples/sentinel/prevent-database-destroy/notes.md
create mode 100644 .claude/skills/tirith-migrate/examples/sentinel/prevent-database-destroy/policy.json
create mode 100644 .claude/skills/tirith-migrate/examples/sentinel/prevent-database-destroy/should-fail.json
create mode 100644 .claude/skills/tirith-migrate/examples/sentinel/prevent-database-destroy/should-pass.json
create mode 100644 .claude/skills/tirith-migrate/examples/sentinel/prevent-database-destroy/source.sentinel
create mode 100644 .claude/skills/tirith-migrate/examples/sentinel/require-private-registry-modules/notes.md
create mode 100644 .claude/skills/tirith-migrate/examples/sentinel/require-private-registry-modules/source.sentinel
create mode 100644 .claude/skills/tirith-migrate/examples/sentinel/restrict-instance-type/notes.md
create mode 100644 .claude/skills/tirith-migrate/examples/sentinel/restrict-instance-type/policy.json
create mode 100644 .claude/skills/tirith-migrate/examples/sentinel/restrict-instance-type/should-fail.json
create mode 100644 .claude/skills/tirith-migrate/examples/sentinel/restrict-instance-type/should-pass.json
create mode 100644 .claude/skills/tirith-migrate/examples/sentinel/restrict-instance-type/source.sentinel
create mode 100644 .claude/skills/tirith-migrate/examples/sentinel/restrict-instance-type/variables.json
create mode 100644 .claude/skills/tirith-migrate/examples/sentinel/restrict-ssh-ingress/diverges.json
create mode 100644 .claude/skills/tirith-migrate/examples/sentinel/restrict-ssh-ingress/notes.md
create mode 100644 .claude/skills/tirith-migrate/examples/sentinel/restrict-ssh-ingress/policy.json
create mode 100644 .claude/skills/tirith-migrate/examples/sentinel/restrict-ssh-ingress/should-fail.json
create mode 100644 .claude/skills/tirith-migrate/examples/sentinel/restrict-ssh-ingress/should-pass.json
create mode 100644 .claude/skills/tirith-migrate/examples/sentinel/restrict-ssh-ingress/source.sentinel
create mode 100644 .claude/skills/tirith-migrate/reference/sentinel-corpus.md
create mode 100644 .claude/skills/tirith-migrate/reference/sentinel.md
diff --git a/.claude/skills/tirith-migrate/SKILL.md b/.claude/skills/tirith-migrate/SKILL.md
new file mode 100644
index 00000000..9ee1c8ba
--- /dev/null
+++ b/.claude/skills/tirith-migrate/SKILL.md
@@ -0,0 +1,88 @@
+---
+name: tirith-migrate
+description: Translate existing policy-as-code into Tirith policies. HashiCorp Sentinel today; Checkov, OPA/Rego and conftest are planned. Use when asked to migrate, convert, port or translate policies to Tirith, when a repository contains .sentinel files or a sentinel.hcl, or when asked what a Sentinel policy would look like in Tirith. Requires the tirith-policies skill for the target vocabulary.
+---
+
+# Migrate policies to Tirith
+
+A migration is a projection from a larger language onto a smaller one. Sentinel and Rego are
+programs; a Tirith policy is JSON that names a provider, a value, and a condition. Most real
+policies fit. Some do not, and the failure mode is quiet: a translation that parses, looks right,
+and gates nothing. **This skill exists to say which is which before any JSON is written.**
+
+## Vocabulary comes from `tirith-policies`
+
+Do not translate from memory. Read `../tirith-policies/reference/schema.md` for the closed list of
+providers, operations, argument keys and the thirteen condition types. If that skill is not
+installed, fetch `https://stackguardian.github.io/tirith/llms.txt` and follow it to the schema
+page. Everything below assumes that vocabulary.
+
+## Per-source references
+
+| Source | Reference | Status |
+| --- | --- | --- |
+| HashiCorp Sentinel | `reference/sentinel.md`, corpus in `reference/sentinel-corpus.md` | Measured against 110 public policies |
+| Checkov | | Planned |
+| OPA / Rego, conftest | | Planned |
+
+## The protocol
+
+1. **Inventory.** List every source policy. Read the policy-set manifest (`sentinel.hcl`) for
+ enforcement levels and parameters. Note which policies are registered twice with different
+ parameters; they translate once.
+2. **Classify before translating.** For each policy, name its pattern from the source reference
+ and assign a fidelity:
+ - `exact`: a Tirith policy returns the same verdict on every plan.
+ - `approximate`: expressible, but stricter or looser in a case you can name.
+ - `not expressible`: needs something Tirith lacks. Name it, and link the tracking issue.
+3. **Translate `exact` and `approximate`.** Carry `meta.name` from the source policy name, put the
+ Sentinel enforcement level in `meta.enforcement`, and map every `param` to `{{ var.NAME }}`.
+4. **Refuse `not expressible` in words.** Write what the policy does, what Tirith cannot see, and
+ the issue that would change that. Do not write a policy that checks something adjacent.
+5. **Verify every translation against the source's own tests.** Sentinel policies ship mocks under
+ `test//`. Transcribe the failing mock into `should-fail.json` and the passing one into
+ `should-pass.json` (the mocks already have the `resource_changes` shape). Run both:
+ ```bash
+ tirith -policy-path policy.json -input-path should-fail.json --fail-on-error; echo $? # 3
+ tirith -policy-path policy.json -input-path should-pass.json --fail-on-error; echo $? # 0
+ ```
+ For an `approximate` translation, also write `diverges.json`: a plan where the source and the
+ translation disagree. The reviewer needs to see the divergence, not read about it.
+6. **Hand back a report**, one row per source policy: name, fidelity, Tirith file, and one line
+ on what changed. Fidelity is the column the reader looks at first.
+
+## Rules that hold for every source
+
+- A Tirith evaluator yields one result per matching resource and fails if any fails. That is the
+ universal quantifier. There is no existential: "at least one resource satisfies X" does not map.
+- `eval_expression` combines evaluator verdicts, each already collapsed across all resources. It
+ cannot bind two tests to the same resource or the same nested block. "Where type is ingress,
+ cidr must not be open" becomes "no block may have cidr open", which is stricter. Say so.
+- `attribute` reads `change.after` only. Anything about the previous value, a destroyed resource,
+ or a value unknown until apply is invisible.
+- Configuration is not the plan. Module sources, variables, outputs, provisioners and expression
+ references live in `tfconfig`; Tirith reads none of them.
+- A plan that destroys a resource of the checked type currently turns the evaluator's verdict to
+ `null` (Tirith issue #293). Test with plans that do not destroy, and warn the reader.
+
+## Before you hand it back
+
+1. Did every policy get a fidelity before it got JSON?
+2. Does every `approximate` row name the case where verdicts differ, and ship `diverges.json`?
+3. Does every `not expressible` row link a Tirith issue or say "not tracked"?
+4. Did every translated policy exit `3` on `should-fail.json` and `0` on `should-pass.json`?
+5. Is every `param` a `{{ var.NAME }}` with a `variables.json` beside the policy?
+6. Is every condition type and argument key taken from `schema.md`, not recalled?
+
+## Worked examples
+
+`examples/sentinel/` holds five translations from the idioms of HashiCorp's public policy
+libraries, each with its Sentinel source, the Tirith policy, and the plans that prove it:
+
+| Example | Fidelity | Shows |
+| --- | --- | --- |
+| `restrict-instance-type` | exact | `filter_attribute_not_in_list` to `ContainedIn`; `param` to `-var` |
+| `mandatory-tags` | exact | Tag keys via `Contains` on the map; one evaluator per key and type |
+| `prevent-database-destroy` | approximate | `action` catches a delete but not a replacement |
+| `restrict-ssh-ingress` | approximate | The per-block conjunction collapses to a stricter rule |
+| `require-private-registry-modules` | not expressible | A `tfconfig` policy, refused in words |
diff --git a/.claude/skills/tirith-migrate/examples/sentinel/README.md b/.claude/skills/tirith-migrate/examples/sentinel/README.md
new file mode 100644
index 00000000..b9326180
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/sentinel/README.md
@@ -0,0 +1,13 @@
+# Sentinel migrations
+
+Five translations, one per fidelity story. Each directory holds `source.sentinel`, `notes.md`,
+and where a translation exists, `policy.json` with `should-fail.json` and `should-pass.json`.
+Approximate translations add `diverges.json`, a plan where Sentinel and Tirith disagree.
+
+```bash
+cd restrict-instance-type
+tirith -policy-path policy.json -input-path should-fail.json --fail-on-error -var-path variables.json; echo $? # 3
+tirith -policy-path policy.json -input-path should-pass.json --fail-on-error -var-path variables.json; echo $? # 0
+```
+
+The Sentinel sources are short originals written in the idioms of `hashicorp/terraform-sentinel-policies`.
diff --git a/.claude/skills/tirith-migrate/examples/sentinel/mandatory-tags/notes.md b/.claude/skills/tirith-migrate/examples/sentinel/mandatory-tags/notes.md
new file mode 100644
index 00000000..dcde17d7
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/sentinel/mandatory-tags/notes.md
@@ -0,0 +1,14 @@
+# mandatory-tags: exact
+
+The Sentinel loops over two lists: resource types and tag keys. Tirith has no loops, so the product
+is written out: one evaluator per (type, key), six in all, joined with `&&`. Verbose, but exact:
+each evaluator ranges over every resource of its type, and `&&` over independently quantified
+evaluators is the Sentinel `all`.
+
+`Contains "Owner"` on the `tags` attribute tests the map's keys. Verified against the engine.
+`error_tolerance: 1` skips a type that is absent from the plan.
+
+| Plan | Sentinel | Tirith |
+| --- | --- | --- |
+| `should-fail.json` (instance lacks CostCenter) | fail | exit 3 |
+| `should-pass.json` | pass | exit 0 |
diff --git a/.claude/skills/tirith-migrate/examples/sentinel/mandatory-tags/policy.json b/.claude/skills/tirith-migrate/examples/sentinel/mandatory-tags/policy.json
new file mode 100644
index 00000000..65033f04
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/sentinel/mandatory-tags/policy.json
@@ -0,0 +1,18 @@
+{
+ "meta": {
+ "version": "v1",
+ "required_provider": "stackguardian/terraform_plan",
+ "name": "mandatory-tags",
+ "description": "aws_instance and aws_s3_bucket carry Name, Owner and CostCenter tags",
+ "enforcement": "hard-mandatory"
+ },
+ "evaluators": [
+ {"id": "instance_name", "provider_args": {"operation_type": "attribute", "terraform_resource_type": "aws_instance", "terraform_resource_attribute": "tags"}, "condition": {"type": "Contains", "value": "Name", "error_tolerance": 1}},
+ {"id": "instance_owner", "provider_args": {"operation_type": "attribute", "terraform_resource_type": "aws_instance", "terraform_resource_attribute": "tags"}, "condition": {"type": "Contains", "value": "Owner", "error_tolerance": 1}},
+ {"id": "instance_costcenter", "provider_args": {"operation_type": "attribute", "terraform_resource_type": "aws_instance", "terraform_resource_attribute": "tags"}, "condition": {"type": "Contains", "value": "CostCenter", "error_tolerance": 1}},
+ {"id": "bucket_name", "provider_args": {"operation_type": "attribute", "terraform_resource_type": "aws_s3_bucket", "terraform_resource_attribute": "tags"}, "condition": {"type": "Contains", "value": "Name", "error_tolerance": 1}},
+ {"id": "bucket_owner", "provider_args": {"operation_type": "attribute", "terraform_resource_type": "aws_s3_bucket", "terraform_resource_attribute": "tags"}, "condition": {"type": "Contains", "value": "Owner", "error_tolerance": 1}},
+ {"id": "bucket_costcenter", "provider_args": {"operation_type": "attribute", "terraform_resource_type": "aws_s3_bucket", "terraform_resource_attribute": "tags"}, "condition": {"type": "Contains", "value": "CostCenter", "error_tolerance": 1}}
+ ],
+ "eval_expression": "instance_name && instance_owner && instance_costcenter && bucket_name && bucket_owner && bucket_costcenter"
+}
diff --git a/.claude/skills/tirith-migrate/examples/sentinel/mandatory-tags/should-fail.json b/.claude/skills/tirith-migrate/examples/sentinel/mandatory-tags/should-fail.json
new file mode 100644
index 00000000..ee951b1c
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/sentinel/mandatory-tags/should-fail.json
@@ -0,0 +1,49 @@
+{
+ "format_version": "1.2",
+ "terraform_version": "1.5.7",
+ "resource_changes": [
+ {
+ "address": "aws_instance.web",
+ "mode": "managed",
+ "type": "aws_instance",
+ "name": "web",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "instance_type": "t3.micro",
+ "tags": {
+ "Name": "web",
+ "Owner": "platform"
+ }
+ },
+ "after_unknown": {}
+ }
+ },
+ {
+ "address": "aws_s3_bucket.logs",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "logs",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "logs",
+ "tags": {
+ "Name": "logs",
+ "Owner": "platform",
+ "CostCenter": "cc-42"
+ }
+ },
+ "after_unknown": {}
+ }
+ }
+ ]
+}
diff --git a/.claude/skills/tirith-migrate/examples/sentinel/mandatory-tags/should-pass.json b/.claude/skills/tirith-migrate/examples/sentinel/mandatory-tags/should-pass.json
new file mode 100644
index 00000000..0a9aeaf9
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/sentinel/mandatory-tags/should-pass.json
@@ -0,0 +1,50 @@
+{
+ "format_version": "1.2",
+ "terraform_version": "1.5.7",
+ "resource_changes": [
+ {
+ "address": "aws_instance.web",
+ "mode": "managed",
+ "type": "aws_instance",
+ "name": "web",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "instance_type": "t3.micro",
+ "tags": {
+ "Name": "web",
+ "Owner": "platform",
+ "CostCenter": "cc-42"
+ }
+ },
+ "after_unknown": {}
+ }
+ },
+ {
+ "address": "aws_s3_bucket.logs",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "logs",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "logs",
+ "tags": {
+ "Name": "logs",
+ "Owner": "platform",
+ "CostCenter": "cc-42"
+ }
+ },
+ "after_unknown": {}
+ }
+ }
+ ]
+}
diff --git a/.claude/skills/tirith-migrate/examples/sentinel/mandatory-tags/source.sentinel b/.claude/skills/tirith-migrate/examples/sentinel/mandatory-tags/source.sentinel
new file mode 100644
index 00000000..e1bedae0
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/sentinel/mandatory-tags/source.sentinel
@@ -0,0 +1,19 @@
+# Every resource of the listed types must carry every mandatory tag key. tfplan/v2.
+import "tfplan-functions" as plan
+
+param mandatory_tags default ["Name", "Owner", "CostCenter"]
+param resource_types default ["aws_instance", "aws_s3_bucket"]
+
+violations = {}
+for resource_types as type {
+ resources = plan.find_resources(type)
+ for resources as address, r {
+ tags = r.change.after.tags else {}
+ missing = filter mandatory_tags as t { t not in keys(tags) }
+ if length(missing) > 0 {
+ violations[address] = missing
+ }
+ }
+}
+
+main = rule { length(violations) is 0 }
diff --git a/.claude/skills/tirith-migrate/examples/sentinel/prevent-database-destroy/diverges.json b/.claude/skills/tirith-migrate/examples/sentinel/prevent-database-destroy/diverges.json
new file mode 100644
index 00000000..789d9940
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/sentinel/prevent-database-destroy/diverges.json
@@ -0,0 +1,28 @@
+{
+ "format_version": "1.2",
+ "terraform_version": "1.5.7",
+ "resource_changes": [
+ {
+ "address": "aws_db_instance.main",
+ "mode": "managed",
+ "type": "aws_db_instance",
+ "name": "main",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "delete",
+ "create"
+ ],
+ "before": {
+ "identifier": "main",
+ "engine": "postgres"
+ },
+ "after": {
+ "identifier": "main",
+ "engine": "postgres"
+ },
+ "after_unknown": {}
+ }
+ }
+ ]
+}
diff --git a/.claude/skills/tirith-migrate/examples/sentinel/prevent-database-destroy/notes.md b/.claude/skills/tirith-migrate/examples/sentinel/prevent-database-destroy/notes.md
new file mode 100644
index 00000000..16b8141a
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/sentinel/prevent-database-destroy/notes.md
@@ -0,0 +1,15 @@
+# prevent-database-destroy: approximate
+
+`find_resources_being_destroyed()` selects resources whose actions contain `"delete"`, which
+includes a replacement (`["delete", "create"]`). Tirith's `action` operation emits one result per
+action in the list, so `ContainedIn ["delete"]` on a replacement yields one pass and one fail, the
+evaluator fails, and `!` turns that into a pass. **A replacement does not fire the guard.**
+
+| Plan | Sentinel | Tirith |
+| --- | --- | --- |
+| `should-fail.json` (pure delete) | fail | exit 3 |
+| `should-pass.json` (in-place update) | pass | exit 0 |
+| `diverges.json` (replacement) | **fail** | **exit 0** |
+
+Say this to the reader. A team that migrates a destroy guard and later sees a forced replacement
+sail through will not think of it as a fidelity note.
diff --git a/.claude/skills/tirith-migrate/examples/sentinel/prevent-database-destroy/policy.json b/.claude/skills/tirith-migrate/examples/sentinel/prevent-database-destroy/policy.json
new file mode 100644
index 00000000..609371d1
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/sentinel/prevent-database-destroy/policy.json
@@ -0,0 +1,16 @@
+{
+ "meta": {
+ "version": "v1",
+ "required_provider": "stackguardian/terraform_plan",
+ "name": "prevent-database-destroy",
+ "description": "No aws_db_instance is deleted by this plan",
+ "enforcement": "hard-mandatory"
+ },
+ "evaluators": [{
+ "id": "database_deleted",
+ "description": "Detects a delete action on any aws_db_instance",
+ "provider_args": {"operation_type": "action", "terraform_resource_type": "aws_db_instance"},
+ "condition": {"type": "ContainedIn", "value": ["delete"], "error_tolerance": 1}
+ }],
+ "eval_expression": "!database_deleted"
+}
diff --git a/.claude/skills/tirith-migrate/examples/sentinel/prevent-database-destroy/should-fail.json b/.claude/skills/tirith-migrate/examples/sentinel/prevent-database-destroy/should-fail.json
new file mode 100644
index 00000000..c4df5650
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/sentinel/prevent-database-destroy/should-fail.json
@@ -0,0 +1,24 @@
+{
+ "format_version": "1.2",
+ "terraform_version": "1.5.7",
+ "resource_changes": [
+ {
+ "address": "aws_db_instance.main",
+ "mode": "managed",
+ "type": "aws_db_instance",
+ "name": "main",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "delete"
+ ],
+ "before": {
+ "identifier": "main",
+ "engine": "postgres"
+ },
+ "after": null,
+ "after_unknown": {}
+ }
+ }
+ ]
+}
diff --git a/.claude/skills/tirith-migrate/examples/sentinel/prevent-database-destroy/should-pass.json b/.claude/skills/tirith-migrate/examples/sentinel/prevent-database-destroy/should-pass.json
new file mode 100644
index 00000000..5e06a5b2
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/sentinel/prevent-database-destroy/should-pass.json
@@ -0,0 +1,29 @@
+{
+ "format_version": "1.2",
+ "terraform_version": "1.5.7",
+ "resource_changes": [
+ {
+ "address": "aws_db_instance.main",
+ "mode": "managed",
+ "type": "aws_db_instance",
+ "name": "main",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "update"
+ ],
+ "before": {
+ "identifier": "main",
+ "engine": "postgres",
+ "allocated_storage": 50
+ },
+ "after": {
+ "identifier": "main",
+ "engine": "postgres",
+ "allocated_storage": 100
+ },
+ "after_unknown": {}
+ }
+ }
+ ]
+}
diff --git a/.claude/skills/tirith-migrate/examples/sentinel/prevent-database-destroy/source.sentinel b/.claude/skills/tirith-migrate/examples/sentinel/prevent-database-destroy/source.sentinel
new file mode 100644
index 00000000..c8bf70be
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/sentinel/prevent-database-destroy/source.sentinel
@@ -0,0 +1,7 @@
+# No aws_db_instance may be destroyed. tfplan/v2, common-functions idiom.
+import "tfplan-functions" as plan
+
+destroyed = plan.find_resources_being_destroyed()
+databases = filter destroyed as address, rc { rc.type is "aws_db_instance" }
+
+main = rule { length(databases) is 0 }
diff --git a/.claude/skills/tirith-migrate/examples/sentinel/require-private-registry-modules/notes.md b/.claude/skills/tirith-migrate/examples/sentinel/require-private-registry-modules/notes.md
new file mode 100644
index 00000000..6f27b6cf
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/sentinel/require-private-registry-modules/notes.md
@@ -0,0 +1,14 @@
+# require-private-registry-modules: not expressible
+
+**What it enforces.** Every `module` call's `source` starts with `app.terraform.io/acme/`.
+
+**What Tirith cannot see.** Module calls are configuration, read by Sentinel through `tfconfig/v2`.
+A Terraform plan's `resource_changes` records resources, not the modules that declared them, and
+Tirith's plan provider reads only `resource_changes` and `configuration.provider_config`. There is
+no attribute, on any resource, that carries the module source.
+
+**What would change that.** Issue #348 proposes a `terraform_code` provider that reads HCL. Until
+it exists, keep this policy in Sentinel or enforce it where modules are resolved.
+
+No `policy.json` is shipped for this example, on purpose. A policy that checked something adjacent,
+say that every resource address contains `module.`, would pass CI and enforce nothing.
diff --git a/.claude/skills/tirith-migrate/examples/sentinel/require-private-registry-modules/source.sentinel b/.claude/skills/tirith-migrate/examples/sentinel/require-private-registry-modules/source.sentinel
new file mode 100644
index 00000000..8263932d
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/sentinel/require-private-registry-modules/source.sentinel
@@ -0,0 +1,10 @@
+# Every module call must come from the organisation's private registry. tfconfig/v2.
+import "tfconfig-functions" as config
+import "strings"
+
+allModules = config.find_all_module_calls()
+violations = filter allModules as address, m {
+ not strings.has_prefix(m.source, "app.terraform.io/acme/")
+}
+
+main = rule { length(violations) is 0 }
diff --git a/.claude/skills/tirith-migrate/examples/sentinel/restrict-instance-type/notes.md b/.claude/skills/tirith-migrate/examples/sentinel/restrict-instance-type/notes.md
new file mode 100644
index 00000000..4929caca
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/sentinel/restrict-instance-type/notes.md
@@ -0,0 +1,13 @@
+# restrict-instance-type: exact
+
+`filter_attribute_not_in_list(resources, "instance_type", allowed_types)` returns the violators.
+The Tirith condition is the desired state: `ContainedIn allowed_types`.
+
+`param allowed_types` becomes `{{ var.allowed_types }}` and lives in `variables.json`, so the same
+policy serves every environment. `error_tolerance: 1` skips a plan with no `aws_instance`, which is
+what the Sentinel `length(...) is 0` did on an empty set.
+
+| Plan | Sentinel | Tirith |
+| --- | --- | --- |
+| `should-fail.json` (an `m5.24xlarge`) | fail | exit 3 |
+| `should-pass.json` | pass | exit 0 |
diff --git a/.claude/skills/tirith-migrate/examples/sentinel/restrict-instance-type/policy.json b/.claude/skills/tirith-migrate/examples/sentinel/restrict-instance-type/policy.json
new file mode 100644
index 00000000..aac062fc
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/sentinel/restrict-instance-type/policy.json
@@ -0,0 +1,20 @@
+{
+ "meta": {
+ "version": "v1",
+ "required_provider": "stackguardian/terraform_plan",
+ "name": "restrict-instance-type",
+ "description": "aws_instance.instance_type must be in the allow-list",
+ "enforcement": "hard-mandatory"
+ },
+ "evaluators": [{
+ "id": "instance_type_allowed",
+ "description": "instance_type is one of allowed_types",
+ "provider_args": {
+ "operation_type": "attribute",
+ "terraform_resource_type": "aws_instance",
+ "terraform_resource_attribute": "instance_type"
+ },
+ "condition": {"type": "ContainedIn", "value": "{{ var.allowed_types }}", "error_tolerance": 1}
+ }],
+ "eval_expression": "instance_type_allowed"
+}
diff --git a/.claude/skills/tirith-migrate/examples/sentinel/restrict-instance-type/should-fail.json b/.claude/skills/tirith-migrate/examples/sentinel/restrict-instance-type/should-fail.json
new file mode 100644
index 00000000..a2651651
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/sentinel/restrict-instance-type/should-fail.json
@@ -0,0 +1,46 @@
+{
+ "format_version": "1.2",
+ "terraform_version": "1.5.7",
+ "resource_changes": [
+ {
+ "address": "aws_instance.web",
+ "mode": "managed",
+ "type": "aws_instance",
+ "name": "web",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "instance_type": "t3.micro",
+ "tags": {
+ "Name": "web"
+ }
+ },
+ "after_unknown": {}
+ }
+ },
+ {
+ "address": "aws_instance.batch",
+ "mode": "managed",
+ "type": "aws_instance",
+ "name": "batch",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "instance_type": "m5.24xlarge",
+ "tags": {
+ "Name": "batch"
+ }
+ },
+ "after_unknown": {}
+ }
+ }
+ ]
+}
diff --git a/.claude/skills/tirith-migrate/examples/sentinel/restrict-instance-type/should-pass.json b/.claude/skills/tirith-migrate/examples/sentinel/restrict-instance-type/should-pass.json
new file mode 100644
index 00000000..02272182
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/sentinel/restrict-instance-type/should-pass.json
@@ -0,0 +1,46 @@
+{
+ "format_version": "1.2",
+ "terraform_version": "1.5.7",
+ "resource_changes": [
+ {
+ "address": "aws_instance.web",
+ "mode": "managed",
+ "type": "aws_instance",
+ "name": "web",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "instance_type": "t3.micro",
+ "tags": {
+ "Name": "web"
+ }
+ },
+ "after_unknown": {}
+ }
+ },
+ {
+ "address": "aws_instance.api",
+ "mode": "managed",
+ "type": "aws_instance",
+ "name": "api",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "instance_type": "t3.small",
+ "tags": {
+ "Name": "api"
+ }
+ },
+ "after_unknown": {}
+ }
+ }
+ ]
+}
diff --git a/.claude/skills/tirith-migrate/examples/sentinel/restrict-instance-type/source.sentinel b/.claude/skills/tirith-migrate/examples/sentinel/restrict-instance-type/source.sentinel
new file mode 100644
index 00000000..581a583a
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/sentinel/restrict-instance-type/source.sentinel
@@ -0,0 +1,12 @@
+# Restrict aws_instance.instance_type to an allow-list. tfplan/v2, common-functions idiom.
+import "tfplan-functions" as plan
+
+param allowed_types default ["t3.micro", "t3.small", "t3.medium"]
+
+allInstances = plan.find_resources("aws_instance")
+violatingInstances = plan.filter_attribute_not_in_list(allInstances,
+ "instance_type", allowed_types, true)
+
+main = rule {
+ length(violatingInstances["messages"]) is 0
+}
diff --git a/.claude/skills/tirith-migrate/examples/sentinel/restrict-instance-type/variables.json b/.claude/skills/tirith-migrate/examples/sentinel/restrict-instance-type/variables.json
new file mode 100644
index 00000000..48af14c0
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/sentinel/restrict-instance-type/variables.json
@@ -0,0 +1 @@
+{"allowed_types": ["t3.micro", "t3.small", "t3.medium"]}
diff --git a/.claude/skills/tirith-migrate/examples/sentinel/restrict-ssh-ingress/diverges.json b/.claude/skills/tirith-migrate/examples/sentinel/restrict-ssh-ingress/diverges.json
new file mode 100644
index 00000000..c71ea8bc
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/sentinel/restrict-ssh-ingress/diverges.json
@@ -0,0 +1,44 @@
+{
+ "format_version": "1.2",
+ "terraform_version": "1.5.7",
+ "resource_changes": [
+ {
+ "address": "aws_security_group.web",
+ "mode": "managed",
+ "type": "aws_security_group",
+ "name": "web",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "name": "web",
+ "ingress": [
+ {
+ "from_port": 443,
+ "to_port": 443,
+ "protocol": "tcp",
+ "cidr_blocks": [
+ "0.0.0.0/0"
+ ],
+ "ipv6_cidr_blocks": []
+ },
+ {
+ "from_port": 22,
+ "to_port": 22,
+ "protocol": "tcp",
+ "cidr_blocks": [
+ "10.0.0.0/8"
+ ],
+ "ipv6_cidr_blocks": []
+ }
+ ],
+ "egress": []
+ },
+ "after_unknown": {}
+ }
+ }
+ ]
+}
diff --git a/.claude/skills/tirith-migrate/examples/sentinel/restrict-ssh-ingress/notes.md b/.claude/skills/tirith-migrate/examples/sentinel/restrict-ssh-ingress/notes.md
new file mode 100644
index 00000000..c2d125a4
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/sentinel/restrict-ssh-ingress/notes.md
@@ -0,0 +1,17 @@
+# restrict-ssh-ingress: approximate, stricter
+
+The Sentinel binds three tests to the same ingress block: port range covers 22, and cidr is
+`0.0.0.0/0`. Tirith evaluators are per resource and `eval_expression` combines verdicts already
+collapsed across all resources, so nothing can say "the block where both hold". The translation
+keeps the test that carries the intent, `0.0.0.0/0` in any ingress block, and drops the port.
+
+The result is stricter: a group that opens 443 to the world and 22 to the VPC passes Sentinel
+and fails Tirith. Issue #316 (`resource_filter`) would make this exact.
+
+| Plan | Sentinel | Tirith |
+| --- | --- | --- |
+| `should-fail.json` (22 from anywhere) | fail | exit 3 |
+| `should-pass.json` (everything internal) | pass | exit 0 |
+| `diverges.json` (443 from anywhere, 22 internal) | **pass** | **exit 3** |
+
+`error_tolerance: 2` skips a group with no ingress blocks, which Sentinel's `any` also passed.
diff --git a/.claude/skills/tirith-migrate/examples/sentinel/restrict-ssh-ingress/policy.json b/.claude/skills/tirith-migrate/examples/sentinel/restrict-ssh-ingress/policy.json
new file mode 100644
index 00000000..bfdad998
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/sentinel/restrict-ssh-ingress/policy.json
@@ -0,0 +1,20 @@
+{
+ "meta": {
+ "version": "v1",
+ "required_provider": "stackguardian/terraform_plan",
+ "name": "restrict-ssh-ingress",
+ "description": "No security group ingress block admits 0.0.0.0/0 (stricter than the Sentinel original, which only refused it on port 22)",
+ "enforcement": "hard-mandatory"
+ },
+ "evaluators": [{
+ "id": "ingress_open_to_world",
+ "description": "Detects 0.0.0.0/0 in any ingress block's cidr_blocks",
+ "provider_args": {
+ "operation_type": "attribute",
+ "terraform_resource_type": "aws_security_group",
+ "terraform_resource_attribute": "ingress.*.cidr_blocks"
+ },
+ "condition": {"type": "NotContains", "value": "0.0.0.0/0", "error_tolerance": 2}
+ }],
+ "eval_expression": "ingress_open_to_world"
+}
diff --git a/.claude/skills/tirith-migrate/examples/sentinel/restrict-ssh-ingress/should-fail.json b/.claude/skills/tirith-migrate/examples/sentinel/restrict-ssh-ingress/should-fail.json
new file mode 100644
index 00000000..675b2fcd
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/sentinel/restrict-ssh-ingress/should-fail.json
@@ -0,0 +1,35 @@
+{
+ "format_version": "1.2",
+ "terraform_version": "1.5.7",
+ "resource_changes": [
+ {
+ "address": "aws_security_group.bastion",
+ "mode": "managed",
+ "type": "aws_security_group",
+ "name": "bastion",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "name": "bastion",
+ "ingress": [
+ {
+ "from_port": 22,
+ "to_port": 22,
+ "protocol": "tcp",
+ "cidr_blocks": [
+ "0.0.0.0/0"
+ ],
+ "ipv6_cidr_blocks": []
+ }
+ ],
+ "egress": []
+ },
+ "after_unknown": {}
+ }
+ }
+ ]
+}
diff --git a/.claude/skills/tirith-migrate/examples/sentinel/restrict-ssh-ingress/should-pass.json b/.claude/skills/tirith-migrate/examples/sentinel/restrict-ssh-ingress/should-pass.json
new file mode 100644
index 00000000..6dee0faa
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/sentinel/restrict-ssh-ingress/should-pass.json
@@ -0,0 +1,44 @@
+{
+ "format_version": "1.2",
+ "terraform_version": "1.5.7",
+ "resource_changes": [
+ {
+ "address": "aws_security_group.app",
+ "mode": "managed",
+ "type": "aws_security_group",
+ "name": "app",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "name": "app",
+ "ingress": [
+ {
+ "from_port": 22,
+ "to_port": 22,
+ "protocol": "tcp",
+ "cidr_blocks": [
+ "10.0.0.0/8"
+ ],
+ "ipv6_cidr_blocks": []
+ },
+ {
+ "from_port": 443,
+ "to_port": 443,
+ "protocol": "tcp",
+ "cidr_blocks": [
+ "10.0.0.0/8"
+ ],
+ "ipv6_cidr_blocks": []
+ }
+ ],
+ "egress": []
+ },
+ "after_unknown": {}
+ }
+ }
+ ]
+}
diff --git a/.claude/skills/tirith-migrate/examples/sentinel/restrict-ssh-ingress/source.sentinel b/.claude/skills/tirith-migrate/examples/sentinel/restrict-ssh-ingress/source.sentinel
new file mode 100644
index 00000000..3b7e5c4c
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/sentinel/restrict-ssh-ingress/source.sentinel
@@ -0,0 +1,13 @@
+# No security group ingress rule may open port 22 to 0.0.0.0/0. tfplan/v2.
+import "tfplan-functions" as plan
+
+groups = plan.find_resources("aws_security_group")
+
+violations = filter groups as address, sg {
+ any sg.change.after.ingress as rule {
+ rule.from_port <= 22 and rule.to_port >= 22 and
+ "0.0.0.0/0" in rule.cidr_blocks
+ }
+}
+
+main = rule { length(violations) is 0 }
diff --git a/.claude/skills/tirith-migrate/reference/sentinel-corpus.md b/.claude/skills/tirith-migrate/reference/sentinel-corpus.md
new file mode 100644
index 00000000..15c28876
--- /dev/null
+++ b/.claude/skills/tirith-migrate/reference/sentinel-corpus.md
@@ -0,0 +1,118 @@
+# The public Sentinel corpus, classified
+
+Every policy in `hashicorp/terraform-sentinel-policies` and `hashicorp/policy-library-CIS-Policy-Set-for-AWS-Terraform`,
+with the fidelity a Tirith translation can reach and why. Look a policy up here before translating it;
+if it is not listed, classify it with `reference/sentinel.md` and add a row.
+
+| Policy | Pattern | Fidelity | Why |
+| --- | --- | --- | --- |
+| `aws/check-ec2-environment-tag` | tag-keys-required | exact | Two per-resource single-attribute tests (tags Contains 'Environment', tags.Environment ContainedIn list); a missing key fails both Sentinel and Tirith (missing attribute = severity-2 fail). |
+| `aws/enforce-mandatory-tags` | tag-keys-required | exact | tags Contains [keys] per resource is Sentinel's not_contains_list on a map; resource_types cannot be a -var (each type is its own evaluator, error_tolerance 1 so absent types pass), but the default list gives identical verdicts. |
+| `aws/require-dns-support-for-vpcs` | attribute-equals | exact | Two single-attribute Equals true checks; null/missing fails in both engines. |
+| `aws/require-private-acl-and-kms-for-s3-buckets` | attribute-equals | exact | Two single-attribute Equals checks on one resource type; S3 permits a single rule so the wildcard equals Sentinel's index-0 read, and missing fails in both. |
+| `aws/require-vpc-and-kms-for-lambda-functions` | attribute-present | exact | Sentinel violates on kms_key_arn null and vpc_config == []; Tirith IsNotEmpty on both yields the same verdicts (Terraform always emits [] for an unset block list, so Sentinel's null-vpc_config pass is unreachable). |
+| `aws/restrict-availability-zones` | attribute-in-list | exact | Single-attribute ContainedIn; an unset/computed availability_zone is a violation in Sentinel (null->'null') and a severity-2 failure in Tirith. |
+| `aws/restrict-db-instance-engines` | attribute-in-list | exact | Single-attribute ContainedIn with identical null handling. |
+| `aws/restrict-ec2-instance-type` | attribute-in-list | exact | Single-attribute ContainedIn with identical null handling. |
+| `aws/restrict-eks-node-group-size` | attribute-numeric | exact | Single numeric comparison; null max_size violates in Sentinel and fails as missing in Tirith. |
+| `aws/restrict-launch-configuration-instance-type` | attribute-in-list | exact | Single-attribute ContainedIn with identical null handling. |
+| `aws/restrict-sagemaker-notebooks` | attribute-equals | exact | Two single-attribute Equals checks; null fails in both engines. |
+| `azure/enforce-mandatory-tags` | tag-keys-required | exact | Same shape as the AWS tags policy; per-type evaluators with error_tolerance 1 reproduce Sentinel's pass on absent types. |
+| `azure/restrict-aks-clusters` | attribute-numeric | exact | Six single-attribute checks over two resource types; Sentinel ignores null counts, which error_tolerance 2 reproduces, while a missing vm_size fails in both. |
+| `azure/restrict-app-service-to-https` | attribute-equals | exact | Single-attribute Equals true. |
+| `azure/restrict-vm-image-id` | attribute-regex | exact | Anchored RegexMatch per resource type; Sentinel's '\|null' alternative lets an unset id pass, reproduced with error_tolerance 2 on a missing attribute. |
+| `azure/restrict-vm-publisher` | attribute-in-list | exact | Three single-attribute ContainedIn checks over three resource types with identical null handling. |
+| `azure/restrict-vm-size` | attribute-in-list | exact | Three single-attribute ContainedIn checks over three resource types. |
+| `cis-aws/cloudtrail/cloudtrail-log-file-validation-enabled` | attribute-equals | exact | Boolean with a provider default; missing attribute fails in both. |
+| `cis-aws/ec2/ec2-ebs-encryption-enabled` | attribute-equals | exact | Missing/unknown encrypted is a violation in Sentinel and a failing missing attribute in Tirith. |
+| `cis-aws/iam/iam-password-expiry` | attribute-threshold | exact | Numeric threshold; missing attribute fails in both. |
+| `cis-aws/iam/iam-password-length` | attribute-threshold | exact | Numeric threshold; missing attribute fails in both. |
+| `cis-aws/iam/iam-password-lowercase` | attribute-equals | exact | Boolean equality; missing attribute fails in both. |
+| `cis-aws/iam/iam-password-numbers` | attribute-equals | exact | Boolean equality; missing attribute fails in both. |
+| `cis-aws/iam/iam-password-reuse` | attribute-threshold | exact | Sentinel uses strict equality (not >=), which Equals reproduces; missing attribute fails in both. |
+| `cis-aws/iam/iam-password-symbols` | attribute-equals | exact | Boolean equality; missing attribute fails in both. |
+| `cis-aws/iam/iam-password-uppercase` | attribute-equals | exact | Boolean equality; missing attribute fails in both. |
+| `cis-aws/rds/rds-encryption-at-rest-enabled` | attribute-equals | exact | Missing/unknown storage_encrypted is a violation in Sentinel and a failing missing attribute in Tirith. |
+| `cis-aws/rds/rds-minor-version-upgrade-enabled` | attribute-equals | exact | Boolean with provider default true; missing fails in both. |
+| `cis-aws/rds/rds-public-access-disabled` | attribute-equals | exact | Sentinel treats a missing attribute as compliant; error_tolerance=2 reproduces that, and the provider default false makes the attribute always present anyway. |
+| `cis-aws/s3/s3-block-public-access-account-level` | attribute-equals | exact | All four attributes must be true on every resource; conjunction of all-quantified evaluators equals Sentinel's per-resource conjunction here since a single false anywhere fails both. |
+| `cloud-agnostic/prevent-destruction-of-prohibited-resources` | destroy-guard | exact | One action evaluator per type with value ["delete"] matches Sentinel's delete-and-not-create/update filter exactly (replacements pass in both), and error_tolerance 1 makes absent types skip as Sentinel's empty filter passes. |
+| `cloud-agnostic/prevent-tfe-provider-workspace-deletion` | destroy-guard | exact | action NotEquals ["delete"] on tfe_workspace is the same whole-list comparison as Sentinel's `is not ["delete"]`; error_tolerance 1 skips when no tfe_workspace exists, matching Sentinel's vacuous all. |
+| `cloud-agnostic/restrict-panos-srgs` | attribute-not-contains | exact | The wildcard walks every rule block and NotContains "any" fails on the same rules Sentinel's contains check does; error_tolerance 2 skips null lists and destroyed SRGs, as Sentinel's find_resources and null handling do. |
+| `gcp/enforce-mandatory-labels` | tag-keys-required | exact | Same shape as the tags policies, on the labels map. |
+| `gcp/restrict-gce-machine-type` | attribute-in-list | exact | Single-attribute ContainedIn with identical null handling. |
+| `gcp/restrict-gke-clusters` | attribute-numeric | exact | Six single-attribute checks; Sentinel skips null counts and lists 'null' as an allowed machine type, both reproduced by error_tolerance 2 on the missing attribute. |
+| `vmware/require-storage-drs` | attribute-equals | exact | Single-attribute Equals true. |
+| `vmware/require_nfs41_and_kerberos` | attribute-equals | exact | One Equals and one ContainedIn on the same resource type; null fails in both engines. |
+| `vmware/restrict-virtual-disk-size-and-type` | attribute-numeric | exact | One numeric bound and one Equals; null size violates in Sentinel and fails as missing in Tirith. |
+| `vmware/restrict-vm-cpu-and-memory` | attribute-numeric | exact | Two numeric bounds on one resource type with identical null handling. |
+| `vmware/restrict-vm-disk-size` | attribute-numeric | exact | A single attribute across a nested block list via the * wildcard; Sentinel's per-VM 'any disk violates' collapses to the same per-resource fail. |
+| `aws/enforce_s3_encryption` | cross-resource-reference | approximate | Tirith can require every aws_s3_bucket_server_side_encryption_configuration to use AES256/aws:kms and every aws_s3_bucket to be referenced by one (direct_references, type-level), but loses the tfconfig-reference pairing of a specific config to a specific bucket and the legacy branch that demands inline aws:kms only. |
+| `aws/protect-against-rds-instance-deletion` | destroy-guard | approximate | Tirith action NotEquals ['delete'] blocks every pure destroy of aws_db_instance (replace still allowed, matching Sentinel), but cannot read change.before so it is stricter: destroys of instances with deletion_protection=false are also blocked. |
+| `aws/restrict-ami-owners` | attribute-in-list | approximate | Only reachable by feeding the state file to the json provider; the wildcard path cannot be filtered to mode=data/type=aws_ami, so any other state resource exposing an owners attribute (e.g. aws_ami_ids) is also checked (stricter), and the list-of-lists ContainedIn semantics are undefined. |
+| `aws/restrict-assumed-roles` | provider-region | approximate | provider_config can read constant provider settings, but Sentinel also resolves a role_arn given as var.X through tfplan.variables; Tirith sees no constant there and would fail/skip that provider. |
+| `aws/restrict-current-ec2-instance-type` | attribute-in-list | approximate | Requires the state file via the json provider; the wildcard cannot select type=aws_instance, so other state resources with an instance_type attribute (aws_launch_configuration, aws_spot_instance_request) are also checked (stricter). |
+| `aws/restrict-egress-sg-rule-cidr-blocks` | nested-block-cidr-port | approximate | The egress.*.cidr_blocks evaluator on aws_security_group is exact, but for aws_security_group_rule the type=='egress' filter cannot be bound to the same resource, so ingress rules with 0.0.0.0/0 are also flagged (stricter). |
+| `aws/restrict-iam-policy-actions` | attribute-not-in-list | approximate | Needs the state file via the json provider and cannot filter to the data source type; case-insensitivity is only available if the regex engine accepts (?i) and NotContains is a literal match, so 'IAM:create*' casing variants may slip through. |
+| `aws/restrict-ingress-sg-rule-cidr-blocks` | nested-block-cidr-port | approximate | ingress.*.cidr_blocks on aws_security_group is exact; for aws_security_group_rule the type=='ingress' filter cannot be bound per resource, so egress rules with 0.0.0.0/0 are also flagged (stricter). |
+| `aws/restrict_s3_acl` | cross-resource-reference | approximate | Tirith can require acl=private on aws_s3_bucket (error_tolerance for buckets without inline acl) and on every aws_s3_bucket_acl, plus a type-level direct_references check, but cannot pair a specific acl resource to a specific bucket via tfconfig references, and cannot reproduce Sentinel's fail-when-no-buckets quirk. |
+| `aws/validate-providers-from-desired-regions` | provider-region | approximate | provider_config covers constant regions, but Sentinel also resolves var.* references through tfplan.variables, module-call arguments and variable defaults, and the tfrun.variables env-var check has no Tirith counterpart. |
+| `azure/require-database-auditing` | cross-resource-reference | approximate | The OR is per database in Sentinel but per evaluator in Tirith: a plan with one inline-audited DB and one externally-audited DB passes Sentinel yet fails both Tirith evaluators (stricter). |
+| `azure/require-free-sec-center-subscription-pricing-for-vms` | conditional-attribute | approximate | The resource_type filter cannot be bound to the tier test on the same resource; a plan with (VirtualMachines, Free) and (StorageAccounts, Standard) passes Sentinel but fails Tirith (stricter). |
+| `azure/restrict-inbound-source-address-prefixes` | conditional-attribute | approximate | The direction==Inbound AND access==Allow filter cannot be bound to the prefix test per rule or per nested block, so Deny-from-* (default-deny) and Outbound rules are flagged too (stricter). |
+| `azure/restrict-outbound-destination-address-prefixes` | conditional-attribute | approximate | The direction==Outbound AND access==Allow filter cannot be bound per rule/block, so Deny-to-* and Inbound rules with those destinations are flagged too (stricter). |
+| `azure/restrict-publishers-of-current-vms` | attribute-in-list | approximate | Requires the state file via the json provider; cannot restrict to the three VM types, so other state resources with the same nested publisher attribute (e.g. VM scale sets) are also checked (stricter). |
+| `cis-aws/cloudtrail/cloudtrail-cloudwatch-logs-group-arn-present` | attribute-present | approximate | A reference to a not-yet-created log group is unknown in the plan so change.after omits it: Sentinel accepts any reference, Tirith fails on the missing attribute (or, with error_tolerance=2, also skips a genuinely unset attribute). |
+| `cis-aws/cloudtrail/cloudtrail-server-side-encryption-enabled` | attribute-present | approximate | kms_key_id almost always references a new aws_kms_key whose ARN is unknown at plan time, so change.after omits it; Sentinel passes any reference while Tirith sees a missing attribute (fail, or skip with error_tolerance=2 which also skips an unset key). |
+| `cis-aws/ec2/ec2-metadata-imdsv2-required` | attribute-equals | approximate | The fallback is per instance in Sentinel but a \|\| b is plan-wide in Tirith: with a compliant metadata_defaults resource present, an aws_instance that explicitly sets http_tokens='optional' passes Tirith but fails Sentinel. |
+| `cis-aws/ec2/ec2-network-acl` | nested-block-cidr-port | approximate | Stricter: cidr, protocol, from_port<=P<=to_port and egress=false cannot be bound to the same rule/ingress block, so an ACL with one 0.0.0.0/0 rule on port 443 and a separate 10.0.0.0/8 rule on port 22 fails Tirith but passes Sentinel; the list-valued blocked_ports param also needs one evaluator pair per port. |
+| `cis-aws/ec2/ec2-security-group-ingress-traffic-restriction-port` | nested-block-cidr-port | approximate | Stricter: the catch-all CIDR test and the from_port<=port<=to_port (or protocol=-1) test cannot be tied to the same ingress block or rule, so a group with an open 443 rule plus a restricted 22 rule fails Tirith but passes Sentinel. |
+| `cis-aws/ec2/ec2-security-group-ingress-traffic-restriction-protocol` | nested-block-cidr-port | approximate | Stricter for the same reason (no per-block conjunction of CIDR and port-range/protocol), and the two boolean params that switch IPv4/IPv6 checks off entirely have no Tirith equivalent other than editing the policy. |
+| `cis-aws/ec2/ec2-vpc-default-security-group-no-traffic` | cross-resource-reference | approximate | Stricter: direct_references is type-level so a rule that references aws_vpc for cidr_blocks=[aws_vpc.x.cidr_block] is flagged the same as one referencing aws_vpc.x.default_security_group_id; Sentinel only matches the security_group_id reference. |
+| `cis-aws/ec2/ec2-vpc-flow-logging-enabled` | cross-resource-reference | approximate | Cannot pair a flow log's traffic_type with the VPC it references: a VPC whose only flow log is ACCEPT passes if another flow log elsewhere uses ALL, and a plan with an ACCEPT flow log on an unmanaged VPC id fails Tirith but not Sentinel. |
+| `cis-aws/efs/efs-encryption-at-rest-enabled` | attribute-equals | approximate | kms_key_id referencing a new aws_kms_key is unknown at plan time and absent from change.after, so Tirith fails (or skips with error_tolerance=2) where Sentinel accepts the reference. |
+| `cis-aws/iam/iam-no-policies-attached-to-users` | count-limit | approximate | Sentinel's planned_values excludes resources being destroyed while Tirith count includes every resource_changes entry, so a plan that only destroys an existing user policy attachment fails Tirith but passes Sentinel (action Equals [delete] with error_tolerance=1 would close the gap if list equality is supported). |
+| `cis-aws/kms/kms-key-rotation-enabled` | attribute-equals | approximate | Stricter: the exemption is per key in Sentinel but a \|\| b is plan-wide, so a disabled key without rotation alongside an enabled rotated key fails Tirith (a fails on key 1, b fails on key 2) yet passes Sentinel. |
+| `cis-aws/s3/s3-block-public-access-bucket-level` | cross-resource-reference | approximate | Cannot pair a compliant block with its bucket: a non-compliant block attached to an unmanaged bucket (bucket = "existing-name") fails Tirith but is ignored by Sentinel; var-driven settings resolve better in the plan than Sentinel's var-only lookup. |
+| `cis-aws/s3/s3-require-mfa-delete` | cross-resource-reference | approximate | Cannot pair the compliant versioning resource with its bucket: a versioning resource with mfa_delete=Disabled on an unmanaged bucket fails Tirith but is ignored by Sentinel, and a bucket whose versioning is non-compliant fails both only because the attribute evaluator is plan-wide. |
+| `cis-aws/vpc/vpc-flow-logging-enabled` | cross-resource-reference | approximate | Cannot pair a flow log's traffic_type with the VPC it references: a VPC whose only flow log is ACCEPT passes when another flow log is REJECT, and a REJECT-less flow log on an unmanaged VPC id fails Tirith but not Sentinel. |
+| `cloud-agnostic/allowed-providers` | provider-allowlist | approximate | provider_name on planned_values resources reproduces the resource/data-source check for the root module and each enumerated child_modules depth (allowlist spelled as registry.terraform.io/hashicorp/), but a provider block with no resources and resources in modules nested deeper than the enumerated wildcard depth are not flagged (looser). |
+| `cloud-agnostic/allowed-resources` | resource-type-allowlist | approximate | resource_changes.*.type ContainedIn reproduces the allowlist for every instance in the plan across all modules, but resource blocks with zero instances (count=0/for_each={}) are invisible (looser) while deferred data sources and resources being destroyed are judged too (stricter). |
+| `cloud-agnostic/limit-cost-and-percentage-increase` | cost | approximate | The absolute limit maps to total_monthly_cost LessThanEqualTo 1000, but the percentage-increase check needs prior cost, delta and division, so plans under $1000 with a >10% jump pass (looser); the figure is also an Infracost breakdown rather than the TFC estimate. |
+| `cloud-agnostic/limit-proposed-monthly-cost` | cost | approximate | Same shape, but the figure is an Infracost breakdown rather than the TFC cost estimate, so resource coverage and prices differ and a plan near the limit can flip verdicts; Sentinel also passes when no estimate exists. |
+| `cloud-agnostic/prohibited-datasources` | datasource-denylist | approximate | planned_values lists both plan-time and deferred data sources by type, so root-module and enumerated-depth uses are caught, but data sources in deeper nested modules and blocks with zero instances are missed (looser). |
+| `cloud-agnostic/prohibited-providers` | provider-denylist | approximate | provider_name on planned_values resources catches null_resource and root/enumerated-depth external/http data sources (denylist spelled as registry.terraform.io/hashicorp/), but a provider block with no resources and uses in deeper nested modules are not flagged (looser). |
+| `cloud-agnostic/prohibited-resources` | resource-type-denylist | approximate | count Equals 0 per prohibited type covers every instance in the plan (including no-op), but a resource block with zero instances is invisible (looser) and an instance being destroyed is still counted (stricter). |
+| `cloud-agnostic/restrict-databricks-clusters` | attribute-in-list | approximate | Both checks map to attribute evaluators with error_tolerance 2 so an absent autoscale block or unknown node_type_id skips as Sentinel allows null; the one divergence is an explicit JSON null node_type_id, which Sentinel whitelists via the literal "null" list entry and whose handling under ContainedIn Tirith does not define. |
+| `cloud-agnostic/restrict-terraform-versions` | terraform-version | approximate | Sentinel compares the version as a plain string, so 0.2.x-0.9.x lexicographically exceed "0.12.0" and wrongly pass; a Tirith RegexMatch ^(0\.1[2-9]\|[1-9]) encodes the intended semver check and rejects them (stricter only on those legacy versions). |
+| `gcp/restrict-egress-firewall-destination-ranges` | conditional-attribute | approximate | The direction==EGRESS filter cannot be bound to the destination_ranges test; an INGRESS rule that sets destination_ranges 0.0.0.0/0 passes Sentinel but fails Tirith (stricter, rare). |
+| `gcp/restrict-ingress-firewall-source-ranges` | conditional-attribute | approximate | The direction==INGRESS filter cannot be bound to the source_ranges test; an EGRESS rule carrying source_ranges 0.0.0.0/0 (a plan GCP would reject at apply) passes Sentinel but fails Tirith (stricter). |
+| `aws/require-most-recent-AMI-version` | tfconfig-only | not expressible | Needs tfconfig expression references on the ami argument (direct_references is only a type-level 'some reference exists' boolean and does not name the attribute) plus most_recent on a state data source, which the plan attribute operation cannot address. |
+| `aws/restrict-assumed-roles-by-workspace` | provider-region | not expressible | Needs the TFC workspace name (tfrun) and a role->workspace-regex map evaluated per provider block, plus resolution of var-referenced role_arn; Tirith has no workspace context and no map lookup. |
+| `aws/restrict-ingress-sg-rule-rdp` | nested-block-cidr-port | not expressible | Requires a per-rule/per-block conjunction (cidr contains 0.0.0.0/0 AND from_port<=3389 AND to_port>=3389); Tirith evaluators combine only at the all-resources level, so a public 443 rule plus a private 3389 rule would fail. |
+| `aws/restrict-ingress-sg-rule-ssh` | nested-block-cidr-port | not expressible | Requires a per-rule/per-block conjunction (cidr contains 0.0.0.0/0 AND from_port<=22 AND to_port>=22); Tirith cannot bind three attribute tests to the same block or resource. |
+| `aws/restrict-s3-bucket-policies` | other | not expressible | Needs tfconfig expression references for the policy argument, plan-time data-source blocks, and per-statement conjunctions over effect/condition.test/variable/values with an 'exists a matching Deny statement' (any) quantifier. |
+| `aws/restrict-subnet-of-ec2-instances` | tfconfig-only | not expressible | Entirely about configuration expressions (references, constant_value, module_address) which Tirith cannot read; plan after-values contain only the resolved subnet id. |
+| `cis-aws/cloudtrail/cloudtrail-bucket-access-logging-enabled` | cross-resource-reference | not expressible | Needs per-instance comparison of aws_cloudtrail.s3_bucket_name against aws_s3_bucket_logging.bucket (resolving aws_s3_bucket.x refs to bucket names); Tirith cannot compare two resources' attributes or follow instance-level references. |
+| `cis-aws/cloudtrail/cloudtrail-logs-bucket-not-public` | cross-resource-reference | not expressible | Requires resolving aws_cloudtrail.s3_bucket_name and aws_s3_bucket_public_access_block.bucket to the same bucket name per instance (across three candidate resource types); Tirith has no cross-resource attribute comparison or instance-level reference resolution. |
+| `cis-aws/iam/iam-no-admin-privileges-allowed-by-policies` | policy-document-statement | not expressible | Needs a per-statement conjunction (effect==Allow AND actions contains '*' AND resources contains '*' on the same statement) over data-source values that live only in tfstate; the json provider's wildcard cannot bind three fields of one statement and cannot filter to mode=data/type=aws_iam_policy_document. |
+| `cis-aws/s3/s3-enable-object-logging-for-events` | cross-resource-reference | not expressible | Needs per-instance comparison of aws_s3_bucket.bucket against arn-prefix-trimmed aws_cloudtrail data_resource values, plus a per-event_selector conjunction (include_management_events AND read_write_type AND data_resource.type); Tirith has neither cross-resource attribute comparison nor per-nested-block conjunction. |
+| `cis-aws/s3/s3-require-ssl` | policy-document-statement | not expressible | Needs resolution of the aws_s3_bucket_policy.policy reference to a data.aws_iam_policy_document (in tfstate or tfconfig), a per-statement conjunction of effect=Deny AND s3 actions AND condition{test=Bool,variable=aws:SecureTransport,values contains false}, and bucket-to-policy pairing; Tirith has none of these. |
+| `cloud-agnostic/allowed-datasources` | datasource-allowlist | not expressible | Data source types are not exposed by any Tirith operation: plan-time-read data sources are absent from resource_changes, and the json provider cannot filter planned_values resources by mode or recurse child_modules, so an allowlist would also reject every managed resource. |
+| `cloud-agnostic/allowed-provisioners` | provisioner-allowlist | not expressible | Provisioners exist only in the Terraform configuration (tfconfig.provisioners); no Tirith operation reads them. |
+| `cloud-agnostic/evaluate-variables-in-nested-modules` | tfconfig-only | not expressible | The verdict is a constant pass, but the policy's entire effect is printing module-call inputs from tfconfig.module_calls[*].config, which Tirith has no operation for. |
+| `cloud-agnostic/http-examples/asteroids` | http | not expressible | Requires the http import, time.now and float parsing; Tirith has no external HTTP data source. |
+| `cloud-agnostic/http-examples/check-external-http-api` | http | not expressible | Requires the http import; Tirith has no external HTTP data source. |
+| `cloud-agnostic/http-examples/use-latest-module-versions` | module-version | not expressible | Needs HTTP registry lookups, tfconfig module sources/version constraints and semver satisfies(); none exist in Tirith. |
+| `cloud-agnostic/http-examples/use-recent-versions-from-pmr` | module-version | not expressible | Needs HTTP registry lookups, tfconfig module sources/version constraints and semver satisfies(); none exist in Tirith. |
+| `cloud-agnostic/limit-cost-by-workspace-name` | cost | not expressible | Requires tfrun.workspace.name to select the limit and to fail unmatched names; Tirith has no workspace metadata, and a per-workflow -var limit would silently pass unmatched workspaces. |
+| `cloud-agnostic/prevent-non-root-providers` | tfconfig-only | not expressible | Needs tfconfig.providers with module_address, config keys and version_constraint; Tirith's provider_config exposes settings of one named provider, not the set of provider blocks per module. |
+| `cloud-agnostic/prevent-remote-exec-provisioners-on-null-resources` | provisioner-denylist | not expressible | Provisioners and their resource_address exist only in tfconfig.provisioners; no Tirith operation reads them. |
+| `cloud-agnostic/prohibited-local-exec-commands` | provisioner-denylist | not expressible | Needs tfconfig.provisioners[*].config.command constant_value/references; no Tirith operation reads provisioners. |
+| `cloud-agnostic/prohibited-provisioners` | provisioner-denylist | not expressible | Provisioners exist only in tfconfig.provisioners; no Tirith operation reads them. |
+| `cloud-agnostic/require-all-modules-have-version-constraint` | module-version | not expressible | Module call version constraints live only in tfconfig.module_calls; no Tirith operation exposes module calls. |
+| `cloud-agnostic/require-all-providers-have-version-constraint` | provider-version | not expressible | Needs quantification over all tfconfig.providers with version_constraint; Tirith's provider_config addresses one named provider's settings and cannot iterate the open set of providers. |
+| `cloud-agnostic/require-all-resources-from-pmr` | module-source | not expressible | Needs module_call.source from tfconfig and tfrun.is_destroy; neither exists (json on resource_changes.*.module_address could catch root-module resources, but not module sources). |
+| `cloud-agnostic/restrict-remote-state` | other | not expressible | The allowed list is selected by tfrun.workspace.name, which Tirith lacks; reading config.workspaces.name from a state file via the json provider is possible but cannot filter by resource type or fail unmatched workspace names. |
+| `cloud-agnostic/restrict-resources-by-module-source` | module-source | not expressible | Needs module_calls[*].source resolved through module_address ancestry (tfconfig); Tirith sees module_address only, never module sources. |
+| `cloud-agnostic/validate-variables-have-descriptions` | tfconfig-only | not expressible | Variables exist only in tfconfig.variables; no Tirith operation reads them. |
diff --git a/.claude/skills/tirith-migrate/reference/sentinel.md b/.claude/skills/tirith-migrate/reference/sentinel.md
new file mode 100644
index 00000000..b87e7dcd
--- /dev/null
+++ b/.claude/skills/tirith-migrate/reference/sentinel.md
@@ -0,0 +1,126 @@
+# Sentinel to Tirith
+
+Measured, not estimated. Every table here was built by classifying the 110 policies in
+`hashicorp/terraform-sentinel-policies` (AWS, Azure, GCP, VMware, cloud-agnostic) and
+`hashicorp/policy-library-CIS-Policy-Set-for-AWS-Terraform`, then checking the Tirith side against
+the engine.
+
+## What to expect
+
+| | exact | approximate | not expressible |
+| --- | --- | --- | --- |
+| All 110 | 41 | 40 | 29 |
+| CIS AWS (32) | 13 | 14 | 5 |
+| Cloud-specific (48) | 25 | 17 | 6 |
+| Cloud-agnostic (30) | 3 | 9 | 18 |
+
+Attribute policies on a single resource type translate exactly. The cloud-agnostic set is mostly
+`tfconfig` and `tfrun` policies, which is why it does not.
+
+## Imports
+
+| Sentinel import | Tirith | Notes |
+| --- | --- | --- |
+| `tfplan/v2` | `stackguardian/terraform_plan` | The main path. Reads `resource_changes[].change.after` |
+| `tfplan/v2` `terraform_version` | operation `terraform_version` | |
+| `tfconfig/v2` provider blocks | operation `provider_config` | Only `region` (constant values) and `version_constraint`. Needs `terraform_provider_full_name` |
+| `tfconfig/v2` anything else | none | Module sources and versions, variables, outputs, provisioners, expression references. Not expressible |
+| `tfstate/v2` | `stackguardian/json` on a state file | `key_path` wildcards cannot filter by resource type, so other types are swept in |
+| `tfrun.cost_estimate` | `stackguardian/infracost` | Different data source and different numbers; total only, no percentage increase |
+| `tfrun.workspace`, `tfrun.variables`, `tfrun.is_destroy` | none | Not expressible |
+| `http` | none | Not expressible |
+
+## Common-function helpers
+
+The `tfplan-functions` library is how most public policies are written. Each helper returns the
+*violations*, so the Tirith condition is the desired state, which is the helper's opposite.
+
+| Helper | Tirith condition | Fidelity |
+| --- | --- | --- |
+| `find_resources(type)` | `terraform_resource_type` | exact |
+| `filter_attribute_is_value(a, v)` | `Equals v` | exact |
+| `filter_attribute_is_not_value(a, v)` | `NotEquals v` | exact |
+| `filter_attribute_not_in_list(a, allowed)` | `ContainedIn allowed` | exact |
+| `filter_attribute_in_list(a, forbidden)` | `NotContainedIn forbidden` | exact |
+| `filter_attribute_contains_items_from_list(a, forbidden)` | one `NotContains` evaluator per item, joined with `&&` | exact |
+| `filter_attribute_contains_items_not_in_list(a, allowed)` | none: needs a subset test | not expressible |
+| `filter_attribute_map_key_contains_items_not_in_list` | none: same | not expressible |
+| `filter_attribute_greater_than_value(a, n)` | `LessThanEqualTo n` | exact |
+| `filter_attribute_less_than_value(a, n)` | `GreaterThanEqualTo n` | exact |
+| `filter_attribute_greater_than_equal_to_value(a, n)` | `LessThan n` | exact |
+| `filter_attribute_less_than_equal_to_value(a, n)` | `GreaterThan n` | exact |
+| `filter_attribute_does_not_match_regex(a, re)` | `RegexMatch re` | exact |
+| `filter_attribute_matches_regex(a, re)` | `RegexMatch re` and `!` in the expression | exact |
+| `filter_attribute_does_not_have_prefix(a, p)` | `RegexMatch "^p"` | exact |
+| `filter_attribute_does_not_have_suffix(a, s)` | `RegexMatch "s$"` | exact |
+| `case_insensitive_filter_...` | `RegexMatch "(?i)..."` | exact |
+| `filter_attribute_was_value` | none: reads `change.before` | not expressible, issue #332 |
+| `find_resources_being_destroyed()` | operation `action`, `ContainedIn ["delete"]`, `!` in the expression | approximate: `action` emits one result per action, so a replacement `["delete","create"]` does not fire |
+| `find_providers_by_type` region checks | `provider_config`, `attribute: region` | approximate: a region set from a variable is invisible |
+| `find_all_module_calls`, `get_module_source` | none | not expressible, issue #348 |
+| `find_all_provisioners` | none | not expressible |
+| `find_all_variables`, `find_all_outputs` | none | not expressible |
+| `find_datasources` | `terraform_resource_type` | approximate: data sources read at plan time are absent from `resource_changes` |
+| `limit_proposed_monthly_cost` | infracost `total_monthly_cost`, `LessThanEqualTo` | approximate: different estimator |
+| `limit_cost_and_percentage_increase` | total only | percentage not expressible |
+
+## Idioms in hand-written policies
+
+| Sentinel | Tirith |
+| --- | --- |
+| `x is v`, `x == v` | `Equals` |
+| `x is not v` | `NotEquals` |
+| `x in [...]` | `ContainedIn` |
+| `x not in [...]` | `NotContainedIn` |
+| `list contains x` | `Contains` |
+| `x matches "re"` | `RegexMatch` |
+| `x is not null`, `x is defined`, `x else default` | `IsNotEmpty`, or `error_tolerance: 2` to skip resources that lack the attribute |
+| `x is null`, `x is empty` | `IsEmpty` |
+| `< <= > >=` | `LessThan LessThanEqualTo GreaterThan GreaterThanEqualTo` |
+| `keys(r.tags) contains "Owner"` | `Contains "Owner"` on the `tags` attribute. `Contains` on a map tests its keys |
+| `all X as r { c }` | one evaluator: it fails if any resource fails |
+| `any X as r { c }` | not expressible: no existential quantifier |
+| `not any X as r { c }` | a detector evaluator and `!` in the expression |
+| `rule_a and rule_b` | `a && b`. Exact only when each rule ranges over all resources independently |
+| `length(violations) is 0` | the evaluator itself |
+| `param name default v` | `{{ var.name }}` in the value, with `-var` or `-var-path` |
+| enforcement level in `sentinel.hcl` | `meta.enforcement`, passed through to the result. The CLI treats every policy as hard-mandatory under `--fail-on-error` |
+
+Two engine behaviours to know, both verified:
+
+- A present-but-null attribute is **not** a missing attribute. `error_tolerance: 2` skips a
+ resource without the key; `"kms_key_id": null` is evaluated as null and `IsNotEmpty` fails it.
+- A value unknown until apply is absent from `change.after`. Sentinel policies that accept any
+ reference (`kms_key_id = aws_kms_key.x.arn`) see a value; Tirith sees a missing attribute.
+
+## Why 69 of 110 are not exact
+
+In order of how often each was the reason:
+
+1. **Conditional scope and per-block conjunction** (about 20). "Ingress rules where type is
+ ingress and from_port ≤ 22 ≤ to_port and cidr is 0.0.0.0/0" cannot bind the tests to one block.
+ The translation is stricter. Issue #316, `resource_filter`, is the fix.
+2. **Instance-level pairing across resources** (about 11). "Every bucket has a logging resource
+ pointing at it." `direct_references` is type-level, so one compliant helper resource satisfies
+ every bucket. Not tracked as an issue.
+3. **`tfconfig`-only data** (about 14). Module sources and versions, provisioners, variables,
+ provider version constraints. Issue #348, an HCL source provider, is research.
+4. **Unknown values** (about 4). A `kms_key_id` referencing a key created in the same plan.
+5. **`change.before` and destroys** (about 3). Issue #332.
+6. **Cost percentage, workspace metadata, `http`** (about 7). Not planned.
+7. **JSON documents inside strings** (2). IAM policy statements behind `jsonencode`. Issue #338.
+
+## From a Sentinel mock to a Tirith fixture
+
+Sentinel tests live in `test//` with `mock-tfplan-v2.sentinel` files. The
+`resource_changes` map in a mock has the same keys as `terraform show -json`: `address`, `type`,
+`name`, `mode`, `change.actions`, `change.before`, `change.after`, `change.after_unknown`.
+Transcribe the failing mock to `should-fail.json` and the passing one to `should-pass.json`,
+wrapped as `{"format_version": "1.2", "terraform_version": "...", "resource_changes": [...]}`.
+Drop `tfconfig` and `tfstate` mocks; Tirith does not read them.
+
+## Refusing well
+
+When a policy is not expressible, the reader needs three sentences: what the policy enforces,
+what Tirith cannot see, and what would change that. See
+`examples/sentinel/require-private-registry-modules/notes.md` for the shape.
diff --git a/.claude/skills/tirith-policies/SKILL.md b/.claude/skills/tirith-policies/SKILL.md
index f73acd95..bb854861 100644
--- a/.claude/skills/tirith-policies/SKILL.md
+++ b/.claude/skills/tirith-policies/SKILL.md
@@ -129,3 +129,6 @@ tirith -policy-path policy.json -input-path should-pass.json --fail-on-error; ec
| `reference/platform.md` | Evaluating against an organization's central policies |
| `reference/debug-ci.md` | Starting from a red build and ending at the rule and the resource |
| `examples/required-tags/` | A policy, a plan that fails it, and a plan that passes it |
+
+Translating existing Sentinel policies is a separate skill, `tirith-migrate`, installed alongside
+this one.
diff --git a/documentation/docs/tirith-usage/agent-skills.md b/documentation/docs/tirith-usage/agent-skills.md
index 9a7c6107..c6180c72 100644
--- a/documentation/docs/tirith-usage/agent-skills.md
+++ b/documentation/docs/tirith-usage/agent-skills.md
@@ -31,8 +31,9 @@ guess from a plausible-looking shape.
curl -fsSL https://stackguardian.github.io/tirith/skill.sh | sh
```
-Eleven markdown files and one worked example under `.claude/skills/tirith-policies/`. No config
-file, no restart, and it is picked up in any repository you copy it into.
+Two skills under `.claude/skills/`: `tirith-policies`, for writing policies, and `tirith-migrate`,
+for translating existing Sentinel policies. No config file, no restart, and they are picked up in
+any repository you copy them into.
| Flag | |
|---|---|
@@ -96,6 +97,15 @@ with a small context window pays for only what the task needs.
| `reference/debug-ci.md` | Diagnosing a red check |
| `examples/required-tags/` | A policy, a plan that fails it and a plan that passes it, so the agent can prove its own work before it hands it back |
+## Migrating from Sentinel
+
+The second skill, `tirith-migrate`, is for teams with existing HashiCorp Sentinel policies. It is a
+projection from a larger language onto a smaller one, and the skill's job is to say what survives.
+Measured against the 110 policies in HashiCorp's public libraries, 41 translate exactly, 40
+approximately, and 29 not at all. Each translation is tagged with that fidelity, every approximate
+one ships a plan on which Sentinel and Tirith disagree, and every impossible one is refused in
+words with the Tirith issue that would change it. Checkov and OPA/Rego are planned next.
+
## Two things decide whether the policy actually works
The pack teaches vocabulary. It does not run anything, and it is not a substitute for evaluating
@@ -118,7 +128,7 @@ version:
curl -fsSL https://stackguardian.github.io/tirith/skill.sh | sh
```
-Re-running is safe: it overwrites the files it owns and leaves everything else alone.
+Re-running is safe: it overwrites the files it owns, in both skills, and leaves everything else alone.
`--ref` takes a branch or a commit, which is worth knowing for a fork or a pull request. It cannot
yet take a release tag: the pack was added after `1.2.0`, so `main` is the only ref that has it,
diff --git a/documentation/static/docs/tirith-usage/agent-skills.md b/documentation/static/docs/tirith-usage/agent-skills.md
index d113f4d8..3561961f 100644
--- a/documentation/static/docs/tirith-usage/agent-skills.md
+++ b/documentation/static/docs/tirith-usage/agent-skills.md
@@ -19,8 +19,9 @@ guess from a plausible-looking shape.
curl -fsSL https://stackguardian.github.io/tirith/skill.sh | sh
```
-Eleven markdown files and one worked example under `.claude/skills/tirith-policies/`. No config
-file, no restart, and it is picked up in any repository you copy it into.
+Two skills under `.claude/skills/`: `tirith-policies`, for writing policies, and `tirith-migrate`,
+for translating existing Sentinel policies. No config file, no restart, and they are picked up in
+any repository you copy them into.
| Flag | |
|---|---|
@@ -84,6 +85,15 @@ with a small context window pays for only what the task needs.
| `reference/debug-ci.md` | Diagnosing a red check |
| `examples/required-tags/` | A policy, a plan that fails it and a plan that passes it, so the agent can prove its own work before it hands it back |
+## Migrating from Sentinel
+
+The second skill, `tirith-migrate`, is for teams with existing HashiCorp Sentinel policies. It is a
+projection from a larger language onto a smaller one, and the skill's job is to say what survives.
+Measured against the 110 policies in HashiCorp's public libraries, 41 translate exactly, 40
+approximately, and 29 not at all. Each translation is tagged with that fidelity, every approximate
+one ships a plan on which Sentinel and Tirith disagree, and every impossible one is refused in
+words with the Tirith issue that would change it. Checkov and OPA/Rego are planned next.
+
## Two things decide whether the policy actually works
The pack teaches vocabulary. It does not run anything, and it is not a substitute for evaluating
@@ -106,7 +116,7 @@ version:
curl -fsSL https://stackguardian.github.io/tirith/skill.sh | sh
```
-Re-running is safe: it overwrites the files it owns and leaves everything else alone.
+Re-running is safe: it overwrites the files it owns, in both skills, and leaves everything else alone.
`--ref` takes a branch or a commit, which is worth knowing for a fork or a pull request. It cannot
yet take a release tag: the pack was added after `1.2.0`, so `main` is the only ref that has it,
diff --git a/documentation/static/llms-full.txt b/documentation/static/llms-full.txt
index 037a0765..1c0465ee 100644
--- a/documentation/static/llms-full.txt
+++ b/documentation/static/llms-full.txt
@@ -3427,8 +3427,9 @@ guess from a plausible-looking shape.
curl -fsSL https://stackguardian.github.io/tirith/skill.sh | sh
```
-Eleven markdown files and one worked example under `.claude/skills/tirith-policies/`. No config
-file, no restart, and it is picked up in any repository you copy it into.
+Two skills under `.claude/skills/`: `tirith-policies`, for writing policies, and `tirith-migrate`,
+for translating existing Sentinel policies. No config file, no restart, and they are picked up in
+any repository you copy them into.
| Flag | |
|---|---|
@@ -3492,6 +3493,15 @@ with a small context window pays for only what the task needs.
| `reference/debug-ci.md` | Diagnosing a red check |
| `examples/required-tags/` | A policy, a plan that fails it and a plan that passes it, so the agent can prove its own work before it hands it back |
+## Migrating from Sentinel
+
+The second skill, `tirith-migrate`, is for teams with existing HashiCorp Sentinel policies. It is a
+projection from a larger language onto a smaller one, and the skill's job is to say what survives.
+Measured against the 110 policies in HashiCorp's public libraries, 41 translate exactly, 40
+approximately, and 29 not at all. Each translation is tagged with that fidelity, every approximate
+one ships a plan on which Sentinel and Tirith disagree, and every impossible one is refused in
+words with the Tirith issue that would change it. Checkov and OPA/Rego are planned next.
+
## Two things decide whether the policy actually works
The pack teaches vocabulary. It does not run anything, and it is not a substitute for evaluating
@@ -3514,7 +3524,7 @@ version:
curl -fsSL https://stackguardian.github.io/tirith/skill.sh | sh
```
-Re-running is safe: it overwrites the files it owns and leaves everything else alone.
+Re-running is safe: it overwrites the files it owns, in both skills, and leaves everything else alone.
`--ref` takes a branch or a commit, which is worth knowing for a fork or a pull request. It cannot
yet take a release tag: the pack was added after `1.2.0`, so `main` is the only ref that has it,
diff --git a/documentation/static/skill.sh b/documentation/static/skill.sh
index 92d5b8c2..9cb4d794 100644
--- a/documentation/static/skill.sh
+++ b/documentation/static/skill.sh
@@ -8,10 +8,10 @@
# its source is documentation/static/skill.sh in StackGuardian/tirith, so the version you
# are about to pipe into a shell is the version you can read in the repository.
#
-# What it does: downloads the skill into .claude/skills/tirith-policies/ -- eleven markdown
-# files plus a worked example (a policy, a plan that fails it, a plan that passes it) -- and,
-# with --cursor, one rule file into .cursor/rules/. It creates directories, writes those
-# files, and nothing else. No package is installed, no PATH is changed, nothing is executed
+# What it does: downloads two skills into .claude/skills/ -- tirith-policies (eleven markdown
+# files plus a worked example) and tirith-migrate (Sentinel-to-Tirith translation, with its
+# classified corpus and five worked examples) -- and, with --cursor, one rule file into
+# .cursor/rules/. It creates directories, writes those files, and nothing else. No package is installed, no PATH is changed, nothing is executed
# after download, and it never touches a file it did not create.
#
# Flags:
@@ -29,11 +29,43 @@ REF="main"
PACK=".claude/skills/tirith-policies"
DEST="."
CURSOR=0
+MIGRATE_PACK=".claude/skills/tirith-migrate"
REFERENCES="schema validate verdicts terraform-plan other-providers variables install pipelines platform debug-ci"
EXAMPLE="examples/required-tags"
EXAMPLE_FILES="README.md policy.json should-fail.json should-pass.json"
+# tirith-migrate, relative to its own pack root. One path per line so the list stays diffable.
+MIGRATE_FILES="SKILL.md
+reference/sentinel.md
+reference/sentinel-corpus.md
+examples/sentinel/README.md
+examples/sentinel/restrict-instance-type/source.sentinel
+examples/sentinel/restrict-instance-type/notes.md
+examples/sentinel/restrict-instance-type/policy.json
+examples/sentinel/restrict-instance-type/variables.json
+examples/sentinel/restrict-instance-type/should-fail.json
+examples/sentinel/restrict-instance-type/should-pass.json
+examples/sentinel/mandatory-tags/source.sentinel
+examples/sentinel/mandatory-tags/notes.md
+examples/sentinel/mandatory-tags/policy.json
+examples/sentinel/mandatory-tags/should-fail.json
+examples/sentinel/mandatory-tags/should-pass.json
+examples/sentinel/prevent-database-destroy/source.sentinel
+examples/sentinel/prevent-database-destroy/notes.md
+examples/sentinel/prevent-database-destroy/policy.json
+examples/sentinel/prevent-database-destroy/should-fail.json
+examples/sentinel/prevent-database-destroy/should-pass.json
+examples/sentinel/prevent-database-destroy/diverges.json
+examples/sentinel/restrict-ssh-ingress/source.sentinel
+examples/sentinel/restrict-ssh-ingress/notes.md
+examples/sentinel/restrict-ssh-ingress/policy.json
+examples/sentinel/restrict-ssh-ingress/should-fail.json
+examples/sentinel/restrict-ssh-ingress/should-pass.json
+examples/sentinel/restrict-ssh-ingress/diverges.json
+examples/sentinel/require-private-registry-modules/source.sentinel
+examples/sentinel/require-private-registry-modules/notes.md"
+
while [ $# -gt 0 ]; do
case "$1" in
--global) DEST="$HOME" ;;
@@ -71,6 +103,11 @@ done
for f in $EXAMPLE_FILES; do
fetch "$BASE/$EXAMPLE/$f" "$TMP/$EXAMPLE/$f"
done
+MBASE="https://raw.githubusercontent.com/$REPO/$REF/$MIGRATE_PACK"
+for f in $MIGRATE_FILES; do
+ mkdir -p "$TMP/migrate/$(dirname "$f")"
+ fetch "$MBASE/$f" "$TMP/migrate/$f"
+done
mkdir -p "$TARGET/reference" "$TARGET/$EXAMPLE"
cp "$TMP/SKILL.md" "$TARGET/SKILL.md"
@@ -81,7 +118,14 @@ for f in $EXAMPLE_FILES; do
cp "$TMP/$EXAMPLE/$f" "$TARGET/$EXAMPLE/$f"
done
+MTARGET="$DEST/$MIGRATE_PACK"
+for f in $MIGRATE_FILES; do
+ mkdir -p "$MTARGET/$(dirname "$f")"
+ cp "$TMP/migrate/$f" "$MTARGET/$f"
+done
+
printf 'Installed the Tirith skill: %s\n' "$TARGET"
+printf 'Installed the migration skill: %s\n' "$MTARGET"
if [ "$CURSOR" -eq 1 ]; then
mkdir -p "$DEST/.cursor/rules"
From 5ba5b33ea731cba6f5ed358897416a24d592f35d Mon Sep 17 00:00:00 2001
From: Akash S
Date: Fri, 4 Sep 2026 16:38:09 +0530
Subject: [PATCH 05/10] Fix the destroy-guard guidance and four other defects
found by a live skill test
A second Claude session installed both skills over the network, wrote three policies from
plain-English requirements and reported what it found. Each finding was reproduced against
the engine before being fixed.
- terraform-plan.md's action example used "destroy"; plan JSON says "delete". Copied
literally, the guard exits 0 on a real delete. The section is rewritten around the fact
that `action` emits one result per action element: `NotEquals "delete"` blocks deletes and
replacements, `ContainedIn ["delete"]` with `!` blocks only a pure delete, and the order of
a replacement's two actions cannot be tested.
- The migrate example prevent-database-destroy moves from approximate to exact using the
universal form; its divergence plan becomes a second failing plan. sentinel.md and two
corpus rows are corrected the same way; one row had put a list into NotEquals, which
never equals an action string and passes everything.
- A type-scoped policy on a plan with none of that type exits 3 by default and 1 with
error_tolerance 1; there is no "nothing in scope, pass". Documented in verdicts.md and as a
trap in SKILL.md. verdicts.md's exit table no longer claims 0 for that case, and
debug-ci.md no longer sends the reader there.
- The docs page said "no restart"; a running session did not see the newly installed skills
until restarted. Qualified.
---
.claude/skills/tirith-migrate/SKILL.md | 2 +-
.../prevent-database-destroy/notes.md | 18 +++++---
.../prevent-database-destroy/policy.json | 23 +++++++---
...rges.json => should-fail-replacement.json} | 0
.../reference/sentinel-corpus.md | 4 +-
.../tirith-migrate/reference/sentinel.md | 2 +-
.claude/skills/tirith-policies/SKILL.md | 10 ++++-
.../tirith-policies/reference/debug-ci.md | 2 +-
.../reference/terraform-plan.md | 44 +++++++++++++------
.../tirith-policies/reference/verdicts.md | 12 ++++-
.../docs/tirith-usage/agent-skills.md | 5 ++-
.../static/docs/tirith-usage/agent-skills.md | 5 ++-
documentation/static/llms-full.txt | 5 ++-
documentation/static/skill.sh | 2 +-
14 files changed, 92 insertions(+), 42 deletions(-)
rename .claude/skills/tirith-migrate/examples/sentinel/prevent-database-destroy/{diverges.json => should-fail-replacement.json} (100%)
diff --git a/.claude/skills/tirith-migrate/SKILL.md b/.claude/skills/tirith-migrate/SKILL.md
index 9ee1c8ba..62c44c61 100644
--- a/.claude/skills/tirith-migrate/SKILL.md
+++ b/.claude/skills/tirith-migrate/SKILL.md
@@ -83,6 +83,6 @@ libraries, each with its Sentinel source, the Tirith policy, and the plans that
| --- | --- | --- |
| `restrict-instance-type` | exact | `filter_attribute_not_in_list` to `ContainedIn`; `param` to `-var` |
| `mandatory-tags` | exact | Tag keys via `Contains` on the map; one evaluator per key and type |
-| `prevent-database-destroy` | approximate | `action` catches a delete but not a replacement |
+| `prevent-database-destroy` | exact | `action` emits one result per action; `NotEquals "delete"` catches deletes and replacements |
| `restrict-ssh-ingress` | approximate | The per-block conjunction collapses to a stricter rule |
| `require-private-registry-modules` | not expressible | A `tfconfig` policy, refused in words |
diff --git a/.claude/skills/tirith-migrate/examples/sentinel/prevent-database-destroy/notes.md b/.claude/skills/tirith-migrate/examples/sentinel/prevent-database-destroy/notes.md
index 16b8141a..dbca7546 100644
--- a/.claude/skills/tirith-migrate/examples/sentinel/prevent-database-destroy/notes.md
+++ b/.claude/skills/tirith-migrate/examples/sentinel/prevent-database-destroy/notes.md
@@ -1,15 +1,19 @@
-# prevent-database-destroy: approximate
+# prevent-database-destroy: exact
`find_resources_being_destroyed()` selects resources whose actions contain `"delete"`, which
includes a replacement (`["delete", "create"]`). Tirith's `action` operation emits one result per
-action in the list, so `ContainedIn ["delete"]` on a replacement yields one pass and one fail, the
-evaluator fails, and `!` turns that into a pass. **A replacement does not fire the guard.**
+action in the list, so the universal form is what matches: `NotEquals "delete"` with no negation.
+Every action must be something other than delete, and a replacement's `delete` element fails it.
+
+The tempting form, `ContainedIn ["delete"]` with `!` in the expression, is a different policy: on a
+replacement it yields one pass and one fail, the evaluator fails, and `!` flips that to a pass. Use
+it only when the source policy deliberately allows replacements.
+
+`error_tolerance: 1` skips a plan with no `aws_db_instance`, which Sentinel's empty filter also
+passed. Without it the guard exits `3` on every plan that has no database.
| Plan | Sentinel | Tirith |
| --- | --- | --- |
| `should-fail.json` (pure delete) | fail | exit 3 |
+| `should-fail-replacement.json` (delete and create) | fail | exit 3 |
| `should-pass.json` (in-place update) | pass | exit 0 |
-| `diverges.json` (replacement) | **fail** | **exit 0** |
-
-Say this to the reader. A team that migrates a destroy guard and later sees a forced replacement
-sail through will not think of it as a fidelity note.
diff --git a/.claude/skills/tirith-migrate/examples/sentinel/prevent-database-destroy/policy.json b/.claude/skills/tirith-migrate/examples/sentinel/prevent-database-destroy/policy.json
index 609371d1..d074ac4a 100644
--- a/.claude/skills/tirith-migrate/examples/sentinel/prevent-database-destroy/policy.json
+++ b/.claude/skills/tirith-migrate/examples/sentinel/prevent-database-destroy/policy.json
@@ -6,11 +6,20 @@
"description": "No aws_db_instance is deleted by this plan",
"enforcement": "hard-mandatory"
},
- "evaluators": [{
- "id": "database_deleted",
- "description": "Detects a delete action on any aws_db_instance",
- "provider_args": {"operation_type": "action", "terraform_resource_type": "aws_db_instance"},
- "condition": {"type": "ContainedIn", "value": ["delete"], "error_tolerance": 1}
- }],
- "eval_expression": "!database_deleted"
+ "evaluators": [
+ {
+ "id": "no_database_delete",
+ "description": "Every action on every aws_db_instance is something other than delete",
+ "provider_args": {
+ "operation_type": "action",
+ "terraform_resource_type": "aws_db_instance"
+ },
+ "condition": {
+ "type": "NotEquals",
+ "value": "delete",
+ "error_tolerance": 1
+ }
+ }
+ ],
+ "eval_expression": "no_database_delete"
}
diff --git a/.claude/skills/tirith-migrate/examples/sentinel/prevent-database-destroy/diverges.json b/.claude/skills/tirith-migrate/examples/sentinel/prevent-database-destroy/should-fail-replacement.json
similarity index 100%
rename from .claude/skills/tirith-migrate/examples/sentinel/prevent-database-destroy/diverges.json
rename to .claude/skills/tirith-migrate/examples/sentinel/prevent-database-destroy/should-fail-replacement.json
diff --git a/.claude/skills/tirith-migrate/reference/sentinel-corpus.md b/.claude/skills/tirith-migrate/reference/sentinel-corpus.md
index 15c28876..14c7a178 100644
--- a/.claude/skills/tirith-migrate/reference/sentinel-corpus.md
+++ b/.claude/skills/tirith-migrate/reference/sentinel-corpus.md
@@ -37,7 +37,7 @@ if it is not listed, classify it with `reference/sentinel.md` and add a row.
| `cis-aws/rds/rds-public-access-disabled` | attribute-equals | exact | Sentinel treats a missing attribute as compliant; error_tolerance=2 reproduces that, and the provider default false makes the attribute always present anyway. |
| `cis-aws/s3/s3-block-public-access-account-level` | attribute-equals | exact | All four attributes must be true on every resource; conjunction of all-quantified evaluators equals Sentinel's per-resource conjunction here since a single false anywhere fails both. |
| `cloud-agnostic/prevent-destruction-of-prohibited-resources` | destroy-guard | exact | One action evaluator per type with value ["delete"] matches Sentinel's delete-and-not-create/update filter exactly (replacements pass in both), and error_tolerance 1 makes absent types skip as Sentinel's empty filter passes. |
-| `cloud-agnostic/prevent-tfe-provider-workspace-deletion` | destroy-guard | exact | action NotEquals ["delete"] on tfe_workspace is the same whole-list comparison as Sentinel's `is not ["delete"]`; error_tolerance 1 skips when no tfe_workspace exists, matching Sentinel's vacuous all. |
+| `cloud-agnostic/prevent-tfe-provider-workspace-deletion` | destroy-guard | exact | Sentinel refuses only `actions is ["delete"]`, a pure delete; Tirith `action ContainedIn ["delete"]` with `!` fires only on a pure delete because a replacement's `create` element fails the detector. `error_tolerance` 1 skips when no tfe_workspace exists, matching the empty filter. |
| `cloud-agnostic/restrict-panos-srgs` | attribute-not-contains | exact | The wildcard walks every rule block and NotContains "any" fails on the same rules Sentinel's contains check does; error_tolerance 2 skips null lists and destroyed SRGs, as Sentinel's find_resources and null handling do. |
| `gcp/enforce-mandatory-labels` | tag-keys-required | exact | Same shape as the tags policies, on the labels map. |
| `gcp/restrict-gce-machine-type` | attribute-in-list | exact | Single-attribute ContainedIn with identical null handling. |
@@ -48,7 +48,7 @@ if it is not listed, classify it with `reference/sentinel.md` and add a row.
| `vmware/restrict-vm-cpu-and-memory` | attribute-numeric | exact | Two numeric bounds on one resource type with identical null handling. |
| `vmware/restrict-vm-disk-size` | attribute-numeric | exact | A single attribute across a nested block list via the * wildcard; Sentinel's per-VM 'any disk violates' collapses to the same per-resource fail. |
| `aws/enforce_s3_encryption` | cross-resource-reference | approximate | Tirith can require every aws_s3_bucket_server_side_encryption_configuration to use AES256/aws:kms and every aws_s3_bucket to be referenced by one (direct_references, type-level), but loses the tfconfig-reference pairing of a specific config to a specific bucket and the legacy branch that demands inline aws:kms only. |
-| `aws/protect-against-rds-instance-deletion` | destroy-guard | approximate | Tirith action NotEquals ['delete'] blocks every pure destroy of aws_db_instance (replace still allowed, matching Sentinel), but cannot read change.before so it is stricter: destroys of instances with deletion_protection=false are also blocked. |
+| `aws/protect-against-rds-instance-deletion` | destroy-guard | approximate | Tirith `action ContainedIn ["delete"]` with `!` blocks a pure delete of aws_db_instance (a replacement still passes, matching Sentinel), but cannot read change.before so it is stricter: destroys of instances with deletion_protection=false are also blocked. |
| `aws/restrict-ami-owners` | attribute-in-list | approximate | Only reachable by feeding the state file to the json provider; the wildcard path cannot be filtered to mode=data/type=aws_ami, so any other state resource exposing an owners attribute (e.g. aws_ami_ids) is also checked (stricter), and the list-of-lists ContainedIn semantics are undefined. |
| `aws/restrict-assumed-roles` | provider-region | approximate | provider_config can read constant provider settings, but Sentinel also resolves a role_arn given as var.X through tfplan.variables; Tirith sees no constant there and would fail/skip that provider. |
| `aws/restrict-current-ec2-instance-type` | attribute-in-list | approximate | Requires the state file via the json provider; the wildcard cannot select type=aws_instance, so other state resources with an instance_type attribute (aws_launch_configuration, aws_spot_instance_request) are also checked (stricter). |
diff --git a/.claude/skills/tirith-migrate/reference/sentinel.md b/.claude/skills/tirith-migrate/reference/sentinel.md
index b87e7dcd..0a3fdebc 100644
--- a/.claude/skills/tirith-migrate/reference/sentinel.md
+++ b/.claude/skills/tirith-migrate/reference/sentinel.md
@@ -55,7 +55,7 @@ The `tfplan-functions` library is how most public policies are written. Each hel
| `filter_attribute_does_not_have_suffix(a, s)` | `RegexMatch "s$"` | exact |
| `case_insensitive_filter_...` | `RegexMatch "(?i)..."` | exact |
| `filter_attribute_was_value` | none: reads `change.before` | not expressible, issue #332 |
-| `find_resources_being_destroyed()` | operation `action`, `ContainedIn ["delete"]`, `!` in the expression | approximate: `action` emits one result per action, so a replacement `["delete","create"]` does not fire |
+| `find_resources_being_destroyed()` | operation `action`, `NotEquals "delete"`, no negation | exact. `action` emits one result per action, so this fails on a replacement too. If the source excludes replacements, use `ContainedIn ["delete"]` with `!` instead |
| `find_providers_by_type` region checks | `provider_config`, `attribute: region` | approximate: a region set from a variable is invisible |
| `find_all_module_calls`, `get_module_source` | none | not expressible, issue #348 |
| `find_all_provisioners` | none | not expressible |
diff --git a/.claude/skills/tirith-policies/SKILL.md b/.claude/skills/tirith-policies/SKILL.md
index bb854861..a00ad819 100644
--- a/.claude/skills/tirith-policies/SKILL.md
+++ b/.claude/skills/tirith-policies/SKILL.md
@@ -87,8 +87,14 @@ and invert it.
- **One evaluator, one result per matching resource.** Three buckets give three results from one
rule, and the check fails if any of them fails.
- **Missing attribute is severity 2, missing resource type is severity 1.** With
- `error_tolerance: 2` a resource lacking the attribute is *skipped*, not failed, which can turn
- the whole policy into `final_result: null`. Skipping is not passing.
+ `error_tolerance: 2` a resource lacking the attribute is *skipped*, not failed. If every
+ evaluator is skipped the policy is `final_result: null`, exit `1`. Skipping is not passing.
+- **A type-scoped policy refuses a plan with none of that type.** Severity 1 under the default
+ tolerance is exit `3`; with `error_tolerance: 1` it is exit `1`. Neither is `0`. See
+ `reference/verdicts.md`.
+- **The delete action is spelled `delete`.** `"destroy"` matches nothing and the guard exits `0`.
+ `action` emits one result per action: `NotEquals "delete"` blocks deletes and replacements,
+ `ContainedIn ["delete"]` with `!` blocks only a pure delete. See `reference/terraform-plan.md`.
- **An unknown `condition.type` fails with no error attached.** It exits `3` and reads like a
real violation. Check the type against the closed list, not your memory.
- **`tirith lint` does not ship.** Do not put it in a pipeline. `reference/validate.md` has
diff --git a/.claude/skills/tirith-policies/reference/debug-ci.md b/.claude/skills/tirith-policies/reference/debug-ci.md
index 892bf9cd..ff94582c 100644
--- a/.claude/skills/tirith-policies/reference/debug-ci.md
+++ b/.claude/skills/tirith-policies/reference/debug-ci.md
@@ -13,7 +13,7 @@ echo $?
| --- | --- | --- |
| `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 |
+| `0` but you expected a failure | The policy matched nothing (wrong key, wrong operation), 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.
diff --git a/.claude/skills/tirith-policies/reference/terraform-plan.md b/.claude/skills/tirith-policies/reference/terraform-plan.md
index d3b546e1..65a9cfd2 100644
--- a/.claude/skills/tirith-policies/reference/terraform-plan.md
+++ b/.claude/skills/tirith-policies/reference/terraform-plan.md
@@ -29,27 +29,45 @@ Arguments: `terraform_resource_type` selects the resources (`"*"` = every type),
## `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`:
+nothing about a destroy is visible through it. Use `action`.
+
+## `action` emits one result per action
+
+A resource's `change.actions` is a list: `["create"]`, `["update"]`, `["delete"]`, or for a
+replacement `["delete", "create"]` / `["create", "delete"]`. The `action` operation emits **one
+result per element**, and the evaluator fails if any element fails. Two forms follow from that,
+and they mean different things:
+
+**Block every delete, including a replacement.** The universal form: every action must be
+something other than `delete`. No negation.
```json
{
- "id": "no_database_destroy",
- "provider_args": {
- "operation_type": "action",
- "terraform_resource_type": "aws_db_instance"
- },
- "condition": {"type": "ContainedIn", "value": ["destroy"]}
+ "id": "no_database_delete",
+ "provider_args": {"operation_type": "action", "terraform_resource_type": "aws_db_instance"},
+ "condition": {"type": "NotEquals", "value": "delete"}
}
```
-with `"eval_expression": "!no_database_destroy"` — the check *detects* a destroy, and `!` turns
-detection into refusal.
+with `"eval_expression": "no_database_delete"`. Exit `3` on `["delete"]` and on
+`["delete", "create"]`; exit `0` on `["update"]`.
+
+**Block only a pure delete, allow a replacement.** The detector form: `ContainedIn ["delete"]`
+passes on a `delete` element and fails on any other, so on a replacement the evaluator has one
+pass and one fail, fails as a whole, and `!` turns that into a pass.
+
+```json
+{"condition": {"type": "ContainedIn", "value": ["delete"]}}
+```
+
+with `"eval_expression": "!no_database_delete"`. Exit `3` only on `["delete"]`.
-## Replacement is two actions, not one
+Two traps, both verified against the engine:
-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`.
+- The action is spelled `delete`, never `destroy`. `"value": ["destroy"]` matches nothing and the
+ policy exits `0` on a real delete.
+- The order of `["delete", "create"]` versus `["create", "delete"]` cannot be tested. Each element
+ is evaluated on its own.
## `count` measures the module, not the change
diff --git a/.claude/skills/tirith-policies/reference/verdicts.md b/.claude/skills/tirith-policies/reference/verdicts.md
index eef3c642..5017ed98 100644
--- a/.claude/skills/tirith-policies/reference/verdicts.md
+++ b/.claude/skills/tirith-policies/reference/verdicts.md
@@ -12,7 +12,7 @@ Add `--json` to get the result document instead of the pretty printer.
| Exit | Meaning |
| --- | --- |
-| `0` | Policies passed, or nothing was in scope to gate on |
+| `0` | Every check passed |
| `1` | Tirith could not tell you either way — bad input, an unevaluable policy, or every check skipped |
| `3` | A policy ran and said no |
| `130` | Interrupted |
@@ -28,6 +28,16 @@ policy violation and cannot tell a working gate from a broken one.
That is the historical behaviour, kept so upgrading cannot turn a passing pipeline red. Any real
gate needs the flag.
+## A type-scoped policy refuses a plan that has none of the type
+
+`terraform_resource_type: "aws_db_instance"` on a plan with no database is severity `1`, "resource
+type not found". Under the default `error_tolerance: 0` that is a **failure, exit `3`**, so the
+policy refuses every unrelated plan. With `error_tolerance: 1` it is skipped instead; if it was the
+only evaluator, `final_result` is `null` and the exit is `1`. There is no setting that yields
+"nothing in scope, pass" for a single-evaluator scoped policy. Choose deliberately: `1` with CI
+treating exit `1` as advisory for that policy, or put several types' checks in one policy so a
+skip on one leaves a verdict from the others. Not tracked as a Tirith issue at the time of writing.
+
## `final_result: null` is not a pass
It means **every check was skipped** — nothing was evaluated. Under `--fail-on-error` that exits
diff --git a/documentation/docs/tirith-usage/agent-skills.md b/documentation/docs/tirith-usage/agent-skills.md
index c6180c72..6e3dc781 100644
--- a/documentation/docs/tirith-usage/agent-skills.md
+++ b/documentation/docs/tirith-usage/agent-skills.md
@@ -32,8 +32,9 @@ curl -fsSL https://stackguardian.github.io/tirith/skill.sh | sh
```
Two skills under `.claude/skills/`: `tirith-policies`, for writing policies, and `tirith-migrate`,
-for translating existing Sentinel policies. No config file, no restart, and they are picked up in
-any repository you copy them into.
+for translating existing Sentinel policies. No config file, and they are picked up in any
+repository you copy them into. A session that is already running may not see a newly installed
+skill until it is restarted; a new session sees it immediately.
| Flag | |
|---|---|
diff --git a/documentation/static/docs/tirith-usage/agent-skills.md b/documentation/static/docs/tirith-usage/agent-skills.md
index 3561961f..eb688a3c 100644
--- a/documentation/static/docs/tirith-usage/agent-skills.md
+++ b/documentation/static/docs/tirith-usage/agent-skills.md
@@ -20,8 +20,9 @@ curl -fsSL https://stackguardian.github.io/tirith/skill.sh | sh
```
Two skills under `.claude/skills/`: `tirith-policies`, for writing policies, and `tirith-migrate`,
-for translating existing Sentinel policies. No config file, no restart, and they are picked up in
-any repository you copy them into.
+for translating existing Sentinel policies. No config file, and they are picked up in any
+repository you copy them into. A session that is already running may not see a newly installed
+skill until it is restarted; a new session sees it immediately.
| Flag | |
|---|---|
diff --git a/documentation/static/llms-full.txt b/documentation/static/llms-full.txt
index 1c0465ee..b73f7f33 100644
--- a/documentation/static/llms-full.txt
+++ b/documentation/static/llms-full.txt
@@ -3428,8 +3428,9 @@ curl -fsSL https://stackguardian.github.io/tirith/skill.sh | sh
```
Two skills under `.claude/skills/`: `tirith-policies`, for writing policies, and `tirith-migrate`,
-for translating existing Sentinel policies. No config file, no restart, and they are picked up in
-any repository you copy them into.
+for translating existing Sentinel policies. No config file, and they are picked up in any
+repository you copy them into. A session that is already running may not see a newly installed
+skill until it is restarted; a new session sees it immediately.
| Flag | |
|---|---|
diff --git a/documentation/static/skill.sh b/documentation/static/skill.sh
index 9cb4d794..261819c4 100644
--- a/documentation/static/skill.sh
+++ b/documentation/static/skill.sh
@@ -56,7 +56,7 @@ examples/sentinel/prevent-database-destroy/notes.md
examples/sentinel/prevent-database-destroy/policy.json
examples/sentinel/prevent-database-destroy/should-fail.json
examples/sentinel/prevent-database-destroy/should-pass.json
-examples/sentinel/prevent-database-destroy/diverges.json
+examples/sentinel/prevent-database-destroy/should-fail-replacement.json
examples/sentinel/restrict-ssh-ingress/source.sentinel
examples/sentinel/restrict-ssh-ingress/notes.md
examples/sentinel/restrict-ssh-ingress/policy.json
From bde4f4f3cf24f41b2df176ac8e036838b49823a2 Mon Sep 17 00:00:00 2001
From: Akash S
Date: Fri, 4 Sep 2026 16:43:26 +0530
Subject: [PATCH 06/10] Warn about skip-masking (#293) and null list attributes
in both skills
The live test's third phase migrated a four-policy Sentinel set and found that the skill's own
restrict-ssh-ingress example returns no verdict when the plan also holds a security group with
no ingress blocks: the tolerated skip erases the bastion's failure (issue #293). Both skills now
say that the error_tolerance-2 "where the attribute exists" idiom is unsafe on mixed plans until
#293 is fixed, and the plan reference recommends one evaluator per type over a wildcard with
tolerance.
Also from that phase: an unset optional list renders as null in plan JSON and Contains or
NotContains on null is a hard unsupported-type failure that no tolerance forgives, so the
`x else default` idiom row is split; scope differs even for exact rows because Sentinel's
find_resources excludes no-op and deleted resources; an approximation that drops the test a
param fed must name the orphaned param rather than ship an unread variables.json; and the
mock-to-fixture recipe now says to read the test .hcl for the expected verdict first.
---
.claude/skills/tirith-migrate/SKILL.md | 11 +++++---
.../sentinel/restrict-ssh-ingress/notes.md | 3 +++
.../tirith-migrate/reference/sentinel.md | 27 ++++++++++++++++---
.claude/skills/tirith-policies/SKILL.md | 3 +++
.../reference/terraform-plan.md | 4 +++
5 files changed, 41 insertions(+), 7 deletions(-)
diff --git a/.claude/skills/tirith-migrate/SKILL.md b/.claude/skills/tirith-migrate/SKILL.md
index 62c44c61..c815417d 100644
--- a/.claude/skills/tirith-migrate/SKILL.md
+++ b/.claude/skills/tirith-migrate/SKILL.md
@@ -37,6 +37,8 @@ page. Everything below assumes that vocabulary.
- `not expressible`: needs something Tirith lacks. Name it, and link the tracking issue.
3. **Translate `exact` and `approximate`.** Carry `meta.name` from the source policy name, put the
Sentinel enforcement level in `meta.enforcement`, and map every `param` to `{{ var.NAME }}`.
+ If an approximation drops the test a `param` fed, do not ship an unread `variables.json`: name
+ the orphaned parameter in the notes and in the report row.
4. **Refuse `not expressible` in words.** Write what the policy does, what Tirith cannot see, and
the issue that would change that. Do not write a policy that checks something adjacent.
5. **Verify every translation against the source's own tests.** Sentinel policies ship mocks under
@@ -62,8 +64,10 @@ page. Everything below assumes that vocabulary.
or a value unknown until apply is invisible.
- Configuration is not the plan. Module sources, variables, outputs, provisioners and expression
references live in `tfconfig`; Tirith reads none of them.
-- A plan that destroys a resource of the checked type currently turns the evaluator's verdict to
- `null` (Tirith issue #293). Test with plans that do not destroy, and warn the reader.
+- A resource skipped through `error_tolerance` can erase an earlier resource's failure in the
+ same evaluator (Tirith issue #293). Destroys trigger it at every tolerance; the "where the
+ attribute exists" idiom triggers it at `error_tolerance: 2`. Test with mixed plans, and put the
+ warning in the report.
## Before you hand it back
@@ -71,7 +75,8 @@ page. Everything below assumes that vocabulary.
2. Does every `approximate` row name the case where verdicts differ, and ship `diverges.json`?
3. Does every `not expressible` row link a Tirith issue or say "not tracked"?
4. Did every translated policy exit `3` on `should-fail.json` and `0` on `should-pass.json`?
-5. Is every `param` a `{{ var.NAME }}` with a `variables.json` beside the policy?
+5. Is every `param` a `{{ var.NAME }}` with a `variables.json` beside the policy, or named as
+ orphaned in the report?
6. Is every condition type and argument key taken from `schema.md`, not recalled?
## Worked examples
diff --git a/.claude/skills/tirith-migrate/examples/sentinel/restrict-ssh-ingress/notes.md b/.claude/skills/tirith-migrate/examples/sentinel/restrict-ssh-ingress/notes.md
index c2d125a4..5882b88b 100644
--- a/.claude/skills/tirith-migrate/examples/sentinel/restrict-ssh-ingress/notes.md
+++ b/.claude/skills/tirith-migrate/examples/sentinel/restrict-ssh-ingress/notes.md
@@ -15,3 +15,6 @@ and fails Tirith. Issue #316 (`resource_filter`) would make this exact.
| `diverges.json` (443 from anywhere, 22 internal) | **pass** | **exit 3** |
`error_tolerance: 2` skips a group with no ingress blocks, which Sentinel's `any` also passed.
+Until Tirith issue #293 is fixed that skip can erase the bastion's failure when both groups are
+in one plan: the evaluator reports `null` and the exit is `1`, not `3`. A translation that leans
+on `error_tolerance: 2` must say this in its report.
diff --git a/.claude/skills/tirith-migrate/reference/sentinel.md b/.claude/skills/tirith-migrate/reference/sentinel.md
index 0a3fdebc..a53a7ca0 100644
--- a/.claude/skills/tirith-migrate/reference/sentinel.md
+++ b/.claude/skills/tirith-migrate/reference/sentinel.md
@@ -74,7 +74,8 @@ The `tfplan-functions` library is how most public policies are written. Each hel
| `x not in [...]` | `NotContainedIn` |
| `list contains x` | `Contains` |
| `x matches "re"` | `RegexMatch` |
-| `x is not null`, `x is defined`, `x else default` | `IsNotEmpty`, or `error_tolerance: 2` to skip resources that lack the attribute |
+| `x is not null`, `x is defined` | `IsNotEmpty`. Handles both an absent key and an explicit `null` |
+| `x else default` | `error_tolerance: 2` skips a resource whose `change.after` lacks the key. It does **not** cover `null`: `terraform show -json` renders an unset optional list such as `cidr_blocks` as `null`, and `Contains`/`NotContains` on `null` is a hard "unsupported data type" failure that no tolerance forgives. A rule with `source_security_group_id` instead of CIDRs is a false positive under a CIDR translation, and there is no workaround today |
| `x is null`, `x is empty` | `IsEmpty` |
| `< <= > >=` | `LessThan LessThanEqualTo GreaterThan GreaterThanEqualTo` |
| `keys(r.tags) contains "Owner"` | `Contains "Owner"` on the `tags` attribute. `Contains` on a map tests its keys |
@@ -86,12 +87,28 @@ The `tfplan-functions` library is how most public policies are written. Each hel
| `param name default v` | `{{ var.name }}` in the value, with `-var` or `-var-path` |
| enforcement level in `sentinel.hcl` | `meta.enforcement`, passed through to the result. The CLI treats every policy as hard-mandatory under `--fail-on-error` |
-Two engine behaviours to know, both verified:
+Three engine behaviours to know, all verified:
- A present-but-null attribute is **not** a missing attribute. `error_tolerance: 2` skips a
- resource without the key; `"kms_key_id": null` is evaluated as null and `IsNotEmpty` fails it.
+ resource without the key; `"kms_key_id": null` is evaluated as null. `IsNotEmpty` fails it
+ cleanly; `Contains`, `NotContains`, `ContainedIn` and the ordering conditions fail it as an
+ unsupported type, which reads like a violation.
- A value unknown until apply is absent from `change.after`. Sentinel policies that accept any
reference (`kms_key_id = aws_kms_key.x.arn`) see a value; Tirith sees a missing attribute.
+- **A skipped resource can erase an earlier failure** (Tirith issue #293). In one evaluator, a
+ resource tolerated away by `error_tolerance` after a violating resource resets the verdict to
+ `null`. The violation disappears and the exit is `1`. Any translation that relies on
+ `error_tolerance: 2`, including the "where the attribute exists" idiom, is unsafe on plans that
+ mix resources with and without the attribute until #293 is fixed. Say so in the report.
+
+## Scope differs even when the test is exact
+
+`find_resources` and the `actions contains "create" or "update"` idiom exclude no-op and deleted
+resources. Tirith evaluates every `resource_changes` entry of the type, so an unchanged resource
+that already violates the rule fails the plan (Sentinel passes it), and a deleted resource is a
+severity-0 error that is skipped or, per #293, erases a sibling's failure. This applies to every
+row marked exact above: exact on the resources both tools evaluate, not on which resources are
+evaluated.
## Why 69 of 110 are not exact
@@ -112,7 +129,9 @@ In order of how often each was the reason:
## From a Sentinel mock to a Tirith fixture
-Sentinel tests live in `test//` with `mock-tfplan-v2.sentinel` files. The
+Sentinel tests live in `test//`. Each `*.hcl` there names a mock file and states the
+expected verdict (`main = false` is the failing case); mock filenames vary, so read the `.hcl`
+first. The
`resource_changes` map in a mock has the same keys as `terraform show -json`: `address`, `type`,
`name`, `mode`, `change.actions`, `change.before`, `change.after`, `change.after_unknown`.
Transcribe the failing mock to `should-fail.json` and the passing one to `should-pass.json`,
diff --git a/.claude/skills/tirith-policies/SKILL.md b/.claude/skills/tirith-policies/SKILL.md
index a00ad819..48131a4b 100644
--- a/.claude/skills/tirith-policies/SKILL.md
+++ b/.claude/skills/tirith-policies/SKILL.md
@@ -89,6 +89,9 @@ and invert it.
- **Missing attribute is severity 2, missing resource type is severity 1.** With
`error_tolerance: 2` a resource lacking the attribute is *skipped*, not failed. If every
evaluator is skipped the policy is `final_result: null`, exit `1`. Skipping is not passing.
+- **A skipped resource can erase a failure (issue #293).** In one evaluator, a resource skipped by
+ `error_tolerance` after a violating one resets the verdict to `null`, exit `1`. Destroys do this
+ at every tolerance. Prefer one evaluator per resource type over a wildcard with tolerance.
- **A type-scoped policy refuses a plan with none of that type.** Severity 1 under the default
tolerance is exit `3`; with `error_tolerance: 1` it is exit `1`. Neither is `0`. See
`reference/verdicts.md`.
diff --git a/.claude/skills/tirith-policies/reference/terraform-plan.md b/.claude/skills/tirith-policies/reference/terraform-plan.md
index 65a9cfd2..8c03a748 100644
--- a/.claude/skills/tirith-policies/reference/terraform-plan.md
+++ b/.claude/skills/tirith-policies/reference/terraform-plan.md
@@ -101,6 +101,10 @@ 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".
+ **Caveat, issue #293:** a skipped resource that follows a failing one in the same evaluator
+ resets the verdict to `null`, so the failure disappears and the exit is `1`. On a plan that mixes
+ resources with and without the attribute, this recipe can hide a violation. Until #293 is fixed,
+ prefer separate evaluators per resource type over a wildcard with tolerance 2 where you can.
On a wildcard policy every message reads identically, and only the resource address in the result
distinguishes one finding from another.
From f0374f947b97c1cdc29b16ebabc37d4174e94a51 Mon Sep 17 00:00:00 2001
From: Akash S
Date: Fri, 4 Sep 2026 17:05:13 +0530
Subject: [PATCH 07/10] Correct three claims found by a second smoke test, and
footnote the helper table
A fresh session auto-discovered both skills, ran fourteen fixtures and probes with every exit
code as predicted, and checked the text against the engine. Three claims were wrong or half
right:
- An unknown condition.type does exit 3 with `errors` empty, but its result message names the
evaluator. The skill said it was indistinguishable from a real violation, which sent the reader
to the plan instead of to the message. Six passages corrected.
- Exit 2 is never returned. The CLI catches argparse's SystemExit and exits 1, so a bad argument
or `tirith lint` is 1, not 2. The morning's correction had replaced one wrong story about exit
2 with another.
- `tirith --version` is mentioned in install.md but not where the install command is given.
The Sentinel helper table now says its fidelity column rates the test only: scope and a computed
attribute can still make a translation approximate. The smoke test's migration of an
allowed-regions policy on aws_s3_bucket was exactly that case, since `region` is usually
inherited from the provider and absent from change.after.
---
.../skills/tirith-migrate/reference/sentinel.md | 7 +++++++
.claude/skills/tirith-policies/SKILL.md | 10 ++++++----
.../examples/required-tags/README.md | 4 ++--
.../skills/tirith-policies/reference/debug-ci.md | 8 ++++----
.claude/skills/tirith-policies/reference/schema.md | 7 ++++---
.../skills/tirith-policies/reference/validate.md | 7 ++++---
.../skills/tirith-policies/reference/verdicts.md | 14 +++++++-------
.cursor/rules/tirith-policies.mdc | 4 ++--
8 files changed, 36 insertions(+), 25 deletions(-)
diff --git a/.claude/skills/tirith-migrate/reference/sentinel.md b/.claude/skills/tirith-migrate/reference/sentinel.md
index a53a7ca0..db026bdc 100644
--- a/.claude/skills/tirith-migrate/reference/sentinel.md
+++ b/.claude/skills/tirith-migrate/reference/sentinel.md
@@ -35,6 +35,13 @@ Attribute policies on a single resource type translate exactly. The cloud-agnost
The `tfplan-functions` library is how most public policies are written. Each helper returns the
*violations*, so the Tirith condition is the desired state, which is the helper's opposite.
+The fidelity column rates the **test**. The **scope** and the **attribute** can still make a
+translation approximate: Sentinel skips no-op and deleted resources and Tirith does not (see
+"Scope differs even when the test is exact"), and an attribute that is computed at apply time,
+such as `region` on a bucket that inherits it from the provider, is absent from `change.after`
+and fails in Tirith where the helper's absence-tolerant flag passed it in Sentinel (see "Unknown
+values"). Check both before marking a row exact.
+
| Helper | Tirith condition | Fidelity |
| --- | --- | --- |
| `find_resources(type)` | `terraform_resource_type` | exact |
diff --git a/.claude/skills/tirith-policies/SKILL.md b/.claude/skills/tirith-policies/SKILL.md
index 48131a4b..693a0b18 100644
--- a/.claude/skills/tirith-policies/SKILL.md
+++ b/.claude/skills/tirith-policies/SKILL.md
@@ -16,6 +16,7 @@ Not from PyPI. `pip install tirith` installs an **unrelated project**; `py-tirit
```bash
pip install "git+https://github.com/StackGuardian/tirith.git@1.2.0"
+tirith --version # 1.2.0
```
## The one rule
@@ -38,8 +39,8 @@ tirith -policy-path .tirith/policies -input-path should-fail.json --fail-on-erro
| `1` | No verdict could be reached | Fail the job, but report a **tool or input** problem |
- Never collapse `3` into `1`. A job that cannot tell them apart reports an outage as a violation.
-- `2` is argparse's usage error, not a Tirith verdict. There is no timeout code; a platform
- timeout exits `1`.
+- `2` is never returned. A bad argument or unknown subcommand exits `1`, as does a platform
+ timeout. `1` means tool, input or usage; it never means a policy said no.
- **`final_result: null` is not a pass.** Every check was skipped, nothing was evaluated, exit `1`.
- Without `--fail-on-error` the exit is always `0`. Every real gate needs the flag.
@@ -98,8 +99,9 @@ and invert it.
- **The delete action is spelled `delete`.** `"destroy"` matches nothing and the guard exits `0`.
`action` emits one result per action: `NotEquals "delete"` blocks deletes and replacements,
`ContainedIn ["delete"]` with `!` blocks only a pure delete. See `reference/terraform-plan.md`.
-- **An unknown `condition.type` fails with no error attached.** It exits `3` and reads like a
- real violation. Check the type against the closed list, not your memory.
+- **An unknown `condition.type` exits `3`, not `1`.** The message names it (`` `Exists` is not a
+ supported evaluator ``) but `errors` is empty, so CI sees a violation. Check the type against
+ the closed list, not your memory.
- **`tirith lint` does not ship.** Do not put it in a pipeline. `reference/validate.md` has
what to do instead.
diff --git a/.claude/skills/tirith-policies/examples/required-tags/README.md b/.claude/skills/tirith-policies/examples/required-tags/README.md
index 37eff391..ae4da128 100644
--- a/.claude/skills/tirith-policies/examples/required-tags/README.md
+++ b/.claude/skills/tirith-policies/examples/required-tags/README.md
@@ -23,5 +23,5 @@ Things to try:
- Add `"error_tolerance": 2` inside `condition` and run against `should-fail.json`. The check is
skipped rather than failed, `final_result` becomes `null`, and the exit is `1`, not `0`.
- Change `IsNotEmpty` to `Equals` with `"value": "product-123"` to pin one exact value.
-- Change the type to `Exists`. It does not exist, and the run exits `3` with no error attached,
- which is why an unknown condition type reads like a real violation.
+- Change the type to `Exists`. It does not exist. The run exits `3` with `errors` empty, so CI
+ sees a violation; only the result message says "`Exists` is not a supported evaluator".
diff --git a/.claude/skills/tirith-policies/reference/debug-ci.md b/.claude/skills/tirith-policies/reference/debug-ci.md
index ff94582c..e3c2f9be 100644
--- a/.claude/skills/tirith-policies/reference/debug-ci.md
+++ b/.claude/skills/tirith-policies/reference/debug-ci.md
@@ -49,10 +49,10 @@ Check `final_result` in the result document.
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.
+- **A misconfigured policy** — an unsupported `condition.type` arrives as a failed check and exits
+ `3`, not `1`; its message names the type. An unknown `provider_args` key is ignored rather than
+ rejected, so the check reads nothing. Check both against `reference/schema.md`: the fault is in
+ the policy even though the exit code points at infrastructure.
## 5. Exit `0` when you expected a failure
diff --git a/.claude/skills/tirith-policies/reference/schema.md b/.claude/skills/tirith-policies/reference/schema.md
index 36de3c5b..46947ef8 100644
--- a/.claude/skills/tirith-policies/reference/schema.md
+++ b/.claude/skills/tirith-policies/reference/schema.md
@@ -1,8 +1,9 @@
# 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.
+Both registries are closed. Inventing a value does not stop the run: an unknown
+`condition.type` becomes a **failed check**, exit `3`, with `errors` empty, so CI reads it as a
+policy violation rather than a tool problem. The result message does name it
+(`` `Exists` is not a supported evaluator ``), so read the message before debugging infrastructure.
Confirm against the live registry rather than this file. It is the source of truth:
diff --git a/.claude/skills/tirith-policies/reference/validate.md b/.claude/skills/tirith-policies/reference/validate.md
index 2ff2f6a1..bfa4327e 100644
--- a/.claude/skills/tirith-policies/reference/validate.md
+++ b/.claude/skills/tirith-policies/reference/validate.md
@@ -3,8 +3,9 @@
## `tirith lint` is not in the released package
This is the one place in the pack that explains it; the other files point here. The released CLI
-dispatches `tirith`, `tirith ui` and `tirith platform check` and nothing else, so `tirith lint`
-in a pipeline is a step that fails with an unrecognised argument. A linter is on the roadmap at
+dispatches `tirith`, `tirith ui` and `tirith platform check` and nothing else. `tirith lint` prints
+the usage text and "Failed because of System Exit" and exits `1`, which a pipeline reads as a tool
+failure on every run. A linter is on the roadmap at
`https://stackguardian.github.io/tirith/roadmap/`; do not assume it has shipped.
Two ways to validate exist today.
@@ -36,7 +37,7 @@ 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. |
+| An invented condition type | There is no `Exists`, `Matches` or `In`. The engine returns an unknown type as a failed check, exit `3`, `errors` empty. The result message does name it; the exit code does not. |
| 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. |
diff --git a/.claude/skills/tirith-policies/reference/verdicts.md b/.claude/skills/tirith-policies/reference/verdicts.md
index 5017ed98..baaa1037 100644
--- a/.claude/skills/tirith-policies/reference/verdicts.md
+++ b/.claude/skills/tirith-policies/reference/verdicts.md
@@ -17,8 +17,9 @@ Add `--json` to get the result document instead of the pretty printer.
| `3` | A policy ran and said no |
| `130` | Interrupted |
-`2` is argparse's usage error, so a caller seeing `2` passed a bad argument. Tirith has no timeout
-code: a `platform check` that times out is reported as `1`.
+`2` is never returned. The CLI catches argparse's usage error and exits `1`, so a bad argument or an
+unknown subcommand (`tirith lint` prints "Failed because of System Exit") is `1`, the same as a
+tool or input problem. Tirith has no timeout code: a `platform check` that times out is `1` too.
**`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
@@ -67,8 +68,7 @@ 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.
+An unsupported `condition.type` comes back as a failed check, exit `3`, with the `errors` array
+empty. It fails in the safe direction, but a job that branches on the exit code sees a policy
+violation. The result message names the fault (`` `Exists` is not a supported evaluator ``), so
+when a check fails on every resource at once, read the message before reading the plan.
diff --git a/.cursor/rules/tirith-policies.mdc b/.cursor/rules/tirith-policies.mdc
index 823565b1..bd799a8c 100644
--- a/.cursor/rules/tirith-policies.mdc
+++ b/.cursor/rules/tirith-policies.mdc
@@ -34,8 +34,8 @@ rejects those with `Unsupported operator in eval_expression`. `!` is the only ne
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 argparse's usage
-error, not a verdict.) `final_result: null` means every check was skipped: that is not a pass, it exits `1`, and
+**Verdicts:** exit `0` passed · `3` a policy failed · `1` no verdict was reached. (`2` is never returned;
+a bad argument exits `1`.) `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:**
From ae33c594fb49b48b51ae5efdc5fc08920d2a2a0a Mon Sep 17 00:00:00 2001
From: AKSHAT TANDON
Date: Fri, 4 Sep 2026 13:35:10 +0100
Subject: [PATCH 08/10] feat: Add Terraform and Kubernetes providers with
lessons and documentation
- Introduced Terraform plan provider with lessons covering resource attributes, actions, and counts.
- Added Kubernetes provider with lessons on manifest handling and attribute paths.
- Updated the lessons structure to support multiple providers and their respective lessons.
- Enhanced the learn page to display lessons grouped by provider, improving navigation.
- Updated styles for track headers to maintain consistency across the documentation.
- Expanded provider overview documentation to include guidance on creating new providers and examples of potential use cases.
---
documentation/DESIGN-NOTES.md | 9 +
.../docs/tirith-providers/overview.md | 48 +++
.../src/components/docs/CopyPageMenu.js | 54 +--
.../src/components/docs/brandMarks.js | 27 ++
documentation/src/data/lessons.js | 341 ++++++++++++++++++
documentation/src/data/tirithLite.js | 278 ++++++++++++--
documentation/src/pages/learn.js | 45 ++-
documentation/src/pages/learn.module.css | 46 +++
.../tirith-providers/providers-overview.md | 47 +++
documentation/static/llms-full.txt | 47 +++
10 files changed, 872 insertions(+), 70 deletions(-)
create mode 100644 documentation/src/components/docs/brandMarks.js
diff --git a/documentation/DESIGN-NOTES.md b/documentation/DESIGN-NOTES.md
index 72e9e3f2..850e4045 100644
--- a/documentation/DESIGN-NOTES.md
+++ b/documentation/DESIGN-NOTES.md
@@ -248,6 +248,15 @@ markdown, view it, or open it in ChatGPT or Claude. It serves the `.md` twin tha
`scripts/generate-llms-full.py` writes for every route, so it is a pointer at something that
already existed rather than a scrape of the rendered DOM.
+Its last two items are **the only place on this site that draws a logo other than Tirith's
+own**, which is a deliberate exception to the rule below and the reason it is written down.
+The marks are the official single-path versions from simple-icons, unmodified, in
+`src/components/docs/brandMarks.js`. The justification is recognition: "Open in ChatGPT"
+beside a generic speech bubble is a control a reader has to read, and beside the real mark it
+is one they can see. Every other glyph in the menu is stroked at the same hairline weight as
+the rest of the design; these two are filled, because stroking a wordless trademark thickens
+it into a blot and is a modification of someone else's mark.
+
The page bodies are authored Markdown and are not part of this design work.
---
diff --git a/documentation/docs/tirith-providers/overview.md b/documentation/docs/tirith-providers/overview.md
index ede6d7ca..67a674fb 100644
--- a/documentation/docs/tirith-providers/overview.md
+++ b/documentation/docs/tirith-providers/overview.md
@@ -88,3 +88,51 @@ When a provider cannot find what an operation asked for, it reports an error ins
2. **Errors without a severity value.** Some errors (an unsupported `operation_type` in the `json` and `kubernetes` providers, and all errors from the `infracost` and `sg_workflow` providers) carry no severity. These always **fail** the check, regardless of `error_tolerance`.
Each provider page below lists exactly which situation produces which severity. See also [error tolerance](../tirith-policies/tirith-policy-error-tolerance.md).
+
+## Write one for what you actually run
+
+Five providers ship. That is not a claim about what is worth gating, it is a list of what has been written so far, and the interesting policies are usually about the system nobody wrote a provider for yet.
+
+A provider is small. It is one function:
+
+```python
+def provide(provider_args: dict, input_data) -> list[dict]:
+ """Turn a document into values a condition can be run against."""
+```
+
+It receives the `provider_args` from an evaluator and the parsed input document, and it returns a list of outputs: `{"value": ...}` for something a condition can judge, or `{"value": ProviderError(severity_value=1), "err": "..."}` for something it could not find. That is the entire contract. The thirteen conditions, `eval_expression`, `error_tolerance`, the result document, the exit codes and every CI integration already work on top of it. `kubernetes/handler.py` is about fifty lines, and it is a complete provider.
+
+:::note How a provider is registered
+There is no plugin discovery and no entry point to hook: `PROVIDERS_DICT` in `src/tirith/providers/__init__.py` is a literal dictionary, so a new provider is a module plus one line in that dict. In practice that means a pull request, or a fork you install from your own git URL. Making providers loadable from outside the package is a real request and worth opening an issue for if you need it.
+:::
+
+### What people ask for
+
+The pattern that makes a good provider is narrow: **a document that describes a proposed change, available before the change is applied.** If you can get that as JSON, you can gate it.
+
+| | |
+|---|---|
+| **Other IaC formats** | CloudFormation change sets, Pulumi previews, ARM and Bicep what-if output, Helm rendered templates and values |
+| **Cloud and SaaS APIs** | AWS Config or Cloud Control, GCP asset inventory, Datadog monitors, PagerDuty schedules, an identity provider's roles |
+| **Your own APIs** | A service catalogue, a CMDB, a deployment API, an internal platform's change request. This is the one nobody else can write for you, and it is usually where the rules that matter to your organisation live |
+| **Supply chain** | An SBOM, a lockfile, a dependency manifest, image provenance and signatures |
+| **Cost and capacity** | Beyond Infracost: quota headroom, commitment coverage, a chargeback model |
+| **Compliance evidence** | Turning a control framework into checks that run on every change instead of once a quarter |
+
+### The one that does not exist yet
+
+Everything above is the same shape as what ships today: a plan, a manifest, an estimate. The shape holds somewhere less obvious.
+
+An AI agent with tools is a system that proposes changes and then applies them. Before it calls a tool, there is a document describing what it is about to do: which tool, which arguments, what it costs, what it can reach. That is a plan, in every sense that matters to a policy engine, and today almost nothing sits between an agent's intention and its action.
+
+**A provider for agent runtime decisions** would let the rules be written the same way the rest of your governance is: this agent may not call a tool that writes to production, may not spend beyond a threshold in one run, may not touch a resource outside its blast radius, may not act at all without a plan a human approved. The same thirteen conditions, the same expression grammar, the same verdict and exit code, evaluated before the call rather than in a review afterwards.
+
+This is **aspirational**. There is no such provider, it is not on the [roadmap](https://stackguardian.github.io/tirith/roadmap/) with a date, and it is written down here because it is the clearest example of the point: the engine does not care what the document is about. If you are building agent infrastructure and want a policy layer with a real evaluator behind it rather than a prompt asking a model to behave, this is worth a conversation.
+
+### Start one
+
+Open an issue describing the document you want to gate and what a rule over it would say. That is enough to work out whether it is a new provider, a new operation on an existing one, or something the `json` provider already does.
+
+- **[Propose a provider](https://github.com/StackGuardian/tirith/issues/new?template=feature_request.md&title=Provider%3A+)**: the system, the document, and one rule you would write
+- **[Read an existing one](https://github.com/StackGuardian/tirith/tree/main/src/tirith/providers/kubernetes)**: the shortest complete example in the repository
+- **[Good first issues](https://github.com/StackGuardian/tirith/labels/good%20first%20issue)**: if you would rather start somewhere smaller
diff --git a/documentation/src/components/docs/CopyPageMenu.js b/documentation/src/components/docs/CopyPageMenu.js
index d59d0b56..2344537e 100644
--- a/documentation/src/components/docs/CopyPageMenu.js
+++ b/documentation/src/components/docs/CopyPageMenu.js
@@ -1,6 +1,7 @@
import React, {useCallback, useEffect, useRef, useState} from 'react';
import {useLocation} from '@docusaurus/router';
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
+import {BRAND} from './brandMarks';
import styles from './CopyPageMenu.module.css';
/**
@@ -23,20 +24,29 @@ import styles from './CopyPageMenu.module.css';
const PROMPT = (url) =>
`Read ${url} so I can ask you questions about it. Reply with a one-line summary when you have.`;
-function Icon({d, className}) {
+/*
+ * Two kinds of mark, drawn differently on purpose.
+ *
+ * The interface glyphs below are stroked at 1.6, which is what keeps them in the same weight
+ * as the hairlines everything else on this site is built from. The vendor marks in
+ * brandMarks.js are solid single paths: stroking one would thicken it into a blot and would
+ * also be a modification of someone's trademark. So `solid` swaps stroke for fill and leaves
+ * the path exactly as published.
+ */
+function Icon({d, className, solid}) {
return (
);
}
@@ -54,16 +64,6 @@ const ICON = {
>
),
- chat: (
- <>
-
- >
- ),
- spark: (
- <>
-
- >
- ),
caret: ,
};
@@ -148,26 +148,28 @@ export default function CopyPageMenu() {
{
icon: ICON.copy,
title: 'Copy page',
- note: 'The markdown source, to your clipboard',
+ note: 'Copy as Markdown format',
onClick: copy,
},
{
icon: ICON.file,
- title: 'View as markdown',
- note: 'The plain source this page is built from',
+ title: 'View as Markdown',
+ note: 'View as plain text',
href: mdPath,
},
{
- icon: ICON.chat,
+ icon: BRAND.openai,
+ solid: true,
title: 'Open in ChatGPT',
- note: 'Ask about this page with it loaded',
+ note: 'Discuss this page in ChatGPT',
href: `https://chatgpt.com/?hints=search&q=${encodeURIComponent(PROMPT(mdUrl))}`,
external: true,
},
{
- icon: ICON.spark,
+ icon: BRAND.claude,
+ solid: true,
title: 'Open in Claude',
- note: 'Ask about this page with it loaded',
+ note: 'Discuss this page in Claude',
href: `https://claude.ai/new?q=${encodeURIComponent(PROMPT(mdUrl))}`,
external: true,
},
@@ -204,7 +206,7 @@ export default function CopyPageMenu() {
target="_blank"
rel={it.external ? 'noopener noreferrer' : 'noopener'}
onClick={() => setOpen(false)}>
-
+
{it.title}
{it.note}
@@ -217,7 +219,7 @@ export default function CopyPageMenu() {
className={styles.item}
role="menuitem"
onClick={it.onClick}>
-
+
{it.title}
{it.note}
diff --git a/documentation/src/components/docs/brandMarks.js b/documentation/src/components/docs/brandMarks.js
new file mode 100644
index 00000000..d71104b0
--- /dev/null
+++ b/documentation/src/components/docs/brandMarks.js
@@ -0,0 +1,27 @@
+/**
+ * The two vendor marks used by the copy-page menu.
+ *
+ * PROVENANCE. Both are the official single-path marks as published by simple-icons
+ * (https://simpleicons.org), which releases its icon set under CC0. Taken from
+ * simple-icons@13, viewBox `0 0 24 24`, filled rather than stroked, verbatim and unmodified.
+ * They are here rather than inline in the component because 3KB of path data in the middle of
+ * a menu makes the menu unreadable, and because a mark that is copied rather than authored
+ * should say where it came from next to itself.
+ *
+ * TRADEMARK. CC0 covers simple-icons' rendering of these marks, not the trademarks
+ * themselves, which remain OpenAI's and Anthropic's. They are used here nominatively: to name
+ * the destination of a link that opens that product with this page loaded. Do not restyle
+ * them, put them on merchandise, or use them anywhere that implies either company endorses
+ * Tirith.
+ *
+ * They are the one place on this site that draws a logo other than Tirith's own. The
+ * justification is recognition: "Open in ChatGPT" beside a generic speech bubble is a control
+ * a reader has to read, and beside the actual mark it is one they can see.
+ */
+
+export const BRAND = {
+ openai:
+ 'M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z',
+ claude:
+ 'm4.7144 15.9555 4.7174-2.6471.079-.2307-.079-.1275h-.2307l-.7893-.0486-2.6956-.0729-2.3375-.0971-2.2646-.1214-.5707-.1215-.5343-.7042.0546-.3522.4797-.3218.686.0608 1.5179.1032 2.2767.1578 1.6514.0972 2.4468.255h.3886l.0546-.1579-.1336-.0971-.1032-.0972L6.973 9.8356l-2.55-1.6879-1.3356-.9714-.7225-.4918-.3643-.4614-.1578-1.0078.6557-.7225.8803.0607.2246.0607.8925.686 1.9064 1.4754 2.4893 1.8336.3643.3035.1457-.1032.0182-.0728-.164-.2733-1.3539-2.4467-1.445-2.4893-.6435-1.032-.17-.6194c-.0607-.255-.1032-.4674-.1032-.7285L6.287.1335 6.6997 0l.9957.1336.419.3642.6192 1.4147 1.0018 2.2282 1.5543 3.0296.4553.8985.2429.8318.091.255h.1579v-.1457l.1275-1.706.2368-2.0947.2307-2.6957.0789-.7589.3764-.9107.7468-.4918.5828.2793.4797.686-.0668.4433-.2853 1.8517-.5586 2.9021-.3643 1.9429h.2125l.2429-.2429.9835-1.3053 1.6514-2.0643.7286-.8196.85-.9046.5464-.4311h1.0321l.759 1.1293-.34 1.1657-1.0625 1.3478-.8804 1.1414-1.2628 1.7-.7893 1.36.0729.1093.1882-.0183 2.8535-.607 1.5421-.2794 1.8396-.3157.8318.3886.091.3946-.3278.8075-1.967.4857-2.3072.4614-3.4364.8136-.0425.0304.0486.0607 1.5482.1457.6618.0364h1.621l3.0175.2247.7892.522.4736.6376-.079.4857-1.2142.6193-1.6393-.3886-3.825-.9107-1.3113-.3279h-.1822v.1093l1.0929 1.0686 2.0035 1.8092 2.5075 2.3314.1275.5768-.3218.4554-.34-.0486-2.2039-1.6575-.85-.7468-1.9246-1.621h-.1275v.17l.4432.6496 2.3436 3.5214.1214 1.0807-.17.3521-.6071.2125-.6679-.1214-1.3721-1.9246L14.38 17.959l-1.1414-1.9428-.1397.079-.674 7.2552-.3156.3703-.7286.2793-.6071-.4614-.3218-.7468.3218-1.4753.3886-1.9246.3157-1.53.2853-1.9004.17-.6314-.0121-.0425-.1397.0182-1.4328 1.9672-2.1796 2.9446-1.7243 1.8456-.4128.164-.7164-.3704.0667-.6618.4008-.5889 2.386-3.0357 1.4389-1.882.929-1.0868-.0062-.1579h-.0546l-6.3385 4.1164-1.1293.1457-.4857-.4554.0608-.7467.2307-.2429 1.9064-1.3114Z',
+};
diff --git a/documentation/src/data/lessons.js b/documentation/src/data/lessons.js
index b585e83c..8a11b5cc 100644
--- a/documentation/src/data/lessons.js
+++ b/documentation/src/data/lessons.js
@@ -315,4 +315,345 @@ export const LESSONS = [
];
/** What the Playground opens on. */
+
+/* ── stackguardian/terraform_plan ─────────────────────────────────────────────
+ *
+ * A different provider, deliberately taught after the json track rather than
+ * instead of it: the conditions and eval_expression are already understood by
+ * this point, so these lessons only have to teach what actually changes, which
+ * is how the provider finds a value in the first place.
+ *
+ * The plan below is a real `terraform show -json` shape, trimmed to the keys the
+ * provider reads. Every verdict on this page is computed from it in the browser.
+ */
+
+export const PLAN_DOC = `{
+ "format_version": "1.2",
+ "terraform_version": "1.9.5",
+ "resource_changes": [
+ {
+ "address": "aws_s3_bucket.assets",
+ "type": "aws_s3_bucket",
+ "name": "assets",
+ "change": {
+ "actions": ["create"],
+ "after": {
+ "bucket": "acme-assets",
+ "acl": "private",
+ "tags": {"Owner": "platform"}
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.logs",
+ "type": "aws_s3_bucket",
+ "name": "logs",
+ "change": {
+ "actions": ["create"],
+ "after": {
+ "bucket": "acme-logs",
+ "acl": "public-read",
+ "tags": {}
+ }
+ }
+ },
+ {
+ "address": "aws_instance.runner",
+ "type": "aws_instance",
+ "name": "runner",
+ "change": {
+ "actions": ["update"],
+ "after": {
+ "instance_type": "t3.large",
+ "tags": {"Owner": "ci"}
+ }
+ }
+ }
+ ]
+}`;
+
+export const TF_LESSONS = [
+ {
+ id: 'tf-attribute',
+ n: '07',
+ title: 'A plan is not a document',
+ teaches: 'terraform_resource_type · terraform_resource_attribute',
+ body:
+ 'Everything you have learned still applies. The conditions are the same thirteen and ' +
+ 'the expression grammar is unchanged. What changes is the address: there is no ' +
+ '`key_path` here, because a plan is not a tree you walk. It is a list of resource ' +
+ 'changes, so you name a **resource type** and an **attribute on it**, and the provider ' +
+ 'returns one value per matching resource.',
+ aside:
+ 'Two buckets match, so one evaluator produces two results and the failing one names ' +
+ 'the value that failed. `"*"` as the resource type means every resource in the plan, ' +
+ 'and `exclude_resource_types` narrows that back down. A missing resource type is an ' +
+ 'error of severity 1, a missing attribute is severity 2, which is why error_tolerance ' +
+ 'can tell "you have no buckets" apart from "your bucket has no acl".',
+ tryIt: 'Change `acl` on `aws_s3_bucket.logs` to `private`, and the whole plan passes.',
+ policy: `{
+ "meta": {
+ "version": "v1",
+ "required_provider": "stackguardian/terraform_plan"
+ },
+ "evaluators": [
+ {
+ "id": "buckets_private",
+ "provider_args": {
+ "operation_type": "attribute",
+ "terraform_resource_type": "aws_s3_bucket",
+ "terraform_resource_attribute": "acl"
+ },
+ "condition": {
+ "type": "Equals",
+ "value": "private"
+ }
+ }
+ ],
+ "eval_expression": "buckets_private"
+}`,
+ },
+ {
+ id: 'tf-action',
+ n: '08',
+ title: 'Gate the change, not the value',
+ teaches: 'operation_type: action',
+ body:
+ 'This is the operation with no equivalent in the json world, and it is the reason a ' +
+ 'plan is worth reading at all. `action` does not ask what a resource *is*. It asks ' +
+ 'what Terraform is **about to do to it**: `create`, `update`, `delete`, `no-op`. A ' +
+ 'policy over actions gates the change itself, which is the only moment the damage is ' +
+ 'still preventable.',
+ aside:
+ 'This is the shape of "no pull request may destroy a database". A resource can carry ' +
+ 'more than one action, and every one of them is checked, so a replacement, which ' +
+ 'terraform reports as delete then create, cannot slip past a rule written about ' +
+ 'creation.',
+ tryIt: 'Change an `actions` array to `["delete"]` and watch a green plan turn red.',
+ policy: `{
+ "meta": {
+ "version": "v1",
+ "required_provider": "stackguardian/terraform_plan"
+ },
+ "evaluators": [
+ {
+ "id": "nothing_destroyed",
+ "provider_args": {
+ "operation_type": "action",
+ "terraform_resource_type": "*"
+ },
+ "condition": {
+ "type": "NotEquals",
+ "value": "delete"
+ }
+ }
+ ],
+ "eval_expression": "nothing_destroyed"
+}`,
+ },
+ {
+ id: 'tf-count',
+ n: '09',
+ title: 'Zero is an answer',
+ teaches: 'operation_type: count · and the error that does not happen',
+ body:
+ '`count` returns one number: how many resources of a type this change touches. The ' +
+ 'detail worth knowing is what it does **not** do. Every other operation reports an ' +
+ 'error when the resource type is absent from the plan, and that error can fail your ' +
+ 'check. `count` reports `0`, because zero of something is a real answer and usually ' +
+ 'the one you are gating on.',
+ aside:
+ 'Two buckets, so this fails. The same policy against a plan with no buckets at all ' +
+ 'returns `0` and passes, with no error and no skip. Worth knowing before you reach ' +
+ 'for `count` as a safety net: it cannot tell you that it looked and found nothing.',
+ tryIt: 'Raise the value to `2` and it passes. Then delete both buckets from the plan: still passing, on `0`.',
+ policy: `{
+ "meta": {
+ "version": "v1",
+ "required_provider": "stackguardian/terraform_plan"
+ },
+ "evaluators": [
+ {
+ "id": "bucket_budget",
+ "provider_args": {
+ "operation_type": "count",
+ "terraform_resource_type": "aws_s3_bucket"
+ },
+ "condition": {
+ "type": "LessThanEqualTo",
+ "value": 1
+ }
+ }
+ ],
+ "eval_expression": "bucket_budget"
+}`,
+ },
+];
+
+/* ── stackguardian/kubernetes ─────────────────────────────────────────────────
+ *
+ * The CLI reads a multi-document YAML file and hands the provider a list of
+ * manifests. The playground parses JSON, so the same manifests are written as a
+ * JSON array here. Nothing else differs: the provider iterates a list either way.
+ */
+
+export const K8S_DOC = `[
+ {
+ "apiVersion": "apps/v1",
+ "kind": "Deployment",
+ "metadata": {"name": "api"},
+ "spec": {
+ "replicas": 3,
+ "template": {
+ "spec": {
+ "containers": [
+ {"name": "api", "image": "ghcr.io/acme/api:1.4.2"}
+ ]
+ }
+ }
+ }
+ },
+ {
+ "apiVersion": "apps/v1",
+ "kind": "Deployment",
+ "metadata": {"name": "worker"},
+ "spec": {
+ "replicas": 1,
+ "template": {
+ "spec": {
+ "containers": [
+ {"name": "worker", "image": "ghcr.io/acme/worker:latest"}
+ ]
+ }
+ }
+ }
+ },
+ {
+ "apiVersion": "v1",
+ "kind": "Service",
+ "metadata": {"name": "api"},
+ "spec": {"type": "LoadBalancer"}
+ }
+]`;
+
+export const K8S_LESSONS = [
+ {
+ id: 'k8s-kind',
+ n: '10',
+ title: 'Pick a kind, then a path',
+ teaches: 'kubernetes_kind · attribute_path',
+ body:
+ 'Kubernetes input is a list of manifests, so the provider needs two things: which ' +
+ '`kind` to look at, and where inside it to look. Manifests of other kinds are ignored ' +
+ 'rather than failed, which is what lets one policy run against a whole directory of ' +
+ 'YAML. The `Service` in this document is simply not consulted.',
+ aside:
+ 'Two Deployments match, so there are two results and the single-replica one fails. ' +
+ 'A kind that appears nowhere is an error of severity 1, so `error_tolerance: 1` turns ' +
+ '"this repository has no Ingress" from a failure into a skip.',
+ tryIt: 'Give `worker` 2 replicas and it passes. Change the kind to `Ingress` to see the severity 1 error instead.',
+ policy: `{
+ "meta": {
+ "version": "v1",
+ "required_provider": "stackguardian/kubernetes"
+ },
+ "evaluators": [
+ {
+ "id": "not_a_single_point",
+ "provider_args": {
+ "operation_type": "attribute",
+ "kubernetes_kind": "Deployment",
+ "attribute_path": "spec.replicas"
+ },
+ "condition": {
+ "type": "GreaterThanEqualTo",
+ "value": 2
+ }
+ }
+ ],
+ "eval_expression": "not_a_single_point"
+}`,
+ },
+ {
+ id: 'k8s-wildcard',
+ n: '11',
+ title: 'The same star, a different meaning',
+ teaches: 'why a passing policy can still be wrong',
+ body:
+ 'Put `*` in a Kubernetes `attribute_path` and you do **not** get one value per match. ' +
+ 'You get a single value that is the whole list. That matters because `Contains` on a ' +
+ 'list is membership, not substring: `":latest"` is not an element of ' +
+ '`["ghcr.io/acme/worker:latest"]`, so the check below passes while an image really is ' +
+ 'pinned to `latest`. A green policy that gates nothing.',
+ aside:
+ 'Terraform’s `.*.` does the opposite: it emits one result per element, which is why ' +
+ 'the same instinct works there and fails here. Naming a container fixes it, at the ' +
+ 'cost of only checking that one. This is the failure the whole site is about, and it ' +
+ 'is why "the check is green" and "the check is working" are different claims.',
+ tryIt: 'Change `containers.*.image` to `containers.0.image`. The verdict flips to failed and names the image.',
+ policy: `{
+ "meta": {
+ "version": "v1",
+ "required_provider": "stackguardian/kubernetes"
+ },
+ "evaluators": [
+ {
+ "id": "no_latest_tag",
+ "provider_args": {
+ "operation_type": "attribute",
+ "kubernetes_kind": "Deployment",
+ "attribute_path": "spec.template.spec.containers.*.image"
+ },
+ "condition": {
+ "type": "NotContains",
+ "value": ":latest"
+ }
+ }
+ ],
+ "eval_expression": "no_latest_tag"
+}`,
+ },
+];
+
+/**
+ * The page, as tracks.
+ *
+ * Each track is a provider, its own input document, and the lessons that run
+ * against it. The numbering is continuous across the page rather than restarting
+ * per track, which is the section grammar the rest of the site uses.
+ */
+export const TRACKS = [
+ {
+ id: 'json',
+ provider: 'stackguardian/json',
+ title: 'Any JSON or YAML document',
+ lede:
+ 'The provider to learn first, because it reads anything with keys and values and gets ' +
+ 'out of the way of the syntax you are actually learning.',
+ input: INPUT_DOC,
+ lessons: LESSONS,
+ },
+ {
+ id: 'terraform',
+ provider: 'stackguardian/terraform_plan',
+ title: 'An OpenTofu or Terraform plan',
+ lede:
+ 'The provider the tool exists for. Same conditions, same expressions; what changes is ' +
+ 'that you address a resource type and an attribute instead of a path, and that you can ' +
+ 'gate on what the change is about to do.',
+ input: PLAN_DOC,
+ lessons: TF_LESSONS,
+ },
+ {
+ id: 'kubernetes',
+ provider: 'stackguardian/kubernetes',
+ title: 'Kubernetes manifests',
+ lede:
+ 'A list of manifests rather than one document, and one wildcard that behaves the ' +
+ 'opposite way to the one you just learned.',
+ input: K8S_DOC,
+ lessons: K8S_LESSONS,
+ },
+];
+
export const PLAYGROUND_START = LESSONS[LESSONS.length - 1].policy;
diff --git a/documentation/src/data/tirithLite.js b/documentation/src/data/tirithLite.js
index 7cdca9e2..d24290b9 100644
--- a/documentation/src/data/tirithLite.js
+++ b/documentation/src/data/tirithLite.js
@@ -5,8 +5,8 @@
* WHAT THIS IS, PRECISELY
*
* This is NOT Tirith. Tirith is a Python package; this is a few hundred lines of
- * JavaScript that reproduces one provider and thirteen conditions closely enough
- * to teach the shape of a policy in a browser, with no install.
+ * JavaScript that reproduces three providers and thirteen conditions closely
+ * enough to teach the shape of a policy in a browser, with no install.
*
* It is written against the real thing and matches it where it matters:
* - result documents have the same shape as `tirith --json` (see
@@ -17,13 +17,27 @@
* passes only if every value passes" (docs/tirith-reference/evaluators.md);
* - `passed` and `final_result` are tri-state: true / false / null-for-skipped.
*
- * KNOWN DIVERGENCES — say these out loud in the UI, never paper over them:
- * - Only `stackguardian/json` is implemented. terraform_plan, kubernetes,
- * infracost and sg_workflow are not.
+ * VERIFIED AGAINST THE ENGINE. Every policy on /learn was run through both this
+ * file and the installed Python package, and all eleven produce the same
+ * final_result and the same per-result outcomes. Re-run that comparison when you
+ * change anything below: a teaching engine that quietly disagrees with the real
+ * one is worse than no playground, because it is believed.
+ *
+ * KNOWN DIVERGENCES, say these out loud in the UI, never paper over them:
+ * - `stackguardian/json`, `stackguardian/terraform_plan` and
+ * `stackguardian/kubernetes` are implemented. infracost and sg_workflow are
+ * not, and neither are the terraform_plan operations beyond attribute,
+ * action and count: direct_references, direct_dependencies, provider_config
+ * and terraform_version all run only in the package.
+ * - Messages are formatted by this file's `fmt`, so a value is shown as JSON in
+ * backticks where the Python sometimes shows a repr. Same verdict, different
+ * punctuation.
* - `Equals` does not sort nested collections the way the Python does.
* - Regexes are JavaScript regexes, not Python's `re`.
- * - Error severities are approximated: a missing key_path is severity 2, which
- * is the one case the error_tolerance lesson needs.
+ * - An evaluator whose results are a mix of failures and skips is reported as
+ * failed here. core.py reports it as skipped when the skip comes last, which
+ * is the ordering defect the roadmap's R1 item covers. This file implements
+ * the intended rule rather than the current one, deliberately.
*
* The authoritative evaluator is always the installed package.
* ─────────────────────────────────────────────────────────────────────────────
@@ -253,6 +267,196 @@ export function getValues(doc, keyPath) {
return current;
}
+/* ── providers ────────────────────────────────────────────────────────────────
+ *
+ * Three of the five that ship. Each returns the same shape the Python providers
+ * return, a list of outputs, so the evaluator loop below does not know or care
+ * which provider produced them:
+ *
+ * {value, meta} a value to run the condition against
+ * {err, severity, meta} the provider could not produce one
+ *
+ * `severity` is the number `error_tolerance` is compared against, and the
+ * comparison is `severity > tolerance` fails, otherwise the check is skipped.
+ * The severities are copied from the handlers, not invented: they are the
+ * difference between "this resource type is not in your plan" and "this
+ * attribute is missing from a resource that is", and a lesson that got them
+ * wrong would teach the wrong error_tolerance.
+ */
+
+const NOT_FOUND = Symbol('not found');
+
+/** pydash.get: a dot path with numeric list indices, or NOT_FOUND. */
+function pget(data, path) {
+ let node = data;
+ for (const part of String(path).split('.')) {
+ if (isDict(node) && part in node) node = node[part];
+ else if (isList(node) && /^\d+$/.test(part) && node[Number(part)] !== undefined) node = node[Number(part)];
+ else return NOT_FOUND;
+ }
+ return node;
+}
+
+/**
+ * The `a.*.b` form of terraform_resource_attribute.
+ *
+ * Mirrors _get_exp_attribute in the handler, including the part that looks odd:
+ * every segment is resolved against the *original* attribute dictionary rather
+ * than against the previous segment's result. The list branch returns early, so
+ * that only shows up on paths that do not match, and reproducing it is the point
+ * of the playground.
+ */
+function expandAttribute(parts, data) {
+ const out = [];
+ for (let i = 0; i < parts.length; i += 1) {
+ const expr = parts[i];
+ const val = pget(data, expr);
+ if (isList(val) && i < parts.length - 1) {
+ for (const item of val) {
+ const sub = expandAttribute(parts.slice(i + 1), item);
+ if (sub.length) out.push(...sub);
+ // A list item without the attribute is still evaluated, as None, so a
+ // policy over a list cannot pass by the item simply being absent.
+ else out.push(null);
+ }
+ return out;
+ }
+ if (i === parts.length - 1 && val !== NOT_FOUND) {
+ out.push(val);
+ } else if (expr.endsWith('.*')) {
+ const base = pget(data, expr.slice(0, -2));
+ if (base !== NOT_FOUND && isList(base)) out.push(...base);
+ }
+ }
+ return out;
+}
+
+function jsonProvide(args, input) {
+ if (args.operation_type !== 'get_value') {
+ return [{err: `operation_type: ${args.operation_type} is not supported`, severity: 99}];
+ }
+ const values = getValues(input, args.key_path);
+ if (values.length === 0) {
+ return [{err: `key_path: \`${args.key_path}\` is not found`, severity: 2}];
+ }
+ return values.map((value) => ({value}));
+}
+
+function terraformProvide(args, input) {
+ const changes = input && input.resource_changes;
+ if (!isList(changes) || changes.length === 0) {
+ return [{err: 'No Terraform resources changes are found', severity: 0}];
+ }
+
+ const type = args.terraform_resource_type;
+ const exclude = args.exclude_resource_types || [];
+ // `*` means every resource, and is the only case exclude_resource_types applies
+ // to: naming a type explicitly and then excluding it is a contradiction the
+ // handler does not entertain.
+ const matches = (rc) => (type === '*' ? !exclude.includes(rc.type) : rc.type === type);
+
+ if (args.operation_type === 'attribute') {
+ const attribute = args.terraform_resource_attribute;
+ const out = [];
+ let resourceFound = false;
+ let attributeFound = false;
+
+ for (const rc of changes) {
+ if (!matches(rc)) continue;
+ resourceFound = true;
+ const after = rc.change && rc.change.after;
+ if (!after) {
+ out.push({err: `No Terraform changes found for resource type: '${type}'`, severity: 0});
+ continue;
+ }
+ let local = false;
+ if (attribute in after) {
+ attributeFound = true;
+ local = true;
+ out.push({value: after[attribute], meta: rc});
+ } else if (attribute.includes('.') || attribute.includes('*')) {
+ const vals = expandAttribute(attribute.split('.*.'), after);
+ if (vals.length) {
+ attributeFound = true;
+ local = true;
+ for (const v of vals) out.push({value: v, meta: rc});
+ }
+ }
+ if (!local) out.push({err: `attribute: '${attribute}' is not found`, severity: 2});
+ }
+
+ if (out.length === 0) {
+ if (!resourceFound) return [{err: `resource_type: '${type}' is not found`, severity: 1}];
+ if (!attributeFound) return [{err: `attribute: '${attribute}' is not found`, severity: 2}];
+ }
+ return out;
+ }
+
+ if (args.operation_type === 'action') {
+ const out = [];
+ let found = false;
+ for (const rc of changes) {
+ if (!matches(rc)) continue;
+ found = true;
+ for (const action of (rc.change && rc.change.actions) || []) out.push({value: action, meta: rc});
+ }
+ if (!found) out.push({err: `resource_type: '${type}' is not found`, severity: 1});
+ return out;
+ }
+
+ if (args.operation_type === 'count') {
+ // No "not found" error here, deliberately: zero of a resource is a real answer
+ // and often the one you are gating on.
+ let count = 0;
+ let meta = null;
+ for (const rc of changes) {
+ if (!matches(rc)) continue;
+ meta = rc;
+ count += 1;
+ }
+ return [{value: count, meta}];
+ }
+
+ return [{err: `operation_type: '${args.operation_type}' is not supported`, severity: 99}];
+}
+
+function kubernetesProvide(args, input) {
+ if (args.operation_type !== 'attribute') {
+ return [{err: `operation_type: ${args.operation_type} is not supported`, severity: 99}];
+ }
+ const kind = args.kubernetes_kind;
+ const path = args.attribute_path || '';
+ if (kind === undefined || kind === null) {
+ return [{err: 'kubernetes_kind must be provided', severity: 99}];
+ }
+ if (path === '') return [{err: 'attribute_path must be provided', severity: 99}];
+
+ // The CLI reads a multi-document YAML file and hands the provider a list. The
+ // playground parses JSON, so the same manifests arrive as a JSON array.
+ const resources = isList(input) ? input : [input];
+ const out = [];
+ let found = false;
+ for (const resource of resources) {
+ if (!isDict(resource) || resource.kind !== kind) continue;
+ found = true;
+ let values = getValues(resource, path);
+ // place_none_if_not_found: a manifest missing the attribute is evaluated as
+ // null rather than skipped, so it cannot pass by omission.
+ if (values.length === 0) values = [null];
+ out.push({value: path.includes('*') ? values : values[0], meta: resource});
+ }
+ if (!found) out.push({err: `kind: ${kind} is not found`, severity: 1});
+ return out;
+}
+
+export const PROVIDERS = {
+ 'stackguardian/json': jsonProvide,
+ 'stackguardian/terraform_plan': terraformProvide,
+ 'stackguardian/kubernetes': kubernetesProvide,
+};
+
+export const PROVIDER_NAMES = Object.keys(PROVIDERS);
+
/* ── eval_expression ──────────────────────────────────────────────────────────
* Supports `&&`, `||`, `!` and parentheses over evaluator ids, which is the
* grammar documented in docs/tirith-reference/eval-expressions.md.
@@ -355,12 +559,13 @@ export function evaluatePolicy(policyText, inputText) {
}
const meta = policy.meta || {};
- const provider = meta.required_provider;
- if (provider && provider !== 'stackguardian/json') {
+ const provider = meta.required_provider || 'stackguardian/json';
+ const provide = PROVIDERS[provider];
+ if (!provide) {
return {
document: null,
fatal:
- `this browser playground only implements stackguardian/json; ` +
+ `this browser playground implements ${PROVIDER_NAMES.join(', ')}; ` +
`"${provider}" runs in the installed package.`,
};
}
@@ -381,31 +586,42 @@ export function evaluatePolicy(policyText, inputText) {
passedById[id] = false;
return {id, passed: false, description: ev.description ?? null, result: [{passed: false, message, meta: null}]};
}
- if (args.operation_type !== 'get_value') {
- const message = `Unsupported operation type: ${args.operation_type}`;
- passedById[id] = false;
- return {id, passed: false, description: ev.description ?? null, result: [{passed: false, message, meta: null}]};
- }
- const values = getValues(input, args.key_path);
+ const outputs = provide(args, input);
- if (values.length === 0) {
- // Severity 2 in the real provider: skipped at error_tolerance >= 2.
- const message = `key_path: \`${args.key_path}\` is not found`;
- if (tolerance >= 2) {
- passedById[id] = null;
- return {id, passed: null, description: ev.description ?? null, result: [{passed: null, message, meta: null}]};
+ const result = outputs.map((o) => {
+ if (o.err !== undefined) {
+ // `severity > tolerance` fails, otherwise the check is skipped. Copied from
+ // core.py rather than reasoned about: the boundary case, severity equal to
+ // tolerance, is a skip, and getting it backwards would invert every lesson
+ // that teaches error_tolerance.
+ if (o.severity > tolerance) {
+ errors.push(o.err);
+ return {passed: false, message: o.err, meta: o.meta ?? null};
+ }
+ return {passed: null, message: o.err, meta: o.meta ?? null};
}
- errors.push(message);
- passedById[id] = false;
- return {id, passed: false, description: ev.description ?? null, result: [{passed: false, message, meta: null}]};
- }
-
- const result = values.map((v) => {
- const r = fn(v, cond.value);
- return {passed: r.passed, message: r.message, meta: null};
+ const r = fn(o.value, cond.value);
+ return {passed: r.passed, message: r.message, meta: o.meta ?? null};
});
- const passed = result.every((r) => r.passed);
+
+ /*
+ * Three-valued, and deliberately not a transcription of the engine.
+ *
+ * core.py sets its running verdict to None inside the skip branch without
+ * checking whether an earlier result already failed, so an evaluator that
+ * fails and *then* skips is reported as unevaluated. That is the defect the
+ * roadmap's "a rule that could not run is never reported as success" item
+ * exists to fix (src/data/roadmap.js). A teaching playground that reproduced
+ * it would teach an ordering artefact as a rule, so this is the intended
+ * semantics: any failure decides, and only an evaluator with nothing but
+ * skips is skipped.
+ */
+ let passed;
+ if (result.some((r) => r.passed === false)) passed = false;
+ else if (result.some((r) => r.passed === true)) passed = true;
+ else passed = null;
+
passedById[id] = passed;
return {id, passed, description: ev.description ?? null, result};
});
diff --git a/documentation/src/pages/learn.js b/documentation/src/pages/learn.js
index 5cb01dbf..3a644c0a 100644
--- a/documentation/src/pages/learn.js
+++ b/documentation/src/pages/learn.js
@@ -6,7 +6,7 @@ import Heading from '@theme/Heading';
import TirithMark from '../components/brand/TirithMark';
import Colophon from '../components/site/Colophon';
import Bench from '../components/learn/Bench';
-import {INPUT_DOC, LESSONS, PLAYGROUND_START} from '../data/lessons';
+import {INPUT_DOC, PLAYGROUND_START, TRACKS} from '../data/lessons';
import {CONDITION_NAMES} from '../data/tirithLite';
import styles from './learn.module.css';
import '../css/chrome.module.css';
@@ -170,7 +170,7 @@ export default function Learn() {
Tirith
- Six lessons and a playground
+ Eleven lessons and a playground
@@ -180,9 +180,10 @@ export default function Learn() {
- Six steps, one document, one policy that grows a rule at a time. Each
- step is editable — change a value, run it, and watch the verdict, the
- messages and the exit code move with it.
+ Three providers, a lesson at a time. Start with any JSON document, then
+ the same syntax against an OpenTofu or Terraform plan and a set of
+ Kubernetes manifests. Every step is editable: change a value, run it, and
+ watch the verdict, the messages and the exit code move with it.
@@ -198,10 +199,11 @@ export default function Learn() {
Browser playground
This runs a Tirith-compatible teaching subset in your browser, not the Python
- package. Edit either pane and press Run check. The examples
- cover stackguardian/json; install Tirith to evaluate OpenTofu,
- Terraform,
- Kubernetes, Infracost, and StackGuardian Workflow inputs.
+ package. Edit either pane and press Run check. It implements
+ stackguardian/json, stackguardian/terraform_plan and
+ stackguardian/kubernetes, and its verdicts are checked against the
+ real engine. Install Tirith for Infracost and StackGuardian Workflow inputs,
+ and for anything you intend to trust.
@@ -216,20 +218,37 @@ export default function Learn() {
- {LESSONS.map((lesson) => (
-
+ {/*
+ * One section per provider. The track header carries the provider name because
+ * every policy below it repeats that name in its own meta block, and a reader
+ * who lands on lesson 10 from the contents needs to know which document the
+ * pane on the right is showing them.
+ */}
+ {TRACKS.map((track) => (
+
+
+ {track.provider}
+
+ {track.title}
+
+ {track.lede}
+
+ {track.lessons.map((lesson) => (
+
+ ))}
+
))}
diff --git a/documentation/src/pages/learn.module.css b/documentation/src/pages/learn.module.css
index 6ecdfca3..379f2e20 100644
--- a/documentation/src/pages/learn.module.css
+++ b/documentation/src/pages/learn.module.css
@@ -863,3 +863,49 @@
color: var(--tp-accent);
text-decoration: underline;
}
+
+/* ------------------------------------------------------------------- tracks ---- */
+
+/*
+ * The header that opens each provider's run of lessons.
+ *
+ * A rule above rather than a box: this is the same section grammar the rest of the
+ * site uses, and a card here would be the only one on the page. The provider name is
+ * set in mono because it is an identifier the reader will type, not a label.
+ */
+.track {
+ max-width: 68ch;
+ margin: 5.5rem 0 0;
+ padding-top: 2rem;
+ border-top: 2px solid var(--tp-ink);
+}
+
+.trackProvider {
+ display: block;
+ margin-bottom: 0.7rem;
+ color: var(--tp-accent);
+ font-family: var(--tp-mono);
+ font-size: 0.78rem;
+ letter-spacing: 0.01em;
+}
+
+.trackTitle {
+ margin: 0 0 0.7rem;
+ font-family: var(--tp-display);
+ font-size: clamp(1.3rem, 2.4vw, 1.7rem);
+ font-weight: 700;
+ letter-spacing: -0.02em;
+ line-height: 1.15;
+}
+
+.trackLede {
+ margin: 0;
+ color: var(--tp-soft);
+ font-size: 1rem;
+ line-height: 1.6;
+}
+
+/* The first track opens directly under the contents, which already has a rule. */
+.track:first-of-type {
+ margin-top: 3rem;
+}
diff --git a/documentation/static/docs/tirith-providers/providers-overview.md b/documentation/static/docs/tirith-providers/providers-overview.md
index a3693389..e5ba5e82 100644
--- a/documentation/static/docs/tirith-providers/providers-overview.md
+++ b/documentation/static/docs/tirith-providers/providers-overview.md
@@ -82,3 +82,50 @@ When a provider cannot find what an operation asked for, it reports an error ins
2. **Errors without a severity value.** Some errors (an unsupported `operation_type` in the `json` and `kubernetes` providers, and all errors from the `infracost` and `sg_workflow` providers) carry no severity. These always **fail** the check, regardless of `error_tolerance`.
Each provider page below lists exactly which situation produces which severity. See also [error tolerance](../tirith-policies/tirith-policy-error-tolerance.md).
+
+## Write one for what you actually run
+
+Five providers ship. That is not a claim about what is worth gating, it is a list of what has been written so far, and the interesting policies are usually about the system nobody wrote a provider for yet.
+
+A provider is small. It is one function:
+
+```python
+def provide(provider_args: dict, input_data) -> list[dict]:
+ """Turn a document into values a condition can be run against."""
+```
+
+It receives the `provider_args` from an evaluator and the parsed input document, and it returns a list of outputs: `{"value": ...}` for something a condition can judge, or `{"value": ProviderError(severity_value=1), "err": "..."}` for something it could not find. That is the entire contract. The thirteen conditions, `eval_expression`, `error_tolerance`, the result document, the exit codes and every CI integration already work on top of it. `kubernetes/handler.py` is about fifty lines, and it is a complete provider.
+
+[NOTE] How a provider is registered
+There is no plugin discovery and no entry point to hook: `PROVIDERS_DICT` in `src/tirith/providers/__init__.py` is a literal dictionary, so a new provider is a module plus one line in that dict. In practice that means a pull request, or a fork you install from your own git URL. Making providers loadable from outside the package is a real request and worth opening an issue for if you need it.
+
+### What people ask for
+
+The pattern that makes a good provider is narrow: **a document that describes a proposed change, available before the change is applied.** If you can get that as JSON, you can gate it.
+
+| | |
+|---|---|
+| **Other IaC formats** | CloudFormation change sets, Pulumi previews, ARM and Bicep what-if output, Helm rendered templates and values |
+| **Cloud and SaaS APIs** | AWS Config or Cloud Control, GCP asset inventory, Datadog monitors, PagerDuty schedules, an identity provider's roles |
+| **Your own APIs** | A service catalogue, a CMDB, a deployment API, an internal platform's change request. This is the one nobody else can write for you, and it is usually where the rules that matter to your organisation live |
+| **Supply chain** | An SBOM, a lockfile, a dependency manifest, image provenance and signatures |
+| **Cost and capacity** | Beyond Infracost: quota headroom, commitment coverage, a chargeback model |
+| **Compliance evidence** | Turning a control framework into checks that run on every change instead of once a quarter |
+
+### The one that does not exist yet
+
+Everything above is the same shape as what ships today: a plan, a manifest, an estimate. The shape holds somewhere less obvious.
+
+An AI agent with tools is a system that proposes changes and then applies them. Before it calls a tool, there is a document describing what it is about to do: which tool, which arguments, what it costs, what it can reach. That is a plan, in every sense that matters to a policy engine, and today almost nothing sits between an agent's intention and its action.
+
+**A provider for agent runtime decisions** would let the rules be written the same way the rest of your governance is: this agent may not call a tool that writes to production, may not spend beyond a threshold in one run, may not touch a resource outside its blast radius, may not act at all without a plan a human approved. The same thirteen conditions, the same expression grammar, the same verdict and exit code, evaluated before the call rather than in a review afterwards.
+
+This is **aspirational**. There is no such provider, it is not on the [roadmap](https://stackguardian.github.io/tirith/roadmap/) with a date, and it is written down here because it is the clearest example of the point: the engine does not care what the document is about. If you are building agent infrastructure and want a policy layer with a real evaluator behind it rather than a prompt asking a model to behave, this is worth a conversation.
+
+### Start one
+
+Open an issue describing the document you want to gate and what a rule over it would say. That is enough to work out whether it is a new provider, a new operation on an existing one, or something the `json` provider already does.
+
+- **[Propose a provider](https://github.com/StackGuardian/tirith/issues/new?template=feature_request.md&title=Provider%3A+)**: the system, the document, and one rule you would write
+- **[Read an existing one](https://github.com/StackGuardian/tirith/tree/main/src/tirith/providers/kubernetes)**: the shortest complete example in the repository
+- **[Good first issues](https://github.com/StackGuardian/tirith/labels/good%20first%20issue)**: if you would rather start somewhere smaller
diff --git a/documentation/static/llms-full.txt b/documentation/static/llms-full.txt
index b73f7f33..ac35d44f 100644
--- a/documentation/static/llms-full.txt
+++ b/documentation/static/llms-full.txt
@@ -1385,6 +1385,53 @@ When a provider cannot find what an operation asked for, it reports an error ins
Each provider page below lists exactly which situation produces which severity. See also [error tolerance](../tirith-policies/tirith-policy-error-tolerance.md).
+## Write one for what you actually run
+
+Five providers ship. That is not a claim about what is worth gating, it is a list of what has been written so far, and the interesting policies are usually about the system nobody wrote a provider for yet.
+
+A provider is small. It is one function:
+
+```python
+def provide(provider_args: dict, input_data) -> list[dict]:
+ """Turn a document into values a condition can be run against."""
+```
+
+It receives the `provider_args` from an evaluator and the parsed input document, and it returns a list of outputs: `{"value": ...}` for something a condition can judge, or `{"value": ProviderError(severity_value=1), "err": "..."}` for something it could not find. That is the entire contract. The thirteen conditions, `eval_expression`, `error_tolerance`, the result document, the exit codes and every CI integration already work on top of it. `kubernetes/handler.py` is about fifty lines, and it is a complete provider.
+
+[NOTE] How a provider is registered
+There is no plugin discovery and no entry point to hook: `PROVIDERS_DICT` in `src/tirith/providers/__init__.py` is a literal dictionary, so a new provider is a module plus one line in that dict. In practice that means a pull request, or a fork you install from your own git URL. Making providers loadable from outside the package is a real request and worth opening an issue for if you need it.
+
+### What people ask for
+
+The pattern that makes a good provider is narrow: **a document that describes a proposed change, available before the change is applied.** If you can get that as JSON, you can gate it.
+
+| | |
+|---|---|
+| **Other IaC formats** | CloudFormation change sets, Pulumi previews, ARM and Bicep what-if output, Helm rendered templates and values |
+| **Cloud and SaaS APIs** | AWS Config or Cloud Control, GCP asset inventory, Datadog monitors, PagerDuty schedules, an identity provider's roles |
+| **Your own APIs** | A service catalogue, a CMDB, a deployment API, an internal platform's change request. This is the one nobody else can write for you, and it is usually where the rules that matter to your organisation live |
+| **Supply chain** | An SBOM, a lockfile, a dependency manifest, image provenance and signatures |
+| **Cost and capacity** | Beyond Infracost: quota headroom, commitment coverage, a chargeback model |
+| **Compliance evidence** | Turning a control framework into checks that run on every change instead of once a quarter |
+
+### The one that does not exist yet
+
+Everything above is the same shape as what ships today: a plan, a manifest, an estimate. The shape holds somewhere less obvious.
+
+An AI agent with tools is a system that proposes changes and then applies them. Before it calls a tool, there is a document describing what it is about to do: which tool, which arguments, what it costs, what it can reach. That is a plan, in every sense that matters to a policy engine, and today almost nothing sits between an agent's intention and its action.
+
+**A provider for agent runtime decisions** would let the rules be written the same way the rest of your governance is: this agent may not call a tool that writes to production, may not spend beyond a threshold in one run, may not touch a resource outside its blast radius, may not act at all without a plan a human approved. The same thirteen conditions, the same expression grammar, the same verdict and exit code, evaluated before the call rather than in a review afterwards.
+
+This is **aspirational**. There is no such provider, it is not on the [roadmap](https://stackguardian.github.io/tirith/roadmap/) with a date, and it is written down here because it is the clearest example of the point: the engine does not care what the document is about. If you are building agent infrastructure and want a policy layer with a real evaluator behind it rather than a prompt asking a model to behave, this is worth a conversation.
+
+### Start one
+
+Open an issue describing the document you want to gate and what a rule over it would say. That is enough to work out whether it is a new provider, a new operation on an existing one, or something the `json` provider already does.
+
+- **[Propose a provider](https://github.com/StackGuardian/tirith/issues/new?template=feature_request.md&title=Provider%3A+)**: the system, the document, and one rule you would write
+- **[Read an existing one](https://github.com/StackGuardian/tirith/tree/main/src/tirith/providers/kubernetes)**: the shortest complete example in the repository
+- **[Good first issues](https://github.com/StackGuardian/tirith/labels/good%20first%20issue)**: if you would rather start somewhere smaller
+
==============================================================================
# Terraform Plan Provider
From b3538604f38071034da2004c7d9073cd0bbff8e4 Mon Sep 17 00:00:00 2001
From: Akash S
Date: Fri, 4 Sep 2026 18:32:34 +0530
Subject: [PATCH 09/10] Drop the #293 warnings now that the engine fix has
merged
PR #365 changed the roll-up so a tolerated skip no longer erases a sibling's verdict. Both skills
now state the fixed behaviour where they used to warn about the bug, and the SSH example's notes
say a skipped group leaves the bastion's failure standing. Re-verified: the probe plan that
masked the bastion before now exits 3, and a compliant bucket next to a destroyed one exits 0.
---
.claude/skills/tirith-migrate/SKILL.md | 8 ++++----
.../examples/sentinel/restrict-ssh-ingress/notes.md | 6 ++----
.claude/skills/tirith-migrate/reference/sentinel.md | 9 ++-------
.claude/skills/tirith-policies/SKILL.md | 3 ---
.../skills/tirith-policies/reference/terraform-plan.md | 6 ++----
5 files changed, 10 insertions(+), 22 deletions(-)
diff --git a/.claude/skills/tirith-migrate/SKILL.md b/.claude/skills/tirith-migrate/SKILL.md
index c815417d..2d893fd3 100644
--- a/.claude/skills/tirith-migrate/SKILL.md
+++ b/.claude/skills/tirith-migrate/SKILL.md
@@ -64,10 +64,10 @@ page. Everything below assumes that vocabulary.
or a value unknown until apply is invisible.
- Configuration is not the plan. Module sources, variables, outputs, provisioners and expression
references live in `tfconfig`; Tirith reads none of them.
-- A resource skipped through `error_tolerance` can erase an earlier resource's failure in the
- same evaluator (Tirith issue #293). Destroys trigger it at every tolerance; the "where the
- attribute exists" idiom triggers it at `error_tolerance: 2`. Test with mixed plans, and put the
- warning in the report.
+- A resource skipped through `error_tolerance` does not touch the verdict of the others: an
+ evaluator fails if any resource fails, passes if none fail and at least one was evaluated, and
+ is skipped only when every resource was tolerated away. Test with mixed plans anyway; that is
+ where a scope difference shows.
## Before you hand it back
diff --git a/.claude/skills/tirith-migrate/examples/sentinel/restrict-ssh-ingress/notes.md b/.claude/skills/tirith-migrate/examples/sentinel/restrict-ssh-ingress/notes.md
index 5882b88b..5139705f 100644
--- a/.claude/skills/tirith-migrate/examples/sentinel/restrict-ssh-ingress/notes.md
+++ b/.claude/skills/tirith-migrate/examples/sentinel/restrict-ssh-ingress/notes.md
@@ -14,7 +14,5 @@ and fails Tirith. Issue #316 (`resource_filter`) would make this exact.
| `should-pass.json` (everything internal) | pass | exit 0 |
| `diverges.json` (443 from anywhere, 22 internal) | **pass** | **exit 3** |
-`error_tolerance: 2` skips a group with no ingress blocks, which Sentinel's `any` also passed.
-Until Tirith issue #293 is fixed that skip can erase the bastion's failure when both groups are
-in one plan: the evaluator reports `null` and the exit is `1`, not `3`. A translation that leans
-on `error_tolerance: 2` must say this in its report.
+`error_tolerance: 2` skips a group with no ingress blocks, which Sentinel's `any` also passed. A
+skipped group next to the bastion leaves the bastion's failure standing: exit `3`.
diff --git a/.claude/skills/tirith-migrate/reference/sentinel.md b/.claude/skills/tirith-migrate/reference/sentinel.md
index db026bdc..74e63b7e 100644
--- a/.claude/skills/tirith-migrate/reference/sentinel.md
+++ b/.claude/skills/tirith-migrate/reference/sentinel.md
@@ -94,7 +94,7 @@ values"). Check both before marking a row exact.
| `param name default v` | `{{ var.name }}` in the value, with `-var` or `-var-path` |
| enforcement level in `sentinel.hcl` | `meta.enforcement`, passed through to the result. The CLI treats every policy as hard-mandatory under `--fail-on-error` |
-Three engine behaviours to know, all verified:
+Two engine behaviours to know, both verified:
- A present-but-null attribute is **not** a missing attribute. `error_tolerance: 2` skips a
resource without the key; `"kms_key_id": null` is evaluated as null. `IsNotEmpty` fails it
@@ -102,18 +102,13 @@ Three engine behaviours to know, all verified:
unsupported type, which reads like a violation.
- A value unknown until apply is absent from `change.after`. Sentinel policies that accept any
reference (`kms_key_id = aws_kms_key.x.arn`) see a value; Tirith sees a missing attribute.
-- **A skipped resource can erase an earlier failure** (Tirith issue #293). In one evaluator, a
- resource tolerated away by `error_tolerance` after a violating resource resets the verdict to
- `null`. The violation disappears and the exit is `1`. Any translation that relies on
- `error_tolerance: 2`, including the "where the attribute exists" idiom, is unsafe on plans that
- mix resources with and without the attribute until #293 is fixed. Say so in the report.
## Scope differs even when the test is exact
`find_resources` and the `actions contains "create" or "update"` idiom exclude no-op and deleted
resources. Tirith evaluates every `resource_changes` entry of the type, so an unchanged resource
that already violates the rule fails the plan (Sentinel passes it), and a deleted resource is a
-severity-0 error that is skipped or, per #293, erases a sibling's failure. This applies to every
+severity-0 error that is skipped without touching its siblings' verdicts. This applies to every
row marked exact above: exact on the resources both tools evaluate, not on which resources are
evaluated.
diff --git a/.claude/skills/tirith-policies/SKILL.md b/.claude/skills/tirith-policies/SKILL.md
index 693a0b18..899750e6 100644
--- a/.claude/skills/tirith-policies/SKILL.md
+++ b/.claude/skills/tirith-policies/SKILL.md
@@ -90,9 +90,6 @@ and invert it.
- **Missing attribute is severity 2, missing resource type is severity 1.** With
`error_tolerance: 2` a resource lacking the attribute is *skipped*, not failed. If every
evaluator is skipped the policy is `final_result: null`, exit `1`. Skipping is not passing.
-- **A skipped resource can erase a failure (issue #293).** In one evaluator, a resource skipped by
- `error_tolerance` after a violating one resets the verdict to `null`, exit `1`. Destroys do this
- at every tolerance. Prefer one evaluator per resource type over a wildcard with tolerance.
- **A type-scoped policy refuses a plan with none of that type.** Severity 1 under the default
tolerance is exit `3`; with `error_tolerance: 1` it is exit `1`. Neither is `0`. See
`reference/verdicts.md`.
diff --git a/.claude/skills/tirith-policies/reference/terraform-plan.md b/.claude/skills/tirith-policies/reference/terraform-plan.md
index 8c03a748..d4cf8f35 100644
--- a/.claude/skills/tirith-policies/reference/terraform-plan.md
+++ b/.claude/skills/tirith-policies/reference/terraform-plan.md
@@ -101,10 +101,8 @@ 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".
- **Caveat, issue #293:** a skipped resource that follows a failing one in the same evaluator
- resets the verdict to `null`, so the failure disappears and the exit is `1`. On a plan that mixes
- resources with and without the attribute, this recipe can hide a violation. Until #293 is fixed,
- prefer separate evaluators per resource type over a wildcard with tolerance 2 where you can.
+ A skipped resource does not touch the verdict of the others: the evaluator still fails if any
+ resource fails, and is skipped as a whole only when every resource was tolerated away.
On a wildcard policy every message reads identically, and only the resource address in the result
distinguishes one finding from another.
From 9e33fb20a62b17e8b35453cd53447995ba7143c3 Mon Sep 17 00:00:00 2001
From: AKSHAT TANDON
Date: Fri, 4 Sep 2026 16:10:00 +0100
Subject: [PATCH 10/10] feat(learn): update lesson numbering and enhance track
chooser functionality
---
documentation/src/data/lessons.js | 40 ++++-
documentation/src/pages/learn.js | 180 +++++++++++++++++------
documentation/src/pages/learn.module.css | 144 ++++++++++++++++++
3 files changed, 314 insertions(+), 50 deletions(-)
diff --git a/documentation/src/data/lessons.js b/documentation/src/data/lessons.js
index 8a11b5cc..ea3b4f82 100644
--- a/documentation/src/data/lessons.js
+++ b/documentation/src/data/lessons.js
@@ -375,7 +375,7 @@ export const PLAN_DOC = `{
export const TF_LESSONS = [
{
id: 'tf-attribute',
- n: '07',
+ n: '01',
title: 'A plan is not a document',
teaches: 'terraform_resource_type · terraform_resource_attribute',
body:
@@ -415,7 +415,7 @@ export const TF_LESSONS = [
},
{
id: 'tf-action',
- n: '08',
+ n: '02',
title: 'Gate the change, not the value',
teaches: 'operation_type: action',
body:
@@ -453,7 +453,7 @@ export const TF_LESSONS = [
},
{
id: 'tf-count',
- n: '09',
+ n: '03',
title: 'Zero is an answer',
teaches: 'operation_type: count · and the error that does not happen',
body:
@@ -539,7 +539,7 @@ export const K8S_DOC = `[
export const K8S_LESSONS = [
{
id: 'k8s-kind',
- n: '10',
+ n: '01',
title: 'Pick a kind, then a path',
teaches: 'kubernetes_kind · attribute_path',
body:
@@ -576,7 +576,7 @@ export const K8S_LESSONS = [
},
{
id: 'k8s-wildcard',
- n: '11',
+ n: '02',
title: 'The same star, a different meaning',
teaches: 'why a passing policy can still be wrong',
body:
@@ -626,34 +626,58 @@ export const TRACKS = [
{
id: 'json',
provider: 'stackguardian/json',
+ tab: 'JSON or YAML',
title: 'Any JSON or YAML document',
lede:
'The provider to learn first, because it reads anything with keys and values and gets ' +
- 'out of the way of the syntax you are actually learning.',
+ 'out of the way of the syntax you are actually learning. Six lessons that build one ' +
+ 'policy a rule at a time.',
+ /*
+ * What the track teaches, for the reader deciding which one to open. Deliberately not
+ * a summary of the lessons: it is the reason to pick this track over the other two.
+ */
+ forYou: 'Start here if you are new to Tirith, whatever you plan to gate later.',
input: INPUT_DOC,
lessons: LESSONS,
+ /*
+ * The playground opens on the track's most representative *correct* policy, which is
+ * not always its last lesson. The Kubernetes track ends on a policy that deliberately
+ * passes while being wrong, and seeding an empty-canvas playground with that would be
+ * handing the reader the trap with none of the explanation attached.
+ */
+ playground: LESSONS[LESSONS.length - 1].policy,
},
{
id: 'terraform',
provider: 'stackguardian/terraform_plan',
+ tab: 'Terraform plan',
title: 'An OpenTofu or Terraform plan',
lede:
'The provider the tool exists for. Same conditions, same expressions; what changes is ' +
'that you address a resource type and an attribute instead of a path, and that you can ' +
'gate on what the change is about to do.',
+ forYou: 'Three lessons. Come here once the syntax is familiar and you want the real thing.',
input: PLAN_DOC,
lessons: TF_LESSONS,
+ playground: TF_LESSONS[0].policy,
},
{
id: 'kubernetes',
provider: 'stackguardian/kubernetes',
+ tab: 'Kubernetes',
title: 'Kubernetes manifests',
lede:
'A list of manifests rather than one document, and one wildcard that behaves the ' +
- 'opposite way to the one you just learned.',
+ 'opposite way to the one in the Terraform track.',
+ forYou: 'Two lessons, the second of which is the most useful mistake on this page.',
input: K8S_DOC,
lessons: K8S_LESSONS,
+ playground: K8S_LESSONS[0].policy,
},
];
-export const PLAYGROUND_START = LESSONS[LESSONS.length - 1].policy;
+/*
+ * Superseded by each track's own `playground` seed, and kept only long enough to say so:
+ * nothing imports this. Delete it on the next pass through this file.
+ */
+export const PLAYGROUND_START = TRACKS[0].playground;
diff --git a/documentation/src/pages/learn.js b/documentation/src/pages/learn.js
index 3a644c0a..d448d081 100644
--- a/documentation/src/pages/learn.js
+++ b/documentation/src/pages/learn.js
@@ -1,4 +1,4 @@
-import {useState} from 'react';
+import {useCallback, useEffect, useState} from 'react';
import Link from '@docusaurus/Link';
import Layout from '@theme/Layout';
import Heading from '@theme/Heading';
@@ -6,7 +6,7 @@ import Heading from '@theme/Heading';
import TirithMark from '../components/brand/TirithMark';
import Colophon from '../components/site/Colophon';
import Bench from '../components/learn/Bench';
-import {INPUT_DOC, PLAYGROUND_START, TRACKS} from '../data/lessons';
+import {TRACKS} from '../data/lessons';
import {CONDITION_NAMES} from '../data/tirithLite';
import styles from './learn.module.css';
import '../css/chrome.module.css';
@@ -99,15 +99,22 @@ function Lesson({lesson, input}) {
);
}
-function Playground() {
- const [policy, setPolicy] = useState(PLAYGROUND_START);
- const [input, setInput] = useState(INPUT_DOC);
+/*
+ * The blank bench, seeded from whichever track is open.
+ *
+ * `start` and `doc` are only the *initial* state, so the parent gives this a key of the
+ * track id: changing tracks remounts it rather than trying to reconcile a policy the
+ * reader may have edited with a document it no longer matches.
+ */
+function Playground({start, doc, num}) {
+ const [policy, setPolicy] = useState(start);
+ const [input, setInput] = useState(doc);
return (
- 07
+ {num}
Playground
@@ -140,8 +147,8 @@ function Playground() {
type="button"
className={styles.reset}
onClick={() => {
- setPolicy(PLAYGROUND_START);
- setInput(INPUT_DOC);
+ setPolicy(start);
+ setInput(doc);
}}>
Reset
@@ -153,7 +160,60 @@ function Playground() {
);
}
+/*
+ * The chooser.
+ *
+ * Three providers stacked on one page meant a reader wanting Kubernetes scrolled past nine
+ * lessons about something else to reach two about their own problem. So the page asks first
+ * and shows one track.
+ *
+ * WHY THE HASH IS THE STATE. `/learn/#kubernetes` has to open on Kubernetes, because that is
+ * the link someone sends a colleague, and `#playground` is already linked from the Skills
+ * page and must keep working. Reading it on mount rather than tracking a separate piece of
+ * state means a deep link and a click end up in exactly the same place.
+ *
+ * The initial render is always the first track, deliberately: this page is prerendered at
+ * build time, where there is no location, and a component that renders one thing on the
+ * server and another on the client is a hydration mismatch. The effect below corrects it
+ * after mount, which is one frame on a page whose content is several screens tall.
+ */
+function useTrack() {
+ const [id, setId] = useState(TRACKS[0].id);
+
+ useEffect(() => {
+ const fromHash = () => {
+ const hash = window.location.hash.replace('#', '');
+ if (!hash) return;
+ // A track id, or any lesson inside one: both mean "open that track".
+ const track =
+ TRACKS.find((t) => t.id === hash) ||
+ TRACKS.find((t) => t.lessons.some((l) => l.id === hash));
+ if (track) setId(track.id);
+ };
+ fromHash();
+ window.addEventListener('hashchange', fromHash);
+ return () => window.removeEventListener('hashchange', fromHash);
+ }, []);
+
+ return [id, setId];
+}
+
export default function Learn() {
+ const [trackId, setTrackId] = useTrack();
+ const track = TRACKS.find((t) => t.id === trackId) || TRACKS[0];
+
+ /*
+ * Choosing a track rewrites the hash without a navigation, so the back button walks the
+ * reader's choices instead of leaving the page, and a copied URL carries the track. No
+ * scroll, because the selector is what they just clicked and it should stay put.
+ */
+ const choose = useCallback((id) => {
+ setTrackId(id);
+ if (typeof window !== 'undefined') {
+ window.history.replaceState(null, '', `#${id}`);
+ }
+ }, [setTrackId]);
+
return (
Tirith
- Eleven lessons and a playground
+ Three providers, eleven lessons
@@ -180,9 +240,9 @@ export default function Learn() {
- Three providers, a lesson at a time. Start with any JSON document, then
- the same syntax against an OpenTofu or Terraform plan and a set of
- Kubernetes manifests. Every step is editable: change a value, run it, and
+ Pick the thing you actually need to gate and learn on that. The syntax is
+ the same for all three, so the JSON track teaches it fastest and the other
+ two teach what changes. Every step is editable: change a value, run it, and
watch the verdict, the messages and the exit code move with it.
@@ -217,41 +277,77 @@ export default function Learn() {
-
+ {/*
+ * The chooser. Radio semantics rather than buttons, because these are three
+ * states of one setting and a screen reader should be told they are exclusive.
+ */}
+
+
+ Choose what you want to gate
+
+
+ {TRACKS.map((t) => {
+ const active = t.id === track.id;
+ return (
+
+ );
+ })}
+
+
{/*
- * One section per provider. The track header carries the provider name because
- * every policy below it repeats that name in its own meta block, and a reader
- * who lands on lesson 10 from the contents needs to know which document the
- * pane on the right is showing them.
+ * The open track. Keyed on the track id so switching remounts every lesson:
+ * each one holds the reader's edits in its own state, and reconciling those
+ * against a different document would leave a policy and an input that do not
+ * belong together.
*/}
- {TRACKS.map((track) => (
-
-
- {track.provider}
-
- {track.title}
-
- {track.lede}
-
- {track.lessons.map((lesson) => (
-
+
+
+ {track.provider}
+
+ {track.title}
+
+ {track.lede}
+ {track.forYou}
+
+
+
- ))}
+
+
+ {String(track.lessons.length + 1).padStart(2, '0')}
+
+ Playground
+
+
-
+ {track.lessons.map((lesson) => (
+
+ ))}
+
+
+
diff --git a/documentation/src/pages/learn.module.css b/documentation/src/pages/learn.module.css
index 379f2e20..42cbbae8 100644
--- a/documentation/src/pages/learn.module.css
+++ b/documentation/src/pages/learn.module.css
@@ -909,3 +909,147 @@
.track:first-of-type {
margin-top: 3rem;
}
+
+/* ------------------------------------------------------------------ chooser ---- */
+
+/*
+ * Three providers, one of which is open.
+ *
+ * A row of shared-edge cells rather than three separate controls: the tracks are three
+ * states of one setting, and hairlines between them say that where a gap would not. The
+ * open one is filled ink so the choice survives a glance from across the page, which a
+ * coloured underline on a page this long does not.
+ */
+.chooser {
+ margin: 3rem 0 0;
+}
+
+.chooserLabel {
+ display: block;
+ margin-bottom: 0.85rem;
+ color: var(--tp-faint);
+ font-family: var(--tp-display);
+ font-size: 0.58rem;
+ font-weight: 700;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+}
+
+.chooserTabs {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ border: 1px solid var(--tp-rule);
+}
+
+/*
+ * SHARED RULE CARRIES STRUCTURE ONLY. No colour here, and that is load-bearing.
+ *
+ * The obvious way to write this is `.tab, .tabOn {background: paper}` followed by
+ * `.tabOn {background: ink}`, relying on source order between two rules of equal
+ * specificity. It works in development and breaks in the build: cssnano groups rules that
+ * share a declaration block, so `.tabOn`'s ink background was merged into an unrelated
+ * rule that also sets ink on paper (the copy button's hover) and hoisted above the paper
+ * rule. The selected tab then rendered white, with its provider id paper-on-paper and
+ * therefore invisible.
+ *
+ * Giving each state its own background means no element is ever matched by two rules that
+ * declare the same property at the same specificity, so cascade order stops mattering and
+ * the minifier can group whatever it likes.
+ */
+.tab,
+.tabOn {
+ display: grid;
+ gap: 0.3rem;
+ padding: 1.1rem 1.25rem;
+ border: 0;
+ border-radius: 0;
+ cursor: pointer;
+ text-align: left;
+ transition: background 120ms ease-out, color 120ms ease-out;
+}
+
+.tab + .tab,
+.tab + .tabOn,
+.tabOn + .tab {
+ border-left: 1px solid var(--tp-rule);
+}
+
+.tab {
+ background: var(--tp-paper);
+ color: var(--tp-ink);
+}
+
+.tab:hover {
+ background: var(--tp-surface);
+ color: var(--tp-ink);
+}
+
+.tabOn {
+ background: var(--tp-ink);
+ color: var(--tp-paper);
+}
+
+.tab:focus-visible,
+.tabOn:focus-visible {
+ outline: 2px solid var(--tp-accent);
+ outline-offset: -3px;
+}
+
+.tabName {
+ font-family: var(--tp-display);
+ font-size: 0.9rem;
+ font-weight: 700;
+ letter-spacing: -0.01em;
+}
+
+.tabProvider {
+ color: var(--tp-soft);
+ font-family: var(--tp-mono);
+ font-size: 0.68rem;
+ /* The provider id is long, and it must not force the three cells to different widths. */
+ overflow-wrap: anywhere;
+}
+
+.tabOn .tabProvider {
+ /* --tp-soft on ink is under 3:1. The paper token at reduced opacity holds the
+ hierarchy without dropping below contrast. */
+ color: var(--tp-paper);
+ opacity: 0.72;
+}
+
+.tabCount {
+ color: var(--tp-faint);
+ font-family: var(--tp-display);
+ font-size: 0.56rem;
+ font-weight: 700;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+}
+
+.tabOn .tabCount {
+ color: var(--tp-paper);
+ opacity: 0.6;
+}
+
+.trackForYou {
+ margin: 0.9rem 0 0;
+ padding-left: 0.9rem;
+ border-left: 2px solid var(--tp-accent);
+ color: var(--tp-ink);
+ font-size: 0.92rem;
+ line-height: 1.55;
+}
+
+/* Stacked below the point where three cells of monospaced provider ids stop fitting. */
+@media (max-width: 720px) {
+ .chooserTabs {
+ grid-template-columns: 1fr;
+ }
+
+ .tab + .tab,
+ .tab + .tabOn,
+ .tabOn + .tab {
+ border-top: 1px solid var(--tp-rule);
+ border-left: 0;
+ }
+}