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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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;
+ }
+}
From 62f3363cc29c3265a0e12239044f1850dd06c996 Mon Sep 17 00:00:00 2001
From: AKSHAT TANDON
Date: Tue, 8 Sep 2026 00:41:20 +0200
Subject: [PATCH 11/13] Add lint and format documentation, update exit codes,
and enhance skills
- Introduced new documentation for `tirith lint` and `tirith fmt` commands, detailing their usage, flags, and exit codes.
- Updated `llms-full.txt` and `llms.txt` to reflect the addition of linting and formatting capabilities, including their integration in CI and pre-commit hooks.
- Enhanced the `skill.sh` script to download three skills: `tirith-policies`, `tirith-standards`, and `tirith-migrate`, with appropriate file structures.
- Clarified exit codes for `tirith lint` and `tirith fmt`, ensuring consistency across commands.
- Added examples and notes regarding the usage of the new commands and their integration into development workflows.
---
.claude/skills/tirith-policies/SKILL.md | 6 +-
.../tirith-policies/reference/pipelines.md | 22 +-
.../tirith-policies/reference/validate.md | 44 +-
.../tirith-policies/reference/verdicts.md | 9 +-
.claude/skills/tirith-standards/SKILL.md | 146 ++++
.../examples/org-standards/README.md | 61 ++
.../examples/org-standards/naming.json | 37 +
.../examples/org-standards/no-bucket.json | 29 +
.../examples/org-standards/should-fail.json | 52 ++
.../examples/org-standards/should-pass.json | 52 ++
.../tirith-standards/reference/standards.md | 202 +++++
.cursor/rules/tirith-policies.mdc | 5 +-
README.md | 3 +
.../docs/tirith-usage/agent-skills.md | 30 +-
.../docs/tirith-usage/ci-integration.md | 33 +-
.../docs/tirith-usage/cli-reference.md | 20 +-
.../docs/tirith-usage/editor-and-local.md | 65 +-
documentation/docs/tirith-usage/exit-codes.md | 32 +-
.../docs/tirith-usage/lint-and-fmt.md | 216 +++++
documentation/scripts/generate-llms-full.py | 1 +
documentation/sidebars.js | 1 +
.../src/components/landing/PhaseJourney.js | 11 +-
.../src/components/landing/PlatformSetup.js | 5 +
documentation/src/data/agentBrief.js | 2 +-
documentation/src/data/demoPhases.js | 66 +-
documentation/src/data/lessons.js | 752 ++++++++++++++++--
documentation/src/data/roadmap.js | 9 +-
documentation/src/data/tirithLite.js | 24 +-
documentation/src/pages/at-scale.js | 124 ++-
documentation/src/pages/index.js | 485 ++++++-----
documentation/src/pages/index.module.css | 291 +++----
documentation/src/pages/learn.js | 14 +-
documentation/src/pages/skills.js | 161 +++-
documentation/src/pages/skills.module.css | 105 +++
.../static/docs/tirith-usage/agent-skills.md | 30 +-
.../docs/tirith-usage/ci-integration.md | 33 +-
.../static/docs/tirith-usage/cli-reference.md | 20 +-
.../docs/tirith-usage/editor-and-local.md | 63 +-
.../static/docs/tirith-usage/exit-codes.md | 32 +-
.../static/docs/tirith-usage/lint-and-fmt.md | 205 +++++
documentation/static/llms-full.txt | 386 +++++++--
documentation/static/llms.txt | 60 +-
documentation/static/skill.sh | 27 +-
43 files changed, 3173 insertions(+), 798 deletions(-)
create mode 100644 .claude/skills/tirith-standards/SKILL.md
create mode 100644 .claude/skills/tirith-standards/examples/org-standards/README.md
create mode 100644 .claude/skills/tirith-standards/examples/org-standards/naming.json
create mode 100644 .claude/skills/tirith-standards/examples/org-standards/no-bucket.json
create mode 100644 .claude/skills/tirith-standards/examples/org-standards/should-fail.json
create mode 100644 .claude/skills/tirith-standards/examples/org-standards/should-pass.json
create mode 100644 .claude/skills/tirith-standards/reference/standards.md
create mode 100644 documentation/docs/tirith-usage/lint-and-fmt.md
create mode 100644 documentation/static/docs/tirith-usage/lint-and-fmt.md
diff --git a/.claude/skills/tirith-policies/SKILL.md b/.claude/skills/tirith-policies/SKILL.md
index 899750e6..81e1634a 100644
--- a/.claude/skills/tirith-policies/SKILL.md
+++ b/.claude/skills/tirith-policies/SKILL.md
@@ -99,8 +99,8 @@ and invert it.
- **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.
+- **`tirith lint` catches most of the above before CI does**, and needs no plan document. It is
+ on `main` but not in `1.2.0`, so pin `@main` to use it. `reference/validate.md` has the detail.
## Test it with the bundled example
@@ -127,7 +127,7 @@ tirith -policy-path policy.json -input-path should-pass.json --fail-on-error; ec
| 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 why `tirith lint` is not the way |
+| `reference/validate.md` | Checking a policy is well-formed with `tirith lint`, `tirith fmt` and the interface |
| `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 |
diff --git a/.claude/skills/tirith-policies/reference/pipelines.md b/.claude/skills/tirith-policies/reference/pipelines.md
index 9139298b..c007ae7f 100644
--- a/.claude/skills/tirith-policies/reference/pipelines.md
+++ b/.claude/skills/tirith-policies/reference/pipelines.md
@@ -31,8 +31,9 @@ pip install "git+https://github.com/StackGuardian/tirith.git@1.2.0"
Not PyPI: `pip install tirith` fetches an unrelated project. Pin the tag so a job cannot change
behaviour underneath you. Python 3.8 or newer, so any `python:3.x` image works.
-Do **not** add `tirith lint` to a pipeline: it is not in the released package. See
-`reference/validate.md`.
+Add `tirith lint .tirith/policies` as a step before the gate: it needs no plan document, so it
+can run in a job with no cloud credentials, and it fails for a different reason than the gate
+does. It is not in `1.2.0`, so pin `@main` in any job that uses it. See `reference/validate.md`.
---
@@ -205,7 +206,18 @@ tirith --json -policy-path .tirith/policies -input-path plan.json > tirith-resul
Publish it as a build artifact. It carries every evaluator, its result and the value that produced
it, which is what makes a failure explainable after the fact.
-## Not yet available
+## At commit time
-A pre-commit hook and an editor loop depend on `tirith lint`, which does not ship. See
-`reference/validate.md`.
+`.pre-commit-hooks.yaml` publishes `tirith-lint` and `tirith-fmt`, both running on the policy
+files a commit touches:
+
+```yaml
+repos:
+ - repo: https://github.com/StackGuardian/tirith
+ rev: main
+ hooks:
+ - id: tirith-lint
+ - id: tirith-fmt
+```
+
+`rev` is a branch because neither command is in `1.2.0` yet. See `reference/validate.md`.
diff --git a/.claude/skills/tirith-policies/reference/validate.md b/.claude/skills/tirith-policies/reference/validate.md
index bfa4327e..85f0fedd 100644
--- a/.claude/skills/tirith-policies/reference/validate.md
+++ b/.claude/skills/tirith-policies/reference/validate.md
@@ -1,21 +1,34 @@
# Validate a policy
-## `tirith lint` is not in the released package
+## `tirith lint` checks the shape
-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. `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.
+This is the one place in the pack that explains it; the other files point here.
-Two ways to validate exist today.
+```bash
+tirith lint .tirith/policies
+```
+
+It reads the live `EVALUATORS_DICT` and `PROVIDERS_DICT` registries, so it catches every trap in
+the table below except the ones only evaluation can catch. Exit `3` when a policy has an error,
+`1` when a path is missing or nothing was found, `0` when clean. `--strict` counts warnings as
+errors, `--json` emits the findings as a document.
+
+With no path it lints `.tirith/policies` if that directory exists, otherwise the current
+directory. JSON that is not a policy is skipped, so pointing it at a directory holding plan
+documents is safe.
+
+**It is not in 1.2.0.** `tirith lint` and `tirith fmt` are on `main` and arrive in the next
+release. Install `@main` rather than the tag if you want them now:
+`pip install "git+https://github.com/StackGuardian/tirith.git@main"`.
+
+`tirith fmt` rewrites a policy into the canonical layout, and `tirith fmt --check` exits `3` if
+a file would change. Neither command needs a plan document, which is why both work in a
+pre-commit hook.
-## The interactive validator does ship
+## The same validator, interactively
-`tirith ui` carries one. `src/tirith/tui/validate.py` reads the live `EVALUATORS_DICT` and
-`PROVIDERS_DICT` and returns errors and warnings as data, and the Playground runs it on every
-keystroke while the Builder refuses to add a check that fails it. So the registry-checking that
-`tirith lint` will do from the command line is already in the product, just interactively:
+`tirith ui` carries the one `tirith lint` calls. The Playground runs it on every keystroke while
+the Builder refuses to add a check that fails it:
```bash
pip install 'py-tirith[tui] @ git+https://github.com/StackGuardian/tirith.git'
@@ -25,10 +38,11 @@ tirith ui --policy .tirith/policies/my-policy.json
It is advisory by design: it reports a malformed policy rather than refusing to evaluate it,
because experimenting with a half-written policy is the point of a playground.
-## Without the interface
+## Without either command
-Check the shape against the closed vocabulary by hand, then evaluate the policy against a document
-that should fail it. The second is the one that matters.
+Pinned to `1.2.0` and unable to install `@main`? Check the shape against the closed vocabulary by
+hand, then evaluate the policy against a document that should fail it. The second is the one that
+matters, and it works on every version.
## Check the shape
diff --git a/.claude/skills/tirith-policies/reference/verdicts.md b/.claude/skills/tirith-policies/reference/verdicts.md
index baaa1037..c574463e 100644
--- a/.claude/skills/tirith-policies/reference/verdicts.md
+++ b/.claude/skills/tirith-policies/reference/verdicts.md
@@ -17,9 +17,12 @@ Add `--json` to get the result document instead of the pretty printer.
| `3` | A policy ran and said no |
| `130` | Interrupted |
-`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.
+`2` is never returned by Tirith itself. An unknown first argument is reported by name
+(`tirith: 'lnit' is not a tirith command`) and exits `1`, the same as a tool or input problem.
+Tirith has no timeout code: a `platform check` that times out is `1` too.
+
+`tirith lint` and `tirith fmt` use the same codes and need no `--fail-on-error`: `3` for a finding,
+`1` for a missing path or nothing found, `0` for clean.
**`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
diff --git a/.claude/skills/tirith-standards/SKILL.md b/.claude/skills/tirith-standards/SKILL.md
new file mode 100644
index 00000000..458833e1
--- /dev/null
+++ b/.claude/skills/tirith-standards/SKILL.md
@@ -0,0 +1,146 @@
+---
+name: tirith-standards
+description: Generate a Tirith policy set for an existing Terraform or OpenTofu repository, covering organization standards such as required tags, naming conventions, allowed regions, permitted resource types, sizes and encryption defaults. Use when asked to add guardrails to a repository that has no policies yet, to enforce a tagging or naming standard, to codify a team convention, or to produce a starting policy set from existing IaC. Requires the tirith-policies skill for the vocabulary.
+---
+
+# Standards policies from existing IaC
+
+A catalogue of generic checks is not what stops your incidents. The rules that do are the ones
+only your organization can state: the tag your finance team reconciles against, the module
+registry you are allowed to pull from, the two regions your data residency answer depends on.
+
+This skill turns a repository you already have into that policy set.
+
+## Vocabulary comes from `tirith-policies`
+
+Do not write policy JSON 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.
+
+## The boundary that decides every policy: HCL in, plan out
+
+You read `.tf` and `.tofu` files to **discover** what to write policies about. The policies
+themselves never see that HCL. They evaluate `terraform show -json` output, where:
+
+| In your code | In the plan |
+| --- | --- |
+| `var.environment`, `local.tags`, `${}` interpolation | already resolved to a value, or `null` when it is not known until apply |
+| a `module` block | its resources, at addresses like `module.vpc.aws_subnet.this[0]` |
+| `count` / `for_each` | one resource instance per element |
+| a computed name | `null`, because the plan does not know it yet |
+| `dynamic` blocks | the blocks they expanded into |
+
+So: never write a policy about a variable, a local, or a module block. Write it about the
+resource attribute the plan will carry. A standard that is only enforceable in HCL (for example
+"always use our module rather than the raw resource") is expressible as
+`direct_references` or as a `provider_config` check, or not at all. Say which, rather than
+producing a policy that reads nothing.
+
+## The rule that catches most generated policies
+
+```
+severity > error_tolerance -> the check FAILS
+otherwise -> the check is SKIPPED
+```
+
+`DEFAULT_ERROR_TOLERANCE` is `0`, and the plan provider raises severity `1` when the resource
+type is absent from the plan and `2` when the attribute is absent from the resource.
+
+**Therefore a policy scoped to one resource type has no green outcome on a plan that does not
+touch that type.** Both tolerance settings are red, in different ways, and this is the single
+most common defect in a generated standards set:
+
+| `error_tolerance` | Plan has no resource of that type | Reads as |
+| --- | --- | --- |
+| omitted, so `0` | `1 > 0`, the check **fails**, exit `3` | your infrastructure violates a policy |
+| `1` | the check is **skipped**, so every check is skipped, `final_result: null`, exit `1` | Tirith could not reach a verdict |
+
+### Guard every type-scoped policy
+
+`count` behaves differently from `attribute` on an absent type: it returns `0` rather than
+raising severity `1`. So it can gate the rule.
+
+```json
+{
+ "evaluators": [
+ {"id": "not_applicable",
+ "provider_args": {"operation_type": "count", "terraform_resource_type": "aws_s3_bucket"},
+ "condition": {"type": "Equals", "value": 0}},
+ {"id": "the_rule", "provider_args": {"...": "..."},
+ "condition": {"type": "RegexMatch", "value": "...", "error_tolerance": 1}}
+ ],
+ "eval_expression": "not_applicable || the_rule"
+}
+```
+
+Verified on the engine: exit `3` when a bucket breaks the rule, `0` when they all satisfy it,
+`0` when the plan touches no bucket at all. `examples/org-standards/` is that policy with its
+three plans.
+
+Keep `error_tolerance: 1` on the rule alongside the guard, so the not-applicable case is
+*skipped* rather than *failed and then masked by the `||`*. Both give exit `0`; only one leaves
+a clean result document.
+
+| Standard | Scope | Shape |
+| --- | --- | --- |
+| Presence, all types | `"*"` | no guard needed, omit the tolerance. A plan always contains something |
+| Anything scoped to a type | `aws_s3_bucket` | guard + `error_tolerance: 1` |
+| Anything | any | never `2`. It forgives the missing attribute, which is the thing you were checking |
+
+Deletes are safe by default: a destroyed resource is severity `0`, which the default tolerance
+skips rather than fails.
+
+## Protocol
+
+1. **Inventory the repository.** List every distinct `resource "TYPE" "NAME"` across `.tf` and
+ `.tofu` files. Group by provider (`aws_`, `azurerm_`, `google_`). Note which types are used
+ more than once: those are where a standard pays for itself. Note the modules in use and where
+ they are sourced from.
+
+2. **Extract the conventions already there.** Read the existing code before proposing rules. What
+ tag keys appear on most resources? What naming pattern do the existing names follow? Which
+ regions and instance sizes appear? A standard the repository already follows is a rule you can
+ enforce today without a migration; a standard it does not follow yet is a change request, and
+ you must say so rather than shipping a policy that fails on day one.
+
+3. **Propose before writing.** Give the user a table: standard, the resource types it would cover,
+ how many resources in the repository would pass today, and how many would fail. Let them
+ choose. Do not generate thirty policies unasked.
+
+4. **Write one file per standard**, under `.tirith/policies/`, named for the rule
+ (`required-tags.json`, `naming-s3.json`). One concern per file: a single policy combining tags
+ and naming reports one verdict for two unrelated problems, and the person who has to fix it
+ cannot tell which fired.
+
+5. **Prove each one.** For every policy, produce a plan document that violates it and confirm
+ `tirith -policy-path P -input-path should-fail.json --fail-on-error` exits `3`, then one that
+ satisfies it and confirm exit `0`. A policy that has only been seen passing is untested. Run
+ `tirith lint` over the directory first: it catches an invented condition type or a misplaced
+ `error_tolerance` without needing any plan at all.
+
+6. **Report what you could not express.** Name the standard, name what Tirith would need, and stop.
+ A policy that approximates a rule without saying so is worse than an absent one.
+
+## The standards catalogue
+
+`reference/standards.md` carries one row per standard: the exact `provider_args`, the condition,
+the tolerance, and the trap specific to that standard. Read it before writing any of them. It
+covers required tags, tag value shape, naming conventions, allowed regions, permitted and
+forbidden resource types, size ceilings, encryption and public-access defaults, provider version
+pinning, and module source restrictions.
+
+## What not to generate
+
+| | Why |
+| --- | --- |
+| A policy per resource instance | Standards are about types, not addresses. `terraform_resource_type` already quantifies over every instance |
+| A policy about `var.` or `local.` | Neither survives into the plan. See the boundary table above |
+| `error_tolerance: 2` on a presence check | It forgives exactly the case the check exists to catch |
+| A rule the repository does not already follow, shipped silently | It fails on the first run and gets disabled. Propose it as a change first |
+| Thirty policies from one request | Propose the set, ship what is agreed |
+
+## Worked example
+
+`examples/org-standards/` holds a naming policy with the plan that violates it and the plan that
+satisfies it, including the `error_tolerance: 1` that keeps it quiet on unrelated changes.
diff --git a/.claude/skills/tirith-standards/examples/org-standards/README.md b/.claude/skills/tirith-standards/examples/org-standards/README.md
new file mode 100644
index 00000000..a1412345
--- /dev/null
+++ b/.claude/skills/tirith-standards/examples/org-standards/README.md
@@ -0,0 +1,61 @@
+# Worked example: a naming standard that stays green on unrelated changes
+
+One policy and three plans. It exists to demonstrate the shape every type-scoped standards
+policy needs, because the obvious version of this policy is red on most pull requests.
+
+| File | |
+| --- | --- |
+| `naming.json` | `RegexMatch` on `aws_s3_bucket.bucket`, guarded by a `count` evaluator |
+| `should-fail.json` | Two buckets, one named `my-logs-bucket`. Expect exit `3` |
+| `should-pass.json` | The same two buckets, both named to convention. Expect exit `0` |
+| `no-bucket.json` | A plan that touches only an EC2 instance. Expect exit `0` |
+
+```bash
+cd .claude/skills/tirith-standards/examples/org-standards
+for doc in should-fail should-pass no-bucket; do
+ tirith -policy-path naming.json -input-path "$doc.json" --fail-on-error >/dev/null 2>&1
+ echo "$doc -> exit $?"
+done
+# should-fail -> exit 3
+# should-pass -> exit 0
+# no-bucket -> exit 0
+```
+
+## Why the guard is there
+
+A policy scoped to one resource type has no green outcome on a plan that does not touch that
+type. Both settings are red, in different ways:
+
+| `error_tolerance` | Plan has no bucket | Reads as |
+| --- | --- | --- |
+| omitted, so `0` | severity `1` is greater than `0`, so the check **fails**, exit `3` | your infrastructure violates a policy |
+| `1` | the check is **skipped**, every check is skipped, `final_result` is `null`, exit `1` | Tirith could not reach a verdict |
+
+Neither is what you want on a pull request that only edits a Lambda.
+
+The guard fixes it because `count` behaves differently from `attribute` on an absent type: it
+returns `0` rather than raising severity `1`. So `no_bucket_in_this_plan` evaluates cleanly to
+`true`, the naming check is skipped and dropped from the expression, and
+
+```
+no_bucket_in_this_plan || bucket_name_matches_convention
+```
+
+is `true`. On a plan that does have buckets the guard is `false` and the naming check decides.
+
+`error_tolerance: 1` stays on the naming evaluator so that in the no-bucket case it is *skipped*
+rather than *failed and then masked by the guard*. Both produce exit `0` here, but a failure
+hidden behind an `||` shows up in `--json` output and in the printed result, and a reader
+debugging the policy later should not have to work out whether that failure mattered.
+
+## Things to try
+
+- Delete the guard evaluator and set `eval_expression` to the naming check alone. `no-bucket.json`
+ now exits `1` with `final_result: null`.
+- Delete the guard *and* the `error_tolerance`. `no-bucket.json` now exits `3`, reporting a
+ policy violation for a plan containing nothing the policy is about.
+- Change `RegexMatch` to `Matches`. It does not exist. The run exits `3` with `errors` empty, so
+ CI sees a violation and only the message says the evaluator is unsupported.
+- Add a bucket using `bucket_prefix` instead of `bucket` to `should-pass.json`. The name is not
+ known at plan time, arrives as `null`, and fails the regex. This is the trap named in
+ `meta.description`.
diff --git a/.claude/skills/tirith-standards/examples/org-standards/naming.json b/.claude/skills/tirith-standards/examples/org-standards/naming.json
new file mode 100644
index 00000000..daedf8f7
--- /dev/null
+++ b/.claude/skills/tirith-standards/examples/org-standards/naming.json
@@ -0,0 +1,37 @@
+{
+ "meta": {
+ "version": "v1",
+ "required_provider": "stackguardian/terraform_plan",
+ "name": "S3 bucket names follow the acme-- convention",
+ "description": "Guarded so a plan touching no bucket passes rather than erroring. Without the guard this policy cannot be green on an unrelated change: at the default tolerance an absent resource type is severity 1 and fails (exit 3), and at error_tolerance 1 it is skipped, which leaves final_result null and exits 1. Buckets using bucket_prefix arrive with a null name and will fail; scope the rule if the repository uses them."
+ },
+ "evaluators": [
+ {
+ "id": "no_bucket_in_this_plan",
+ "description": "True when the plan touches no aws_s3_bucket, so the naming rule does not apply. count returns 0 for an absent type rather than raising severity 1, which is what makes it usable as a guard.",
+ "provider_args": {
+ "operation_type": "count",
+ "terraform_resource_type": "aws_s3_bucket"
+ },
+ "condition": {
+ "type": "Equals",
+ "value": 0
+ }
+ },
+ {
+ "id": "bucket_name_matches_convention",
+ "description": "Every aws_s3_bucket name is acme-, then dev|staging|prod, then a lowercase purpose. error_tolerance 1 skips this cleanly when there is no bucket, rather than failing and relying on the guard to mask it.",
+ "provider_args": {
+ "operation_type": "attribute",
+ "terraform_resource_type": "aws_s3_bucket",
+ "terraform_resource_attribute": "bucket"
+ },
+ "condition": {
+ "type": "RegexMatch",
+ "value": "^acme-(dev|staging|prod)-[a-z0-9-]+$",
+ "error_tolerance": 1
+ }
+ }
+ ],
+ "eval_expression": "no_bucket_in_this_plan || bucket_name_matches_convention"
+}
diff --git a/.claude/skills/tirith-standards/examples/org-standards/no-bucket.json b/.claude/skills/tirith-standards/examples/org-standards/no-bucket.json
new file mode 100644
index 00000000..a88ec17a
--- /dev/null
+++ b/.claude/skills/tirith-standards/examples/org-standards/no-bucket.json
@@ -0,0 +1,29 @@
+{
+ "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": {
+ "Owner": "platform"
+ }
+ },
+ "after_unknown": {
+ "arn": true,
+ "id": true
+ }
+ }
+ }
+ ]
+}
diff --git a/.claude/skills/tirith-standards/examples/org-standards/should-fail.json b/.claude/skills/tirith-standards/examples/org-standards/should-fail.json
new file mode 100644
index 00000000..3d33a3ec
--- /dev/null
+++ b/.claude/skills/tirith-standards/examples/org-standards/should-fail.json
@@ -0,0 +1,52 @@
+{
+ "format_version": "1.2",
+ "terraform_version": "1.5.7",
+ "resource_changes": [
+ {
+ "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": "acme-prod-assets",
+ "tags": {
+ "Owner": "platform"
+ }
+ },
+ "after_unknown": {
+ "arn": true,
+ "id": true
+ }
+ }
+ },
+ {
+ "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": "my-logs-bucket",
+ "tags": {
+ "Owner": "platform"
+ }
+ },
+ "after_unknown": {
+ "arn": true,
+ "id": true
+ }
+ }
+ }
+ ]
+}
diff --git a/.claude/skills/tirith-standards/examples/org-standards/should-pass.json b/.claude/skills/tirith-standards/examples/org-standards/should-pass.json
new file mode 100644
index 00000000..970fb202
--- /dev/null
+++ b/.claude/skills/tirith-standards/examples/org-standards/should-pass.json
@@ -0,0 +1,52 @@
+{
+ "format_version": "1.2",
+ "terraform_version": "1.5.7",
+ "resource_changes": [
+ {
+ "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": "acme-prod-assets",
+ "tags": {
+ "Owner": "platform"
+ }
+ },
+ "after_unknown": {
+ "arn": true,
+ "id": true
+ }
+ }
+ },
+ {
+ "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": "acme-prod-logs",
+ "tags": {
+ "Owner": "platform"
+ }
+ },
+ "after_unknown": {
+ "arn": true,
+ "id": true
+ }
+ }
+ }
+ ]
+}
diff --git a/.claude/skills/tirith-standards/reference/standards.md b/.claude/skills/tirith-standards/reference/standards.md
new file mode 100644
index 00000000..0cadc767
--- /dev/null
+++ b/.claude/skills/tirith-standards/reference/standards.md
@@ -0,0 +1,202 @@
+# The standards catalogue
+
+One row per standard. Every `provider_args` block below is
+`"required_provider": "stackguardian/terraform_plan"` unless it says otherwise, and every
+`error_tolerance` goes **inside `condition`**.
+
+**Every entry scoped to a resource type needs the guard evaluator from `SKILL.md`**, not just the
+tolerance shown here. Without it the policy is red on any plan that does not touch that type:
+exit `3` at tolerance `0`, exit `1` at tolerance `1`. The tolerance shown per standard is the one
+to pair with the guard.
+
+Read `../../tirith-policies/reference/schema.md` for the closed condition list. There is no
+`Exists`, no `Matches`, no `In` and no `NotRegexMatch`.
+
+---
+
+## Tags
+
+### Every resource carries a tag key
+
+```json
+{
+ "operation_type": "attribute",
+ "terraform_resource_type": "*",
+ "terraform_resource_attribute": "tags.Owner"
+}
+```
+`IsNotEmpty`, no `error_tolerance`.
+
+`"*"` is right here: the rule is "everything", and a plan always contains something. Leaving the
+tolerance at the default is what makes a missing tag fail, because an absent attribute is
+severity `2`.
+
+**Trap.** `"*"` includes resource types that cannot be tagged at all
+(`aws_iam_role_policy_attachment`, `random_id`, `null_resource`, every data source). Those fail
+on a rule they can never satisfy. If the repository has them, scope to the taggable types you
+actually use, one policy per type or one evaluator per type combined with `&&`, and set
+`error_tolerance: 1` on each.
+
+**Trap.** AWS provider v4+ splits tags into `tags` and `tags_all`. `tags` holds what the
+resource declares; `tags_all` includes provider-level `default_tags`. If your organization sets
+`default_tags`, check `tags_all.Owner` or the rule fails on resources that inherit correctly.
+
+### A tag must take one of a fixed set of values
+
+```json
+{
+ "operation_type": "attribute",
+ "terraform_resource_type": "aws_instance",
+ "terraform_resource_attribute": "tags.Environment"
+}
+```
+`ContainedIn` with `"value": ["dev", "staging", "prod"]`, `error_tolerance: 1`.
+
+**Trap.** `ContainedIn` on two lists is element membership, not subset. If the attribute is
+itself a list, this asks a different question than you think.
+
+### A tag must match a shape
+
+`RegexMatch` with `"value": "^[a-z]+-[0-9]{4}$"` on `tags.CostCentre`, `error_tolerance: 1`.
+
+**Trap.** `RegexMatch` against `null` does not match, so an absent tag fails. That is usually
+what you want; say so in `meta.description` so nobody later "fixes" it with a tolerance.
+
+---
+
+## Naming conventions
+
+The attribute holding the name **differs per resource type**. There is no generic `name`.
+
+| Type | Attribute |
+| --- | --- |
+| `aws_s3_bucket` | `bucket` |
+| `aws_instance` | `tags.Name` |
+| `aws_db_instance` | `identifier` |
+| `aws_iam_role`, `aws_lambda_function`, `aws_ecs_cluster` | `name` / `function_name` |
+| `azurerm_*` | `name` |
+| `google_*` | `name` |
+
+```json
+{
+ "operation_type": "attribute",
+ "terraform_resource_type": "aws_s3_bucket",
+ "terraform_resource_attribute": "bucket"
+}
+```
+`RegexMatch` with `"value": "^acme-(dev|staging|prod)-[a-z0-9-]+$"`, `error_tolerance: 1`.
+
+**Trap, and it is the big one.** A name the plan does not know yet arrives as `null`. That
+happens with `bucket_prefix`, with `name_prefix`, and with any name built from an attribute of a
+resource being created in the same plan. `RegexMatch` against `null` fails, so the rule rejects
+a change that is actually fine. Check the repository for `_prefix` arguments before shipping a
+naming rule, and if they are in use, scope the rule to the resources that set a literal name.
+
+**Trap.** Anchor the pattern. `"acme-"` without `^` matches `not-acme-prod-thing`.
+
+---
+
+## Allowed regions
+
+Region is not a resource attribute; it lives on the provider block.
+
+```json
+{
+ "operation_type": "provider_config",
+ "terraform_provider_full_name": "registry.terraform.io/hashicorp/aws",
+ "attribute_to_get": "region"
+}
+```
+`ContainedIn` with `"value": ["eu-central-1", "eu-west-1"]`.
+
+**Trap.** The full registry name is required, not `aws`. A wrong name is severity `1`.
+
+**Trap.** A region supplied by an environment variable or by an aliased provider will not appear
+here. This rule proves the declared region, not the effective one.
+
+---
+
+## Permitted and forbidden resource types
+
+Forbid a type by counting it:
+
+```json
+{"operation_type": "count", "terraform_resource_type": "aws_iam_user"}
+```
+`Equals` with `"value": 0`.
+
+**Trap.** `count` measures the root module and includes no-op resources, so a resource that is
+merely present and unchanged still counts. This rule reads "must not exist in this
+configuration", not "must not be created by this change".
+
+---
+
+## Size ceilings
+
+```json
+{
+ "operation_type": "attribute",
+ "terraform_resource_type": "aws_ebs_volume",
+ "terraform_resource_attribute": "size"
+}
+```
+`LessThanEqualTo` with `"value": 100`, `error_tolerance: 1`.
+
+For instance types, prefer `ContainedIn` against an allow-list over a regex: an allow-list is
+readable in a diff and a regex over instance families is not.
+
+---
+
+## Encryption and public access
+
+```json
+{
+ "operation_type": "attribute",
+ "terraform_resource_type": "aws_ebs_volume",
+ "terraform_resource_attribute": "encrypted"
+}
+```
+`Equals` with `"value": true`, `error_tolerance: 1`.
+
+**Trap.** `true` and `"true"` are different questions. `condition.value` keeps its JSON type.
+
+**Trap.** An argument the configuration omits is often present in the plan as the schema default,
+so it is checkable. An argument that is genuinely absent is severity `2` and fails at tolerance
+`1`, which for an encryption rule is the correct outcome: unset encryption is unencrypted.
+
+---
+
+## Provider version pinning
+
+```json
+{
+ "operation_type": "terraform_version"
+}
+```
+`GreaterThanEqualTo` with `"value": "1.6.0"`.
+
+**Trap.** This is a string comparison. `"1.10.0"` sorts below `"1.6.0"`. Pin a floor you are
+confident about lexically, or state the limitation in `meta.description`.
+
+---
+
+## Module source restrictions
+
+There is no operation that reads a module's `source`. The nearest available check is
+`direct_references`, which reports what a resource references, and it produces no edge for
+`var`, `local` or `module` references.
+
+**Say so rather than approximating.** "Only modules from our registry" is not expressible today.
+Tracking: the roadmap's `module_address` filter would make it so.
+
+---
+
+## Assembling the set
+
+- One file per standard under `.tirith/policies/`, named for the rule.
+- Fill `meta.name` and `meta.description`. The description is where a trap gets recorded, and it
+ is what a person reads when the check fires eighteen months from now.
+- `meta.severity` and `meta.tags` are carried into the result document but nothing gates on them
+ yet, so do not build a workflow that depends on them.
+- Run `tirith lint .tirith/policies` before running anything against a plan.
+- Prove each policy against a document that should fail it.
diff --git a/.cursor/rules/tirith-policies.mdc b/.cursor/rules/tirith-policies.mdc
index bd799a8c..7864a7d3 100644
--- a/.cursor/rules/tirith-policies.mdc
+++ b/.cursor/rules/tirith-policies.mdc
@@ -60,8 +60,9 @@ for that test, and exit `0` means it matched nothing.
tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error
```
-`tirith lint` is **not in the released package**; it is in development. Do not put it in a
-pipeline. The released CLI dispatches `tirith`, `tirith ui` and `tirith platform check`.
+`tirith lint` checks a policy's shape without a plan document, and `tirith fmt` rewrites it into
+the canonical layout. Both are on `main` but **not in `1.2.0`**, so pin `@main` in any job that
+uses them. The CLI dispatches `lint`, `fmt`, `ui` and `platform`.
## Installing, and adding it to CI
diff --git a/README.md b/README.md
index ffbc2883..dccf00c0 100644
--- a/README.md
+++ b/README.md
@@ -208,6 +208,9 @@ Subcommands:
organization enforces, rather than local files.
tirith ui --help Explore results, build policies and experiment in
an interactive interface. Needs the 'tui' extra.
+ tirith lint --help Check policy files for mistakes that would gate
+ nothing or fail as a false violation. No plan needed.
+ tirith fmt --help Rewrite policy files into the canonical layout.
About Tirith:
diff --git a/documentation/docs/tirith-usage/agent-skills.md b/documentation/docs/tirith-usage/agent-skills.md
index 6e3dc781..042b84b1 100644
--- a/documentation/docs/tirith-usage/agent-skills.md
+++ b/documentation/docs/tirith-usage/agent-skills.md
@@ -31,9 +31,10 @@ guess from a plausible-looking shape.
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, and they are picked up in any
-repository you copy them into. A session that is already running may not see a newly installed
+Three skills under `.claude/skills/`: `tirith-policies`, for writing policies,
+`tirith-standards`, for generating a policy set from a repository you already have, and
+`tirith-migrate`, 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 | |
@@ -98,9 +99,30 @@ 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 |
+## Standards from a repository you already have
+
+`tirith-standards` starts from the Terraform or OpenTofu you already run rather than from a
+catalogue. It reads the code to work out which resource types are in use and which conventions
+are already followed, proposes a set of standards with the count of resources that would pass and
+fail today, and writes one policy per rule: required tags, naming conventions, allowed regions,
+permitted resource types, size ceilings, encryption defaults.
+
+Two things in it are worth knowing even if you never install it, because they decide whether a
+generated policy is usable:
+
+- **The policies never see your HCL.** The skill reads `.tf` files only to decide what to write
+ about; the rules themselves evaluate `terraform show -json`, where variables are resolved,
+ modules are expanded into addressed resources, and a name that is not known until apply is
+ `null`. A rule about a variable or a module block reads nothing.
+- **A rule scoped to one resource type needs a guard.** With the default `error_tolerance` an
+ absent resource type is severity `1` and the check fails, so the rule is red on every plan that
+ does not touch that type. At `error_tolerance: 1` it is skipped instead, every check is skipped,
+ and `final_result` is `null`, which exits `1`. Neither is green. The skill's fix is a `count`
+ evaluator, which returns `0` for an absent type rather than erroring, combined with `||`.
+
## Migrating from Sentinel
-The second skill, `tirith-migrate`, is for teams with existing HashiCorp Sentinel policies. It is a
+The third 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
diff --git a/documentation/docs/tirith-usage/ci-integration.md b/documentation/docs/tirith-usage/ci-integration.md
index d2c63780..b29c7afc 100644
--- a/documentation/docs/tirith-usage/ci-integration.md
+++ b/documentation/docs/tirith-usage/ci-integration.md
@@ -191,11 +191,17 @@ pipelines:
- step:
name: Policy gate
script:
- - pip install "git+https://github.com/StackGuardian/tirith.git@1.2.0"
- # - tirith lint .tirith/policies # in dev, not in 1.2.0
+ # @main rather than @1.2.0, because the lint step below is not in 1.2.0 yet.
+ - pip install "git+https://github.com/StackGuardian/tirith.git@main"
+ - tirith lint .tirith/policies
- tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error
```
+Linting first is cheap and it fails for a different reason than the gate does: a policy that is
+malformed never gets as far as disagreeing with your infrastructure. It needs no plan document,
+so it can also run in a job that has no cloud credentials at all. See
+[lint and format](lint-and-fmt.md).
+
A worked repository is at
[tirith-bitbucket-demo](https://bitbucket.org/__refeed__/tirith-bitbucket-demo).
@@ -228,32 +234,31 @@ exit and the two cases become one.
## As a pre-commit hook
-:::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/).
+:::note Not in 1.2.0
+`tirith lint` and `tirith fmt` are on `main` and arrive in the next release, so pin `rev` to a
+branch until then. Everything else on this page works on 1.2.0.
:::
-Catch a broken policy before it is committed, let alone before CI runs it. Tirith will publish a
-`tirith-lint` hook:
+Catch a broken policy before it is committed, let alone before CI runs it. Tirith publishes a
+`tirith-lint` and a `tirith-fmt` hook:
```yaml title=".pre-commit-config.yaml"
repos:
- repo: https://github.com/StackGuardian/tirith
- rev: 1.2.0
+ rev: main
hooks:
- id: tirith-lint
+ - id: tirith-fmt
```
```bash
pre-commit install
-pre-commit run tirith-lint --all-files
+pre-commit run --all-files
```
-The hook runs only when a file under `.tirith/policies/` or a `*.tirith.json` changes. It lints
-the policy directory rather than the individual changed files, because `tirith lint` takes a
-single path.
+Both hooks run only when a file under `.tirith/` or a `*.tirith.json` changes, and both are
+handed the individual changed files rather than the whole directory: `tirith lint` and
+`tirith fmt` each take any number of paths, so a commit touching one policy checks one policy.
:::note Why linting and not evaluation
Evaluating a policy needs a plan document, and producing one means running `terraform plan` —
diff --git a/documentation/docs/tirith-usage/cli-reference.md b/documentation/docs/tirith-usage/cli-reference.md
index b8a726c5..b5f6d622 100644
--- a/documentation/docs/tirith-usage/cli-reference.md
+++ b/documentation/docs/tirith-usage/cli-reference.md
@@ -19,9 +19,23 @@ tirith -policy-path policy.json -input-path plan.json
Run with no arguments, `tirith` prints its help text and exits `0`.
-There is one subcommand, `tirith platform check`, which evaluates against the policies a
-StackGuardian organization enforces instead of local files. It has its own flags and its own page:
-[Platform Check](platform-check.md).
+Four subcommands sit beside it, each with its own flags and its own page:
+
+| Subcommand | What it does |
+|---|---|
+| [`tirith lint`](lint-and-fmt.md) | Check policy files for mistakes that would gate nothing or fail as a false violation. No plan document needed |
+| [`tirith fmt`](lint-and-fmt.md) | Rewrite policy files into the canonical layout |
+| [`tirith ui`](interactive-interface.md) | Explore results, build policies and experiment in an interactive interface. Needs the optional `tui` extra |
+| [`tirith platform check`](platform-check.md) | Evaluate against the policies a StackGuardian organization enforces instead of local files |
+
+A first argument that is not one of those four and does not begin with a dash is rejected by name,
+because the flat command below takes no positional arguments:
+
+```console
+$ tirith lnit .tirith/policies
+tirith: 'lnit' is not a tirith command. Commands: fmt, lint, platform, ui.
+Run 'tirith --help' for the local-evaluation options.
+```
## Flags
diff --git a/documentation/docs/tirith-usage/editor-and-local.md b/documentation/docs/tirith-usage/editor-and-local.md
index 052f7569..8d48c239 100644
--- a/documentation/docs/tirith-usage/editor-and-local.md
+++ b/documentation/docs/tirith-usage/editor-and-local.md
@@ -1,8 +1,8 @@
---
id: editor-and-local
title: In your editor
-sidebar_label: In your editor (in dev)
-description: In development — VS Code tasks, a pre-commit hook, and the local loop to use when an AI agent is drafting the policy. The lint half is not in the released package yet.
+sidebar_label: In your editor
+description: VS Code tasks, a pre-commit hook, and the local loop to use when an AI agent is drafting the policy.
keywords:
- tirith
- vscode
@@ -13,24 +13,15 @@ site_name: Tirith
slug: editor-and-local/
---
-:::warning In development — the lint half has not shipped
-
-`tirith lint`, the pre-commit hook and the VS Code tasks below are **in development**. They are
-not in the released package: `pip install "git+https://github.com/StackGuardian/tirith.git@1.2.0"`
-gives you `tirith`, `tirith ui` and `tirith platform check`, and no `lint` subcommand. The
-`tirith-lint` hook id and the task file are not in the repository yet either.
-
-**What works today** is the second half of the loop — evaluating a policy against a document with
-`tirith -policy-path … -input-path … --fail-on-error`. That runs locally, offline, on any
-installed version.
-
-This page is published now so the shape is reviewable. Follow
-[the repository](https://github.com/StackGuardian/tirith) for the release, or
-[tell us what the loop is missing](https://github.com/StackGuardian/tirith/issues/new/choose).
+:::note Not in 1.2.0
+`tirith lint` and `tirith fmt` are on `main` and will arrive in the next release. Until then,
+install from the branch rather than the tag:
+`pip install "git+https://github.com/StackGuardian/tirith.git@main"`. Evaluation, the second half
+of the loop, works on any installed version.
:::
-CI is the last place a policy should fail. This page is about the loop before that — running
-Tirith on your own machine, while the code is still being written.
+CI is the last place a policy should fail. This page is about the loop before that, running
+Tirith on your own machine while the code is still being written.
It matters most when an agent is drafting the policy for you. Generated policy JSON is plausible
by construction: it parses, it looks right, and a policy that matches nothing is indistinguishable
@@ -39,28 +30,34 @@ from one that works until you evaluate it.
## The loop
```bash
-tirith lint .tirith/policies # shape — in dev
-tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error # meaning — ships
+tirith fmt .tirith/policies # layout
+tirith lint .tirith/policies # shape
+tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error # meaning
```
-Linting checks the **shape** — that every condition type exists, that no `provider_args` key
-belongs to a different provider, that `eval_expression` names every evaluator. Only evaluation
-checks the **meaning**.
+Linting checks the **shape**: that every condition type exists, that no `provider_args` key
+belongs to a different provider, that `eval_expression` names every evaluator, that
+`error_tolerance` is inside `condition` where the engine reads it. Only evaluation checks the
+**meaning**. [Lint and format](lint-and-fmt.md) covers both commands in full.
Run both against a document that *should* fail. A guardrail only ever seen passing is a guardrail
nobody has tested.
## VS Code tasks
-**In development.** The lint task below calls a subcommand the released package does not have; the
-evaluate task works today.
-
Drop this in `.vscode/tasks.json` and the loop becomes one keystroke:
```json title=".vscode/tasks.json"
{
"version": "2.0.0",
"tasks": [
+ {
+ "label": "Tirith: format policies",
+ "type": "shell",
+ "command": "tirith fmt .tirith/policies",
+ "problemMatcher": [],
+ "group": "test"
+ },
{
"label": "Tirith: lint policies",
"type": "shell",
@@ -81,29 +78,29 @@ Drop this in `.vscode/tasks.json` and the loop becomes one keystroke:
```
`Tirith: check the plan` is the default test task, so **⇧⌘B** / **Ctrl+Shift+B** runs the whole
-loop. The complete file — including a task to refresh `plan.json` and one to open the result in
-the interactive explorer — is
-[`.vscode/tasks.json`](https://github.com/StackGuardian/tirith/blob/main/.vscode/tasks.json).
+loop: lint first, then evaluate, because `dependsOn` will not run the second if the first exits
+non-zero.
## Catch it at commit time
-**In development.** The `tirith-lint` hook id is not published yet, so this configuration will not
-resolve — `pre-commit` fails with an unknown hook rather than installing anything.
+Tirith publishes both commands as pre-commit hooks:
```yaml title=".pre-commit-config.yaml"
repos:
- repo: https://github.com/StackGuardian/tirith
- rev: 1.2.0
+ rev: main
hooks:
- id: tirith-lint
+ - id: tirith-fmt
```
```bash
pre-commit install
```
-The hook runs only when a policy file changes, needs no network, and exits `3` when a policy has
-an error. See [CI integration](ci-integration.md) for why it lints rather than evaluates.
+Both run only on the policy files a commit actually touches, need no network, and exit `3` on a
+finding. See [CI integration](ci-integration.md) for why they lint rather than evaluate, and
+[lint and format](lint-and-fmt.md) for the flags and the files they read.
## When an agent is writing the policy
diff --git a/documentation/docs/tirith-usage/exit-codes.md b/documentation/docs/tirith-usage/exit-codes.md
index cac5ad99..c42ff18e 100644
--- a/documentation/docs/tirith-usage/exit-codes.md
+++ b/documentation/docs/tirith-usage/exit-codes.md
@@ -11,18 +11,21 @@ site_name: Tirith
slug: exit-codes/
---
-Tirith's exit codes are a contract shared by both surfaces — local evaluation (`tirith`) and
-platform evaluation (`tirith platform check`) — so a caller scripting both only has to learn one
-vocabulary.
+Tirith's exit codes are one contract shared by every surface: local evaluation (`tirith`),
+platform evaluation (`tirith platform check`), and the two file commands, `tirith lint` and
+`tirith fmt`. A caller scripting more than one only has to learn a single vocabulary.
| Code | Meaning |
|---|---|
-| `0` | Policies passed, or nothing was in scope to gate on |
-| `1` | Tirith could not complete the evaluation — bad input, a policy it could not evaluate, unreachable API |
-| `2` | Timed out waiting for a StackGuardian run (`tirith platform check` only; local evaluation never produces it) |
-| `3` | A policy failed. Only with `--fail-on-error`, on either surface |
+| `0` | Policies passed, nothing was in scope to gate on, or the files were clean |
+| `1` | Tirith could not complete the run: bad input, a policy it could not evaluate, an unreachable API, a path that does not exist |
+| `3` | A policy said no. On the evaluation surfaces only with `--fail-on-error`; `lint` and `fmt` need no flag |
| `130` | Interrupted (Ctrl-C) |
+`2` is deliberately absent. It used to be a timeout code that nothing ever returned, and
+`argparse` already exits `2` of its own accord on a usage error, so a caller seeing `2` has
+passed a bad argument. Do not write a pipeline that branches on it.
+
## `3` is deliberately not `1`
`3` means a check ran and said no: your infrastructure violates a policy. `1` means Tirith could
@@ -36,6 +39,21 @@ Both surfaces **fail closed**: anything that leaves the verdict unknown exits no
of `--fail-on-error`. That flag governs policy verdicts, not tool health — a run that produced no
verdict must never look like a pass.
+## `tirith lint` and `tirith fmt`
+
+Both use the same three codes, and neither takes `--fail-on-error`: a linter that cannot fail is
+not a linter, and there is no existing green pipeline to protect because nothing has run them
+before.
+
+| Command | `0` | `3` | `1` |
+|---|---|---|---|
+| `tirith lint` | every policy is clean | a policy has an error-level finding, or a warning under `--strict` | a path is missing, or nothing was found to lint |
+| `tirith fmt` | nothing to change, or the changes were written | `--check` found files that would change | a path is missing, a file is not valid JSON, or nothing was found |
+
+"Nothing was found" being `1` rather than `0` is the same fail-closed rule the evaluation
+surfaces follow. A hook pointed at the wrong directory reports a problem instead of quietly
+passing every commit. See [lint and format](lint-and-fmt.md).
+
## Without `--fail-on-error`
The local command exits `0` whether the policy passed or failed, with the verdict in the output.
diff --git a/documentation/docs/tirith-usage/lint-and-fmt.md b/documentation/docs/tirith-usage/lint-and-fmt.md
new file mode 100644
index 00000000..7f02afef
--- /dev/null
+++ b/documentation/docs/tirith-usage/lint-and-fmt.md
@@ -0,0 +1,216 @@
+---
+id: lint-and-fmt
+title: Lint and format
+sidebar_label: Lint and format
+description: tirith lint checks policy files for mistakes that would gate nothing or fail as a false violation. tirith fmt rewrites them into one canonical layout. Neither needs a plan document.
+keywords:
+ - tirith
+ - lint
+ - fmt
+ - format
+ - pre-commit
+site_name: Tirith
+slug: lint-and-fmt/
+---
+
+Two commands that read policy files and nothing else. Neither needs a plan document, an account,
+or a network call, which is what lets them run in a pre-commit hook and in a slim CI image without
+the optional `tui` extra.
+
+```bash
+tirith lint .tirith/policies # is the policy well formed?
+tirith fmt .tirith/policies # rewrite it into the canonical layout
+```
+
+:::note Not in 1.2.0
+Both commands are on `main` and will arrive in the next release. To use them today, install from
+the branch rather than the `1.2.0` tag:
+
+```bash
+pip install "git+https://github.com/StackGuardian/tirith.git@main"
+```
+:::
+
+## `tirith lint`
+
+Lint checks the **shape**, not the meaning. It runs the same validator the
+[interactive interface](interactive-interface.md) runs on every keystroke, so the two never
+disagree, and it catches the class of mistake that otherwise reaches CI looking like a real
+infrastructure violation:
+
+- a condition `type` that does not exist, against the closed list of
+ [thirteen conditions](../tirith-reference/evaluators.md)
+- a `provider_args` key that belongs to a different provider than the one in
+ `meta.required_provider`
+- an evaluator that `eval_expression` never names, so it is computed and then discarded
+- `error_tolerance` placed on the evaluator instead of inside `condition`, where it is silently
+ ignored and the check still fails
+
+What lint cannot tell you is whether a well-formed policy matches anything. Only evaluating it
+against a document that *should* fail answers that:
+
+```bash
+tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error
+```
+
+### Usage
+
+```
+tirith lint [PATH ...] [--json] [--strict] [--quiet]
+```
+
+| Flag | What it does |
+|---|---|
+| `--json` | Print the report as a JSON document instead of text |
+| `--strict` | Treat warnings as errors when deciding the exit code |
+| `--quiet` | Print findings only, with no summary line and no skipped files |
+
+Findings are printed one per line, in a form an editor or a log scraper can parse:
+
+```
+.tirith/policies/s3.json:evaluators[0].condition: error: unknown condition type 'EqualTo'
+.tirith/policies/s3.json:eval_expression: warning: evaluator 'bucket_acl' is never referenced
+2 policies, 1 errors, 1 warnings
+```
+
+`--json` returns the same report structured, so an editor integration or another program does not
+have to parse the text:
+
+```json
+{
+ "files": [
+ {
+ "path": ".tirith/policies/s3.json",
+ "findings": [
+ {"severity": "error", "where": "evaluators[0].condition", "message": "unknown condition type 'EqualTo'"}
+ ]
+ }
+ ],
+ "skipped": [],
+ "missing": [],
+ "summary": {"policies": 2, "errors": 1, "warnings": 1, "ignored": 0},
+ "exit_status": 3
+}
+```
+
+### Exit codes
+
+| Code | Meaning |
+|---|---|
+| `0` | Every policy is clean |
+| `3` | A policy has an error-level finding, or a warning under `--strict` |
+| `1` | A path does not exist, or nothing was found to lint |
+
+`3` rather than `1` for a bad policy is deliberate, and matches the rest of Tirith: the linter
+saying no about a policy is a verdict, not a tool failure. See [exit codes](exit-codes.md).
+
+## `tirith fmt`
+
+Format rewrites a policy into one canonical layout, so that two people writing the same policy
+produce the same bytes and a diff shows a change of meaning rather than a change of key order.
+
+It reorders keys and normalises whitespace. It never changes a value, adds a key, or reorders a
+list, and the result parses back to a document equal to the one it read.
+
+### Usage
+
+```
+tirith fmt [PATH ...] [--check] [--diff]
+```
+
+| Flag | What it does |
+|---|---|
+| `--check` | Do not write. Exit `3` if any file would change |
+| `--diff` | Print a unified diff of what would change. Implies `--check` |
+
+With no flags it rewrites the files in place and prints the path of each one it changed.
+
+### The layout
+
+Whitespace is exactly `json.dumps(indent=2, ensure_ascii=False)` plus one trailing newline, which
+means a policy written by any tool using the Python standard library is already canonical without
+knowing `fmt` exists. Non-ASCII characters stay as written rather than being escaped.
+
+Keys are ordered:
+
+| Object | Order |
+|---|---|
+| top level | `$schema`, `meta`, `evaluators`, `eval_expression` |
+| `meta` | `version`, `required_provider`, `id`, `name`, `description`, `severity`, `enforcement`, `tags`, `remediation` |
+| each evaluator | `id`, `description`, `provider_args`, `condition` |
+| `provider_args` | `operation_type` first, then the provider's own keys in their original order |
+| `condition` | `type`, `value`, `error_tolerance` |
+
+A key the document has that is not in these lists keeps its original relative order after the
+listed ones, so an unrecognised key is preserved rather than dropped or sorted somewhere
+surprising.
+
+### Exit codes
+
+| Code | Meaning |
+|---|---|
+| `0` | Nothing to change, or the changes were written |
+| `3` | `--check` found files that would change |
+| `1` | A path is missing, a file is not valid JSON, or nothing was found |
+
+## Which files they read
+
+Both commands take any number of paths, and both default to the same place when given none:
+`.tirith/policies` if that directory exists, otherwise the current directory.
+
+A directory is searched recursively for `*.json`. A **policy** is a JSON object with at least one
+of the three top-level keys the engine reads, `meta`, `evaluators` or `eval_expression`; anything
+else that parses as JSON is left alone. That is what lets you point either command at a directory
+that also holds a `package.json` or a plan document without producing findings about files that
+were never policies.
+
+- A JSON file named **explicitly on the command line** that is not a policy is reported as skipped
+ by name, so a typo'd path to a plan document does not read as a clean run.
+- A JSON file met **while walking a directory** that is not a policy is counted rather than
+ listed. Input documents living beside their policies are expected there.
+- A `.json` file inside a policy directory that does not parse at all is reported as an error, not
+ ignored. An unparseable policy is a defect.
+
+These directories are never descended into: `.git`, `.terraform`, `node_modules`, `__pycache__`,
+`.venv`, `venv`. Note that `.tirith` is deliberately not among them, since that is where policies
+conventionally live and skipping dot-directories wholesale would skip the one that matters.
+
+## In a pre-commit hook
+
+This repository publishes both commands as hooks, so a commit that touches one policy lints and
+formats that one policy:
+
+```yaml title=".pre-commit-config.yaml"
+repos:
+ - repo: https://github.com/StackGuardian/tirith
+ rev: main
+ hooks:
+ - id: tirith-lint
+ - id: tirith-fmt
+```
+
+```bash
+pre-commit install
+pre-commit run --all-files
+```
+
+Both hooks match `.tirith/*.json` and `*.tirith.json` by default. Override `files` if your
+policies live somewhere else.
+
+Linting at commit time rather than evaluating is a deliberate choice. Evaluating needs a plan
+document, and producing one means running `terraform plan`, which is too slow for a commit hook
+and needs cloud credentials a hook has no business holding. See
+[CI integration](ci-integration.md) for the full argument, and for where evaluation belongs.
+
+## In CI
+
+`fmt --check` is the CI shape, next to whatever else already checks formatting:
+
+```bash
+tirith fmt --check .tirith/policies
+tirith lint .tirith/policies
+```
+
+Both exit `3` on a finding, so a pipeline that already distinguishes `3` from `1` treats a badly
+formatted or invalid policy exactly as it treats a policy violation, and a missing path as the
+tooling problem it is.
diff --git a/documentation/scripts/generate-llms-full.py b/documentation/scripts/generate-llms-full.py
index 4e61345a..7272633a 100644
--- a/documentation/scripts/generate-llms-full.py
+++ b/documentation/scripts/generate-llms-full.py
@@ -57,6 +57,7 @@
"tirith-reference/evaluators.md",
"tirith-reference/eval-expressions.md",
"tirith-usage/cli-reference.md",
+ "tirith-usage/lint-and-fmt.md",
"tirith-usage/exit-codes.md",
"tirith-usage/ci-integration.md",
"tirith-usage/interactive-interface.md",
diff --git a/documentation/sidebars.js b/documentation/sidebars.js
index 96ebba86..99242827 100644
--- a/documentation/sidebars.js
+++ b/documentation/sidebars.js
@@ -21,6 +21,7 @@ module.exports = {
label: "Using Tirith",
items: [
"tirith-usage/cli-reference",
+ "tirith-usage/lint-and-fmt",
"tirith-usage/interactive-interface",
"tirith-usage/editor-and-local",
"tirith-usage/agent-skills",
diff --git a/documentation/src/components/landing/PhaseJourney.js b/documentation/src/components/landing/PhaseJourney.js
index 0568a100..c3a85acb 100644
--- a/documentation/src/components/landing/PhaseJourney.js
+++ b/documentation/src/components/landing/PhaseJourney.js
@@ -31,7 +31,13 @@ export default function PhaseJourney() {
onClick={() => setActiveIndex(index)}>
{item.number}
- {item.group}
+
+ {item.group}
+ {/* Subtle on purpose: it qualifies the step, it does not warn about it. */}
+ {item.platform ? (
+ platform mode
+ ) : null}
+
{item.title}
{item.summary}
@@ -49,6 +55,9 @@ export default function PhaseJourney() {
Phase {phase.number} · {phase.group}
+ {phase.platform ? (
+ platform mode
+ ) : null}
{phase.title}
diff --git a/documentation/src/components/landing/PlatformSetup.js b/documentation/src/components/landing/PlatformSetup.js
index f4301874..7a675b89 100644
--- a/documentation/src/components/landing/PlatformSetup.js
+++ b/documentation/src/components/landing/PlatformSetup.js
@@ -13,6 +13,11 @@ import styles from '../../pages/index.module.css';
*
* Each target links either to a public demo repository (`url`) or to the documentation that
* covers it (`to`) -- never both, and never a link that does not exist.
+ *
+ * The delegated route is the first target rather than a strip under the panel. It was a
+ * strip briefly, repeated beneath all five snippets, which said the same thing five times
+ * and still let a reader start typing before meeting it. As the leading tab it is the
+ * panel's opening state, and skipping it costs one click.
*/
export default function PlatformSetup() {
const [activeId, setActiveId] = useState(PIPELINE_TARGETS[0].id);
diff --git a/documentation/src/data/agentBrief.js b/documentation/src/data/agentBrief.js
index 651dd3e0..8aa8108a 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## 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";
+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## 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## 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` and `tirith fmt` exist, but not in 1.2.0.** They are on `main` and arrive in\n the next release; install `@main` to use them today. The CLI dispatches four subcommands:\n `lint`, `fmt`, `ui` and `platform`. Lint checks a policy's shape without a plan document; fmt\n rewrites it into the canonical layout. Both exit `3` on a finding, with no `--fail-on-error`.\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. (`2` is not a Tirith code at all:\n argparse exits `2` on a usage error, so a caller seeing it passed a bad argument.)\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- **The argument key differs per provider, and a wrong key is ignored rather than rejected.**\n `terraform_plan` reads `terraform_resource_attribute` alongside `terraform_resource_type`;\n `kubernetes` reads `attribute_path` alongside `kubernetes_kind`; `json` reads `key_path`;\n `sg_workflow` reads `workflow_attribute`. Give an evaluator the wrong key and it reads\n nothing and the check passes. This is the highest-cost mistake in the schema.\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- **`error_tolerance` is a threshold, not a switch.** A problem of severity N fails the check\n when `N > error_tolerance` and is skipped otherwise; the default is `0`. The plan provider\n raises severity `0` for a resource being deleted, `1` for a resource type absent from the\n plan, and `2` for an attribute absent from the resource. So the default skips deletes and\n fails everything else.\n- **A policy scoped to one resource type has no green outcome on a plan that does not touch\n that type.** At the default tolerance the absent type is severity `1`, the check fails, exit\n `3`. At `error_tolerance: 1` it is skipped, so every check is skipped, `final_result` is\n `null`, exit `1`. Guard it instead: `count` returns `0` for an absent type rather than\n raising severity `1`, so an evaluator of\n `{\"operation_type\": \"count\", \"terraform_resource_type\": T}` with `Equals: 0`, combined as\n `eval_expression: \"not_applicable || the_rule\"`, passes cleanly on unrelated plans and still\n fails on real violations.\n- **The pre-commit hooks are published.** `.pre-commit-hooks.yaml` carries `tirith-lint` and\n `tirith-fmt`, both run on the policy files a commit touches. Pin `rev` to a branch until lint\n and fmt are in a release.\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\nThree skills: tirith-policies (eleven markdown files and a worked example), tirith-standards\n(generating a policy set from an existing Terraform or OpenTofu repository, including the guard\nabove) and tirith-migrate (Sentinel translation). Add --cursor for the Cursor rule, --global to\ninstall 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- [Lint and format](https://stackguardian.github.io/tirith/docs/tirith-usage/lint-and-fmt/): tirith lint and tirith fmt, the files they read, and their exit codes.\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.\n";
diff --git a/documentation/src/data/demoPhases.js b/documentation/src/data/demoPhases.js
index 8f960070..55f1197b 100644
--- a/documentation/src/data/demoPhases.js
+++ b/documentation/src/data/demoPhases.js
@@ -46,6 +46,10 @@ export const DEMO_PHASES = [
{
number: '02',
group: 'Scale · organization',
+ // Neither this phase nor 'Close the loop' works on the open-source path alone: one needs
+ // organization credentials, the other publishes state to the platform. Flagged so the
+ // walkthrough cannot read as five things every reader can do today.
+ platform: true,
title: 'Share policies across repositories',
summary: 'Add organization credentials and select centrally managed policies.',
body:
@@ -145,6 +149,7 @@ export const DEMO_PHASES = [
{
number: '05',
group: 'Close the loop',
+ platform: true,
title: 'Publish state after apply',
summary: 'Send the current state without creating another policy verdict.',
body:
@@ -186,6 +191,16 @@ export const DEMO_PHASES = [
* What differs between them is only how Tirith is invoked; the policies, the verdicts and
* the exit codes are identical, because it is the same CLI underneath in every case.
*/
+/**
+ * The one-line skill-pack installer, served from this site's own static/ directory.
+ *
+ * Lives here because three surfaces reference it -- the announcement row, the hero's
+ * install plate and the first pipeline target below, which interpolates it, so this has to
+ * be declared before PIPELINE_TARGETS rather than beside the other exports -- and a command that appears in three places with
+ * two spellings is a command that will eventually be wrong in one of them.
+ */
+export const SKILL_INSTALL = 'curl -fsSL https://stackguardian.github.io/tirith/skill.sh | sh';
+
/**
* Everywhere the gate can be wired up, for the switcher in section 03.
*
@@ -197,6 +212,32 @@ export const DEMO_PHASES = [
* the other, never both.
*/
export const PIPELINE_TARGETS = [
+ /*
+ * First, and therefore the one selected on arrival.
+ *
+ * It is not a sixth platform -- it is the other way of producing any of the five below,
+ * which is exactly why it leads rather than sits at the end as an afterthought. A reader
+ * who already knows which file they want clicks past it in one move; a reader who does
+ * not has just been shown that they do not have to write it themselves.
+ *
+ * The `file` slot names where the pack lands rather than a config path, because that is
+ * the artefact this tab produces on disk.
+ */
+ {
+ id: 'agent',
+ name: 'Coding agent',
+ file: '.claude/skills/tirith-policies/',
+ note:
+ 'Covers every target in the tabs beside this one, so the agent writes for whichever ' +
+ 'it finds. It carries the install, the plan export and the exit-code contract.',
+ to: '/skills/',
+ linkLabel: 'Claude, Cursor and Codex setup',
+ code: `# 1 · give the agent the vocabulary, once per repository
+${SKILL_INSTALL}
+
+# 2 · then ask for the gate, in your own words
+"Add a Tirith policy gate to our pipeline"`,
+ },
{
id: 'github',
name: 'GitHub Actions',
@@ -237,7 +278,7 @@ tirith:
needs: [terraform-plan]
script:
- pip install "git+https://github.com/StackGuardian/tirith.git@1.2.0"
- # - tirith lint .tirith/policies # in dev, not in 1.2.0
+ # - tirith lint .tirith/policies # not in 1.2.0 yet, on main
- tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error`,
},
{
@@ -258,7 +299,7 @@ tirith:
name: Policy gate
script:
- pip install "git+https://github.com/StackGuardian/tirith.git@1.2.0"
- # - tirith lint .tirith/policies # in dev, not in 1.2.0
+ # - tirith lint .tirith/policies # not in 1.2.0 yet, on main
- tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error`,
},
{
@@ -271,7 +312,7 @@ tirith:
to: '/docs/tirith-usage/ci-integration/',
linkLabel: 'Jenkins and other runners',
code: `pip install "git+https://github.com/StackGuardian/tirith.git@1.2.0"
-# tirith lint .tirith/policies # in dev, not in 1.2.0
+# tirith lint .tirith/policies # not in 1.2.0 yet, on main
tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error`,
},
{
@@ -320,7 +361,7 @@ steps:
needs: [terraform-plan]
script:
- pip install "git+https://github.com/StackGuardian/tirith.git@1.2.0"
- # - tirith lint .tirith/policies # in dev, not in 1.2.0
+ # - tirith lint .tirith/policies # not in 1.2.0 yet, on main
- tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error`,
},
{
@@ -333,7 +374,7 @@ steps:
name: Policy gate
script:
- pip install "git+https://github.com/StackGuardian/tirith.git@1.2.0"
- # - tirith lint .tirith/policies # in dev, not in 1.2.0
+ # - tirith lint .tirith/policies # not in 1.2.0 yet, on main
- tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error`,
},
];
@@ -346,18 +387,17 @@ export const INTEGRATIONS = [
{
glyph: '⎇',
title: 'pre-commit',
- // Needs .pre-commit-hooks.yaml, which is not in this repository, and `tirith lint`,
- // which is not in the released CLI.
- inDev: true,
- body: 'A tirith-lint hook that will run when a policy file changes. Offline, no plan needed, and it catches the mistakes that read as real violations.',
- to: '/docs/tirith-usage/editor-and-local/',
+ // .pre-commit-hooks.yaml publishes both ids, and `tirith lint` / `tirith fmt` are on
+ // main. Not in 1.2.0, so the documented `rev` is a branch until the next release.
+ body: 'tirith-lint and tirith-fmt hooks that run on the policy files a commit touches. Offline, no plan needed, and they catch the mistakes that read as real violations.',
+ to: '/docs/tirith-usage/lint-and-fmt/',
},
{
glyph: '{}',
title: 'Your editor',
- // Needs .vscode/tasks.json, which is not in this repository, and the same lint command.
- inDev: true,
- body: 'VS Code tasks that will lint and evaluate in one keystroke — the loop to use while an agent is drafting the policy for you.',
+ // The tasks are given inline on the docs page; .vscode/tasks.json is not shipped from
+ // this repository, so the page no longer links to it as though it were.
+ body: 'VS Code tasks that lint and evaluate in one keystroke, the loop to use while an agent is drafting the policy for you.',
to: '/docs/tirith-usage/editor-and-local/',
},
];
diff --git a/documentation/src/data/lessons.js b/documentation/src/data/lessons.js
index ea3b4f82..5c3793e6 100644
--- a/documentation/src/data/lessons.js
+++ b/documentation/src/data/lessons.js
@@ -331,6 +331,19 @@ export const PLAN_DOC = `{
"format_version": "1.2",
"terraform_version": "1.9.5",
"resource_changes": [
+ {
+ "address": "aws_db_instance.orders",
+ "type": "aws_db_instance",
+ "name": "orders",
+ "change": {
+ "actions": ["update"],
+ "after": {
+ "identifier": "orders",
+ "storage_encrypted": true,
+ "tags": {"Owner": "data"}
+ }
+ }
+ },
{
"address": "aws_s3_bucket.assets",
"type": "aws_s3_bucket",
@@ -353,7 +366,7 @@ export const PLAN_DOC = `{
"after": {
"bucket": "acme-logs",
"acl": "public-read",
- "tags": {}
+ "tags": {"Owner": "platform"}
}
}
},
@@ -365,7 +378,11 @@ export const PLAN_DOC = `{
"actions": ["update"],
"after": {
"instance_type": "t3.large",
- "tags": {"Owner": "ci"}
+ "tags": {"Owner": "ci"},
+ "ebs_block_device": [
+ {"device_name": "/dev/sdf", "encrypted": true},
+ {"device_name": "/dev/sdg", "encrypted": false}
+ ]
}
}
}
@@ -374,23 +391,56 @@ export const PLAN_DOC = `{
export const TF_LESSONS = [
{
- id: 'tf-attribute',
+ id: 'tf-shell',
n: '01',
- title: 'A plan is not a document',
- teaches: 'terraform_resource_type · terraform_resource_attribute',
+ title: 'A policy is a document',
+ teaches: 'meta · evaluators · eval_expression',
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.',
+ 'Every Tirith policy has the same three parts. `meta` names the provider that will read ' +
+ 'your input. `evaluators` is the list of checks. `eval_expression` says how their ' +
+ 'results combine into one verdict. Nothing here is a program: it is a description of ' +
+ 'what to look for in a plan you are about to apply.',
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.',
+ 'A plan is not a tree you walk, so there is no path here. It is a list of resource ' +
+ 'changes, and you address one by naming a **resource type** and an **attribute on it**. ' +
+ 'One database matches, so there is one result.',
+ tryIt: 'Change the value to `false`. The verdict, the message and the exit code all move together.',
+ policy: `{
+ "meta": {
+ "version": "v1",
+ "required_provider": "stackguardian/terraform_plan"
+ },
+ "evaluators": [
+ {
+ "id": "db_encrypted",
+ "provider_args": {
+ "operation_type": "attribute",
+ "terraform_resource_type": "aws_db_instance",
+ "terraform_resource_attribute": "storage_encrypted"
+ },
+ "condition": {
+ "type": "Equals",
+ "value": true
+ }
+ }
+ ],
+ "eval_expression": "db_encrypted"
+}`,
+ },
+ {
+ id: 'tf-many',
+ n: '02',
+ title: 'One check, every matching resource',
+ teaches: 'many results · every one must pass',
+ body:
+ 'Name a type that matches more than one resource and the check runs against each of ' +
+ 'them separately. The evaluator passes only if **every** result passes, so you write ' +
+ 'the rule once and it covers the bucket somebody adds next month without you editing ' +
+ 'anything.',
+ aside:
+ 'Two buckets, two results, and the failing one names the value that failed rather than ' +
+ 'just the rule. That is the difference between a report you can act on and a red cross.',
+ tryIt: 'Change `acl` on `aws_s3_bucket.logs` to `private` and the whole plan passes.',
policy: `{
"meta": {
"version": "v1",
@@ -411,19 +461,107 @@ export const TF_LESSONS = [
}
],
"eval_expression": "buckets_private"
+}`,
+ },
+ {
+ id: 'tf-every',
+ n: '03',
+ title: 'Every resource in the plan',
+ teaches: '"*" · exclude_resource_types',
+ body:
+ '`"*"` as the resource type means every resource the plan touches, whatever it is. This ' +
+ 'is how you write the rules that are actually organisational policy rather than ' +
+ 'service-specific: everything must be owned, everything must be tagged, nothing may be ' +
+ 'created outside a region. `exclude_resource_types` carves out the ones that genuinely ' +
+ 'cannot comply.',
+ aside:
+ 'Four resources, four results, in plan order. Note what the printed report does *not* ' +
+ 'say: results are numbered, not named, so `4. FAILED` means the fourth resource rather ' +
+ 'than `aws_instance.runner`. The address is carried in the `--json` output under `meta` ' +
+ 'and shown by `tirith ui`; putting it in the printed message is on the roadmap and has ' +
+ 'not shipped.',
+ tryIt: 'Change one resource’s `Owner` tag to `""`, then count down the list to find which resource line 4 is.',
+ policy: `{
+ "meta": {
+ "version": "v1",
+ "required_provider": "stackguardian/terraform_plan"
+ },
+ "evaluators": [
+ {
+ "id": "everything_owned",
+ "provider_args": {
+ "operation_type": "attribute",
+ "terraform_resource_type": "*",
+ "terraform_resource_attribute": "tags.Owner",
+ "exclude_resource_types": ["aws_iam_policy_document"]
+ },
+ "condition": {
+ "type": "IsNotEmpty"
+ }
+ }
+ ],
+ "eval_expression": "everything_owned"
+}`,
+ },
+ {
+ id: 'tf-combine',
+ n: '04',
+ title: 'Combining checks',
+ teaches: '&& · || · grouping',
+ body:
+ 'Each evaluator has an `id`, and `eval_expression` combines those ids with `&&`, `||`, ' +
+ '`!` and parentheses. That is the whole grammar. The evaluators do not know about each ' +
+ 'other; the expression is the only place their results meet.',
+ aside:
+ 'Both checks run whatever the expression says, so you always see every result. The ' +
+ 'expression decides the single verdict at the end, and only that verdict reaches the ' +
+ 'exit code.',
+ tryIt: 'Swap `&&` for `||`. One passing check is now enough to carry the whole policy, which is usually not what you want.',
+ 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"
+ }
+ },
+ {
+ "id": "db_encrypted",
+ "provider_args": {
+ "operation_type": "attribute",
+ "terraform_resource_type": "aws_db_instance",
+ "terraform_resource_attribute": "storage_encrypted"
+ },
+ "condition": {
+ "type": "Equals",
+ "value": true
+ }
+ }
+ ],
+ "eval_expression": "buckets_private && db_encrypted"
}`,
},
{
id: 'tf-action',
- n: '02',
+ n: '05',
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.',
+ 'This is the operation with no equivalent in a document, 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 ' +
@@ -449,11 +587,88 @@ export const TF_LESSONS = [
}
],
"eval_expression": "nothing_destroyed"
+}`,
+ },
+ {
+ id: 'tf-nested',
+ n: '06',
+ title: 'Reaching inside a resource',
+ teaches: 'nested attributes · one result per element',
+ body:
+ 'Real resources are not flat. A block that repeats, like `ebs_block_device`, arrives as ' +
+ 'a list, and `.*.` walks into it. Each element becomes its **own result**, so one ' +
+ 'attached volume that is unencrypted fails the check even though its neighbour on the ' +
+ 'same instance is fine.',
+ aside:
+ 'Remember this shape. The Kubernetes provider spells its wildcard the same way and ' +
+ 'means something different by it, which is the subject of the last lesson in that ' +
+ 'track and the easiest way on this whole site to write a policy that gates nothing.',
+ tryIt: 'Set both `encrypted` values to `true`. Then delete the `.*.` and see the attribute stop resolving.',
+ policy: `{
+ "meta": {
+ "version": "v1",
+ "required_provider": "stackguardian/terraform_plan"
+ },
+ "evaluators": [
+ {
+ "id": "volumes_encrypted",
+ "provider_args": {
+ "operation_type": "attribute",
+ "terraform_resource_type": "aws_instance",
+ "terraform_resource_attribute": "ebs_block_device.*.encrypted"
+ },
+ "condition": {
+ "type": "Equals",
+ "value": true
+ }
+ }
+ ],
+ "eval_expression": "volumes_encrypted"
+}`,
+ },
+ {
+ id: 'tf-tolerance',
+ n: '07',
+ title: 'When the attribute is not there',
+ teaches: 'error_tolerance · a skip is not a pass',
+ body:
+ 'Nothing in this plan sets `server_side_encryption`, so the provider cannot produce a ' +
+ 'value to judge. That is not a violation, and it is not compliance either. Tirith ' +
+ 'reports it as an **error with a severity**, and `error_tolerance` decides whether that ' +
+ 'error fails the check or skips it.',
+ aside:
+ 'Severity 1 means the resource type is not in the plan. Severity 2 means the resource ' +
+ 'is there but the attribute is not. `severity > tolerance` fails, anything else skips. ' +
+ 'With every check skipped the verdict is neither true nor false, and the exit code is ' +
+ '`1`, not `0`: a policy that evaluated nothing must never look like a policy that ' +
+ 'passed.',
+ tryIt: 'Drop `error_tolerance` to `1`. The skip becomes a failure and the exit code changes from 1 to 3.',
+ policy: `{
+ "meta": {
+ "version": "v1",
+ "required_provider": "stackguardian/terraform_plan"
+ },
+ "evaluators": [
+ {
+ "id": "bucket_encryption",
+ "provider_args": {
+ "operation_type": "attribute",
+ "terraform_resource_type": "aws_s3_bucket",
+ "terraform_resource_attribute": "server_side_encryption"
+ },
+ "condition": {
+ "type": "Equals",
+ "value": "AES256",
+ "error_tolerance": 2
+ }
+ }
+ ],
+ "eval_expression": "bucket_encryption"
}`,
},
{
id: 'tf-count',
- n: '03',
+ n: '08',
title: 'Zero is an answer',
teaches: 'operation_type: count · and the error that does not happen',
body:
@@ -464,8 +679,8 @@ export const TF_LESSONS = [
'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.',
+ '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": {
@@ -486,6 +701,76 @@ export const TF_LESSONS = [
}
],
"eval_expression": "bucket_budget"
+}`,
+ },
+ {
+ id: 'tf-together',
+ n: '09',
+ title: 'The whole thing',
+ teaches: 'a policy you would actually commit',
+ body:
+ 'Four rules over one plan: everything is owned, buckets are private, attached volumes ' +
+ 'are encrypted, and nothing is destroyed. This is the size of a real starting policy, ' +
+ 'and it is the file you would put in `.tirith/policies` and point a pipeline at.',
+ aside:
+ 'The report tells you which rule refused and which value refused it, which is what ' +
+ 'makes a red build actionable rather than a thing to re-run. Under `--fail-on-error` ' +
+ 'this exits `3`, and the pipeline stops before `apply`.',
+ tryIt: 'Fix the plan until it passes: `logs` to private, and the second volume encrypted. The exit code goes to 0.',
+ policy: `{
+ "meta": {
+ "version": "v1",
+ "required_provider": "stackguardian/terraform_plan"
+ },
+ "evaluators": [
+ {
+ "id": "everything_owned",
+ "provider_args": {
+ "operation_type": "attribute",
+ "terraform_resource_type": "*",
+ "terraform_resource_attribute": "tags.Owner"
+ },
+ "condition": {
+ "type": "IsNotEmpty"
+ }
+ },
+ {
+ "id": "buckets_private",
+ "provider_args": {
+ "operation_type": "attribute",
+ "terraform_resource_type": "aws_s3_bucket",
+ "terraform_resource_attribute": "acl"
+ },
+ "condition": {
+ "type": "Equals",
+ "value": "private"
+ }
+ },
+ {
+ "id": "volumes_encrypted",
+ "provider_args": {
+ "operation_type": "attribute",
+ "terraform_resource_type": "aws_instance",
+ "terraform_resource_attribute": "ebs_block_device.*.encrypted"
+ },
+ "condition": {
+ "type": "Equals",
+ "value": true
+ }
+ },
+ {
+ "id": "nothing_destroyed",
+ "provider_args": {
+ "operation_type": "action",
+ "terraform_resource_type": "*"
+ },
+ "condition": {
+ "type": "NotEquals",
+ "value": "delete"
+ }
+ }
+ ],
+ "eval_expression": "everything_owned && buckets_private && volumes_encrypted && nothing_destroyed"
}`,
},
];
@@ -501,13 +786,19 @@ export const K8S_DOC = `[
{
"apiVersion": "apps/v1",
"kind": "Deployment",
- "metadata": {"name": "api"},
+ "metadata": {"name": "api", "namespace": "production"},
"spec": {
"replicas": 3,
"template": {
"spec": {
"containers": [
- {"name": "api", "image": "ghcr.io/acme/api:1.4.2"}
+ {
+ "name": "api",
+ "image": "ghcr.io/acme/api:1.4.2",
+ "resources": {"limits": {"cpu": "500m", "memory": "512Mi"}},
+ "securityContext": {"runAsNonRoot": true, "allowPrivilegeEscalation": false},
+ "readinessProbe": {"httpGet": {"path": "/healthz", "port": 8080}}
+ }
]
}
}
@@ -516,13 +807,17 @@ export const K8S_DOC = `[
{
"apiVersion": "apps/v1",
"kind": "Deployment",
- "metadata": {"name": "worker"},
+ "metadata": {"name": "worker", "namespace": "production"},
"spec": {
"replicas": 1,
"template": {
"spec": {
"containers": [
- {"name": "worker", "image": "ghcr.io/acme/worker:latest"}
+ {
+ "name": "worker",
+ "image": "docker.io/library/redis:latest",
+ "securityContext": {"runAsNonRoot": false}
+ }
]
}
}
@@ -531,8 +826,8 @@ export const K8S_DOC = `[
{
"apiVersion": "v1",
"kind": "Service",
- "metadata": {"name": "api"},
- "spec": {"type": "LoadBalancer"}
+ "metadata": {"name": "api", "namespace": "production"},
+ "spec": {"type": "ClusterIP", "ports": [{"port": 80, "targetPort": 8080}]}
}
]`;
@@ -543,15 +838,18 @@ export const K8S_LESSONS = [
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.',
+ '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.',
+ 'Two Deployments match, so there are two results. `api` is fine and `worker` is not, ' +
+ 'which is the pattern for this whole track: one bad Deployment, and each lesson catches ' +
+ 'a different thing wrong with it.',
+ tryIt:
+ 'Give `worker` 2 replicas and the check passes. Then change the kind to `Service`: it has no ' +
+ '`spec.replicas`, so the value is null and the failure you get is about comparing types, not ' +
+ 'about a missing path.',
policy: `{
"meta": {
"version": "v1",
@@ -575,21 +873,109 @@ export const K8S_LESSONS = [
}`,
},
{
- id: 'k8s-wildcard',
+ id: 'k8s-two-kinds',
n: '02',
+ title: 'One policy, two kinds',
+ teaches: 'several evaluators · && · || · grouping',
+ body:
+ 'One evaluator reads one kind, so a policy that spans Deployments and Services is two ' +
+ 'evaluators and an expression. Each has an `id`, and `eval_expression` combines those ' +
+ 'ids with `&&`, `||`, `!` and parentheses. That is the whole grammar; the evaluators ' +
+ 'never see each other.',
+ aside:
+ 'Both checks run whatever the expression says, so you always see every result: the ' +
+ 'Service passes, a Deployment does not, and the expression turns those into one verdict ' +
+ 'at the end. Only that verdict reaches the exit code.',
+ tryIt: 'Swap `&&` for `||`. The passing Service now carries the whole policy, which is almost never what you meant.',
+ 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
+ }
+ },
+ {
+ "id": "not_exposed",
+ "provider_args": {
+ "operation_type": "attribute",
+ "kubernetes_kind": "Service",
+ "attribute_path": "spec.type"
+ },
+ "condition": {
+ "type": "NotEquals",
+ "value": "LoadBalancer"
+ }
+ }
+ ],
+ "eval_expression": "not_a_single_point && not_exposed"
+}`,
+ },
+ {
+ id: 'k8s-registry',
+ n: '03',
+ title: 'Where the image came from',
+ teaches: 'indexing into containers · RegexMatch',
+ body:
+ 'Containers are a list, so reaching one means indexing it: `containers.0` is the first ' +
+ 'container in the pod template. `RegexMatch` then does what an allowlist needs, which is ' +
+ 'to say where an image may come from rather than enumerating every image you have ever ' +
+ 'approved.',
+ aside:
+ 'An unpinned public image is the supply-chain problem stated plainly: `worker` pulls ' +
+ 'from Docker Hub, so anything that lands under that name lands in your cluster. The ' +
+ 'regex is anchored with `^` on purpose; without it, `ghcr.io/acme/` would match anywhere ' +
+ 'in the string and `evil.example.com/ghcr.io/acme/x` would pass.',
+ tryIt: 'Point `worker` at `ghcr.io/acme/worker:0.9.0` and the check passes. Then drop the `^` and see what else it lets through.',
+ policy: `{
+ "meta": {
+ "version": "v1",
+ "required_provider": "stackguardian/kubernetes"
+ },
+ "evaluators": [
+ {
+ "id": "approved_registry",
+ "provider_args": {
+ "operation_type": "attribute",
+ "kubernetes_kind": "Deployment",
+ "attribute_path": "spec.template.spec.containers.0.image"
+ },
+ "condition": {
+ "type": "RegexMatch",
+ "value": "^ghcr\\\\.io/acme/"
+ }
+ }
+ ],
+ "eval_expression": "approved_registry"
+}`,
+ },
+ {
+ id: 'k8s-wildcard',
+ n: '04',
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.',
+ '`["docker.io/library/redis: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.',
+ '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 why "the check is green" and "the check is working" ' +
+ 'are different claims, and why the last lesson here evaluates a policy against a ' +
+ 'manifest that should fail it.',
tryIt: 'Change `containers.*.image` to `containers.0.image`. The verdict flips to failed and names the image.',
policy: `{
"meta": {
@@ -611,6 +997,215 @@ export const K8S_LESSONS = [
}
],
"eval_expression": "no_latest_tag"
+}`,
+ },
+ {
+ id: 'k8s-limits',
+ n: '05',
+ title: 'A missing path is not an error here',
+ teaches: 'resource limits · null instead of a severity',
+ body:
+ 'A container with no memory limit can take the node down and everything scheduled on it ' +
+ 'with it, so this is the check most clusters want first. It also shows the sharpest ' +
+ 'difference between this provider and the Terraform one: `worker` has no `resources` at ' +
+ 'all, and instead of an error with a severity, the provider returns **null** and the ' +
+ 'condition judges that.',
+ aside:
+ 'That distinction decides what `error_tolerance` can do for you. In a plan, a missing ' +
+ 'attribute is severity 2 and can be tolerated into a skip. Here it is an ordinary value ' +
+ 'that fails an `IsNotEmpty`, and no tolerance setting will turn it into a skip. Which is ' +
+ 'the safer default: absence is a failure unless you say otherwise.',
+ tryIt: 'Add `"resources": {"limits": {"memory": "256Mi"}}` to the worker container and the policy passes.',
+ policy: `{
+ "meta": {
+ "version": "v1",
+ "required_provider": "stackguardian/kubernetes"
+ },
+ "evaluators": [
+ {
+ "id": "memory_limited",
+ "provider_args": {
+ "operation_type": "attribute",
+ "kubernetes_kind": "Deployment",
+ "attribute_path": "spec.template.spec.containers.0.resources.limits.memory"
+ },
+ "condition": {
+ "type": "IsNotEmpty"
+ }
+ }
+ ],
+ "eval_expression": "memory_limited"
+}`,
+ },
+ {
+ id: 'k8s-security',
+ n: '06',
+ title: 'Running as root, by default',
+ teaches: 'securityContext · two rules over one container',
+ body:
+ 'Kubernetes defaults are permissive: unless a manifest says otherwise, a container runs ' +
+ 'as whatever user its image declares, which is very often root, and may escalate ' +
+ 'privileges. These are the two settings that a cluster policy almost always pins, and ' +
+ 'they are the rules that survive contact with an image you did not build.',
+ aside:
+ '`worker` sets `runAsNonRoot: false` explicitly, so it fails. A manifest that omits ' +
+ '`securityContext` entirely returns null and fails the same way, which is the behaviour ' +
+ 'you want: a container is not non-root because nobody mentioned it.',
+ tryIt: 'Set `runAsNonRoot` to `true` on `worker`. It still fails, because that container has no `allowPrivilegeEscalation` at all.',
+ policy: `{
+ "meta": {
+ "version": "v1",
+ "required_provider": "stackguardian/kubernetes"
+ },
+ "evaluators": [
+ {
+ "id": "non_root",
+ "provider_args": {
+ "operation_type": "attribute",
+ "kubernetes_kind": "Deployment",
+ "attribute_path": "spec.template.spec.containers.0.securityContext.runAsNonRoot"
+ },
+ "condition": {
+ "type": "Equals",
+ "value": true
+ }
+ },
+ {
+ "id": "no_escalation",
+ "provider_args": {
+ "operation_type": "attribute",
+ "kubernetes_kind": "Deployment",
+ "attribute_path": "spec.template.spec.containers.0.securityContext.allowPrivilegeEscalation"
+ },
+ "condition": {
+ "type": "Equals",
+ "value": false
+ }
+ }
+ ],
+ "eval_expression": "non_root && no_escalation"
+}`,
+ },
+ {
+ id: 'k8s-tolerance',
+ n: '07',
+ title: 'When the kind is not there',
+ teaches: 'error_tolerance · a skip is not a pass',
+ body:
+ 'A missing *path* returns null here, but a missing **kind** is different: the provider ' +
+ 'cannot look at anything at all, so it reports an error with severity 1. This document ' +
+ 'has no `Ingress`, and that is the situation `error_tolerance` exists for. One policy ' +
+ 'set runs across many repositories, and not every repository has every kind.',
+ aside:
+ '`severity > tolerance` fails, anything else skips. At tolerance 1 the check is skipped, ' +
+ 'and because it is the only check, the verdict is neither true nor false and the exit ' +
+ 'code is `1` rather than `0`. A policy that evaluated nothing must never look like a ' +
+ 'policy that passed.',
+ tryIt: 'Drop `error_tolerance` to `0`. The skip becomes a failure and the exit code changes from 1 to 3.',
+ policy: `{
+ "meta": {
+ "version": "v1",
+ "required_provider": "stackguardian/kubernetes"
+ },
+ "evaluators": [
+ {
+ "id": "ingress_is_tls",
+ "provider_args": {
+ "operation_type": "attribute",
+ "kubernetes_kind": "Ingress",
+ "attribute_path": "spec.tls"
+ },
+ "condition": {
+ "type": "IsNotEmpty",
+ "error_tolerance": 1
+ }
+ }
+ ],
+ "eval_expression": "ingress_is_tls"
+}`,
+ },
+ {
+ id: 'k8s-together',
+ n: '08',
+ title: 'The whole thing',
+ teaches: 'a policy you would actually commit',
+ body:
+ 'Five rules over one directory of manifests: every Deployment is replicated, pulls from ' +
+ 'an approved registry, pins a memory limit, runs as non-root, and no Service is exposed ' +
+ 'to the internet. This is the size of a real starting policy, and it is the file you ' +
+ 'would put in `.tirith/policies` and point at `kubectl kustomize` output.',
+ aside:
+ 'The report names the rule that refused and the value that refused it, so a red build ' +
+ 'tells you which manifest to open. Under `--fail-on-error` this exits `3`, before ' +
+ '`kubectl apply` ever runs.',
+ tryIt: 'Fix `worker` until it passes: 2 replicas, a ghcr.io image, a memory limit, and runAsNonRoot true.',
+ 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
+ }
+ },
+ {
+ "id": "approved_registry",
+ "provider_args": {
+ "operation_type": "attribute",
+ "kubernetes_kind": "Deployment",
+ "attribute_path": "spec.template.spec.containers.0.image"
+ },
+ "condition": {
+ "type": "RegexMatch",
+ "value": "^ghcr\\\\.io/acme/"
+ }
+ },
+ {
+ "id": "memory_limited",
+ "provider_args": {
+ "operation_type": "attribute",
+ "kubernetes_kind": "Deployment",
+ "attribute_path": "spec.template.spec.containers.0.resources.limits.memory"
+ },
+ "condition": {
+ "type": "IsNotEmpty"
+ }
+ },
+ {
+ "id": "non_root",
+ "provider_args": {
+ "operation_type": "attribute",
+ "kubernetes_kind": "Deployment",
+ "attribute_path": "spec.template.spec.containers.0.securityContext.runAsNonRoot"
+ },
+ "condition": {
+ "type": "Equals",
+ "value": true
+ }
+ },
+ {
+ "id": "not_exposed",
+ "provider_args": {
+ "operation_type": "attribute",
+ "kubernetes_kind": "Service",
+ "attribute_path": "spec.type"
+ },
+ "condition": {
+ "type": "NotEquals",
+ "value": "LoadBalancer"
+ }
+ }
+ ],
+ "eval_expression": "not_a_single_point && approved_registry && memory_limited && non_root && not_exposed"
}`,
},
];
@@ -623,43 +1218,29 @@ export const K8S_LESSONS = [
* per track, which is the section grammar the rest of the site uses.
*/
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. 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.',
+ 'The provider Tirith exists for, and the one to learn on. Nine lessons that build one ' +
+ 'policy a rule at a time, against a plan of the shape your pipeline already produces ' +
+ 'with `terraform show -json`.',
+ /*
+ * 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. The syntax you learn on a plan is the same syntax everywhere else.',
input: PLAN_DOC,
lessons: TF_LESSONS,
- playground: TF_LESSONS[0].policy,
+ /*
+ * 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: TF_LESSONS[TF_LESSONS.length - 1].policy,
},
{
id: 'kubernetes',
@@ -667,13 +1248,28 @@ export const TRACKS = [
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 in the Terraform track.',
- forYou: 'Two lessons, the second of which is the most useful mistake on this page.',
+ 'A list of manifests rather than one document. Eight lessons over two Deployments and a ' +
+ 'Service, one of which is a mess: replicas, registries, limits, `securityContext`, and ' +
+ 'the wildcard that behaves the opposite way to the one in the Terraform track.',
+ forYou: 'Eight lessons. Lesson 04 is the most useful mistake on this site.',
input: K8S_DOC,
lessons: K8S_LESSONS,
playground: K8S_LESSONS[0].policy,
},
+ {
+ id: 'json',
+ provider: 'stackguardian/json',
+ tab: 'JSON or YAML',
+ title: 'Any JSON or YAML document',
+ lede:
+ 'The provider that reads anything with keys and values, addressed by key path. Useful ' +
+ 'for the documents nobody wrote a provider for: a lockfile, an SBOM, a config file, ' +
+ 'the response from one of your own APIs.',
+ forYou: 'Six lessons. Come here when the thing you need to gate is not IaC at all.',
+ input: INPUT_DOC,
+ lessons: LESSONS,
+ playground: LESSONS[LESSONS.length - 1].policy,
+ },
];
/*
diff --git a/documentation/src/data/roadmap.js b/documentation/src/data/roadmap.js
index 44aae740..defd52e7 100644
--- a/documentation/src/data/roadmap.js
+++ b/documentation/src/data/roadmap.js
@@ -8,8 +8,7 @@
*
* Status is two values and they mean different things.
*
- * inDev there is code. `tirith lint` runs today outside the released package; the
- * clearer result messages are an open pull request.
+ * inDev there is code, on `main` or in an open pull request, but not in a release.
* planned specified, sized, ordered, and not started. Most of this list.
*
* Nothing here is shipped. Anything shipped belongs in the documentation instead, and the
@@ -203,9 +202,9 @@ export const RELEASES = [
title: 'Installed the way you install anything else',
status: 'planned',
body:
- 'Homebrew, a container image, a dev container feature, a pre-commit hook, and the ' +
- 'Action listed on the Marketplace. Today installation is a git URL, which is the ' +
- 'single most friction-heavy thing about starting.',
+ 'Homebrew, a container image, a dev container feature, and the Action listed on ' +
+ 'the Marketplace. Today installation is a git URL, which is the single most ' +
+ 'friction-heavy thing about starting. The pre-commit hooks are already published.',
},
],
},
diff --git a/documentation/src/data/tirithLite.js b/documentation/src/data/tirithLite.js
index d24290b9..48cc387c 100644
--- a/documentation/src/data/tirithLite.js
+++ b/documentation/src/data/tirithLite.js
@@ -34,10 +34,11 @@
* punctuation.
* - `Equals` does not sort nested collections the way the Python does.
* - Regexes are JavaScript regexes, not Python's `re`.
- * - 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.
+ * - (Resolved.) This file used to differ from core.py on an evaluator whose
+ * results mix failures and skips: the engine let a trailing skip overwrite an
+ * earlier failure. The fix for issue #293 has since merged, core.py now
+ * decides once at the end from two flags, and the two agree. Left recorded
+ * because the divergence was deliberate while it lasted.
*
* The authoritative evaluator is always the installed package.
* ─────────────────────────────────────────────────────────────────────────────
@@ -606,16 +607,13 @@ export function evaluatePolicy(policyText, inputText) {
});
/*
- * Three-valued, and deliberately not a transcription of the engine.
+ * Three-valued: any failure decides, a pass needs at least one real
+ * evaluation, and only an evaluator with nothing but skips is skipped.
*
- * 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.
+ * Written to the intended rule at a time when core.py disagreed, letting a
+ * trailing skip overwrite an earlier failure. The fix for issue #293 has
+ * since merged and the engine now decides the same way, from two flags at
+ * the end of its loop rather than inside the branch.
*/
let passed;
if (result.some((r) => r.passed === false)) passed = false;
diff --git a/documentation/src/pages/at-scale.js b/documentation/src/pages/at-scale.js
index fbfdb664..cd8eedcb 100644
--- a/documentation/src/pages/at-scale.js
+++ b/documentation/src/pages/at-scale.js
@@ -208,6 +208,76 @@ const loop = {
},
};
+/*
+ * The estate-shaped version of the argument the landing page makes in section 02, and the
+ * one place on this site where the two products are load-bearing for each other.
+ *
+ * Everything asserted here ships. The MCP server is live and publicly documented at
+ * docs.stackguardian.io/docs/mcp-server/, with read access across workflows, stacks,
+ * templates, connectors and policies plus the cloud posture dataset. The skill pack is in
+ * .claude/skills/tirith-policies/. What is NOT claimed is a button: no part of the platform
+ * generates a policy set for you here. Two shipped pieces compose, an agent does the
+ * composing, and saying otherwise would be selling a feature nobody has built.
+ *
+ * "Policy set", never "pack". `--pack` and running many policies in one invocation are an
+ * open pull request, not a release, and the caveat below says so rather than letting the
+ * word imply it.
+ *
+ * No em dashes. See documentation/scripts/find-dashes.py.
+ */
+const estate = {
+ num: '05',
+ title: 'A catalogue cannot know your estate',
+ lede:
+ 'A scanner catalogue is built for the mistakes everyone makes, and it is good at ' +
+ 'them. At fifty repositories the rules that actually stop your incidents are the ones ' +
+ 'only you can state: which module sources are allowed, which accounts a pipeline may ' +
+ 'touch, the tag your finance team reconciles against. No catalogue ships those, ' +
+ 'because no catalogue has read your estate. Two things you already have can write ' +
+ 'them between them.',
+ steps: [
+ {
+ n: '1',
+ k: 'The MCP server reads what you actually run',
+ v:
+ 'Read access across workflows, stacks, templates, connectors and policies, and ' +
+ 'the cloud posture beside it: which resources are managed, which have drifted, ' +
+ 'and which are failing which control. Your agent asks in plain language and gets ' +
+ 'your estate back, not a generic example of one.',
+ },
+ {
+ n: '2',
+ k: 'The skill pack turns that into rules',
+ v:
+ 'With the Tirith skills loaded, what it read becomes policy JSON against the real ' +
+ 'condition list and the argument key each provider actually reads. A ' +
+ 'misconfiguration you already carry is the best specification a rule can have: ' +
+ 'somebody built it that way once, so somebody will build it that way again.',
+ product: true,
+ },
+ {
+ n: '3',
+ k: 'The gate enforces them, deterministically',
+ v:
+ 'The resulting policy set runs in your pipeline, on your runner, through the same ' +
+ 'open-source CLI as everything else on this site, and returns the same exit code ' +
+ 'every time for the same plan. Nothing about enforcement is agentic: the agent ' +
+ 'drafts the rule, the engine rules on the change.',
+ },
+ ],
+ verifyBefore: 'Every generated policy is a draft until it has refused something. ',
+ verifyCmd: 'tirith lint',
+ verifyAfter:
+ ' checks the shape against the engine\'s own registries without needing a plan, and ' +
+ 'the skill\'s one standing instruction is never to hand back a policy it has not run ' +
+ 'against a document that should fail it. Packaging a set to run in one invocation is ' +
+ 'on the roadmap and has not shipped.',
+ mcpHref: 'https://docs.stackguardian.io/docs/mcp-server/',
+ mcpLabel: 'Set up the MCP server',
+ skillsTo: '/skills/',
+ skillsLabel: 'The Tirith skills',
+};
+
const VIEWS = [
[
'Policy enforcement',
@@ -248,6 +318,11 @@ const COMPARISON = [
['Central policy, history and plan visualisation', 'None', 'Included'],
['Approvals, credential brokering and audit', 'Use your existing CI tools', 'Included'],
['Assisted prioritisation and remediation', 'None', 'Included; verify entitlement'],
+ // The OSS column keeps something real on both of these rows. The skill pack is
+ // Apache-2.0 and in this repository, so authoring with an agent needs no relationship;
+ // what needs one is the estate to point it at.
+ ['Author policies with a coding agent', 'Included; skill pack', 'Included'],
+ ['Read the estate from that agent: workflows, templates, cloud posture', 'None', 'Included; MCP server'],
['Drift, snapshots, recovery and notifications', 'None', 'Where execution or state is connected'],
['Private user-owned runtime', 'Your own CI runner', 'Included'],
];
@@ -605,9 +680,46 @@ export default function AtScale() {
)}
- {/* ================= 05 COMPARISON ================= */}
+ {/* ================= 05 YOUR ESTATE ================= */}
+
+
+
+
+ {estate.steps.map((step) => (
+ -
+ {step.n}
+
+ {step.k}
+ {step.v}
+
+
+ ))}
+
+
+ {/*
+ * The claim and its qualifier in one paragraph, deliberately. A reader who takes
+ * the three steps above at face value is owed the sentence about what makes a
+ * generated policy trustworthy in the same breath, not two screens later.
+ */}
+
+ {estate.verifyBefore}
+ {estate.verifyCmd}
+ {estate.verifyAfter}
+
+
+
+
+ {estate.mcpLabel}
+
+
+ {estate.skillsLabel}
+
+
+
+
+ {/* ================= 06 COMPARISON ================= */}
-
+
@@ -628,9 +740,9 @@ export default function AtScale() {
- {/* ================= 06 FAQ ================= */}
+ {/* ================= 07 FAQ ================= */}
-
+
{FAQ.map(([question, answer]) => (
@@ -649,10 +761,10 @@ export default function AtScale() {
- {/* ================= 07 CONTACT ================= */}
+ {/* ================= 08 CONTACT ================= */}
diff --git a/documentation/src/pages/index.js b/documentation/src/pages/index.js
index 4fa896c8..7a98da5b 100644
--- a/documentation/src/pages/index.js
+++ b/documentation/src/pages/index.js
@@ -9,7 +9,7 @@ import CopyField from '../components/landing/CopyField';
import PhaseJourney from '../components/landing/PhaseJourney';
import PlatformSetup from '../components/landing/PlatformSetup';
import Specimen from '../components/landing/Specimen';
-import {INTEGRATIONS} from '../data/demoPhases';
+import {INTEGRATIONS, SKILL_INSTALL} from '../data/demoPhases';
import {HIGHLIGHTS} from '../data/roadmap';
import {AGENT_BRIEF} from '../data/agentBrief';
import {CONTRIBUTORS} from '../data/contributors';
@@ -115,13 +115,44 @@ const hero = {
* The last clause is the one no competitor answers, so the sentence ends on it rather
* than on a feature any scanner could also claim.
*/
+ /*
+ * The four assurances that used to sit in a band under the plate are folded in here.
+ * Three of them were already being made: "checks the plan" and "failures explain why"
+ * by the verdict clause, "local first" by "on your own runner". Only "policies are
+ * JSON" was a claim this paragraph did not make, so only that one arrives as new words,
+ * and the band goes. Net saving is about forty words and a full-width row.
+ */
lede:
- 'Plug IaC governance into any pipeline you already run. Tirith evaluates the plan on ' +
- 'your own runner, enforces one policy set across repositories when you want one, and ' +
- 'returns a single actionable verdict before the change is applied, including the ' +
- 'outcome every scanner reports as success: a check that never ran.',
+ 'Plug IaC governance into any pipeline you already run. Policies are JSON, not a ' +
+ 'program in Rego or Python. Tirith evaluates the plan on your own runner, enforces ' +
+ 'one policy set across repositories when you want one, and returns a single verdict ' +
+ 'before the change is applied.',
// "On your own runner" is the lede's line now, so this no longer repeats it.
actions: [
+ /*
+ * First here for the same reason it leads the tabs in section 03: it is not a sixth
+ * platform, it is the other way of producing any of the five after it. Being first
+ * also makes it the plate's opening state, which is the point -- a reader who already
+ * knows they want the Action clicks past it in one move.
+ *
+ * It replaced the "Rather delegate?" line that used to sit under this plate. A tab
+ * named Coding agent and a sentence underneath offering the same thing were the same
+ * offer made twice, and the tab is the one that can carry the command.
+ */
+ {
+ id: 'agent',
+ label: 'Coding agent',
+ command:
+ `${SKILL_INSTALL}\n` +
+ '\n' +
+ '# then, in your agent:\n' +
+ '"Add a Tirith policy gate to our pipeline"',
+ prompt: false,
+ facts: ['Any agent with a shell', 'Reads the real schema', 'You review the PR'],
+ // Deliberately the shortest caveat of the six. The argument for this route is made
+ // once, in section 02; repeating it here made the default tab the wordiest thing
+ // above the fold. 'this one, so you need not name your CI. It drafts; the engine still decides.',
+ },
{
id: 'github',
label: 'GitHub Actions',
@@ -143,16 +174,11 @@ const hero = {
// check run. It is the only setup step the Action cannot do for you, and the one
// thing people get wrong on a first install.
facts: ['Needs two write permissions', 'Runs on your runner', 'No plan JSON on disk'],
- caveat:
- 'The Action runs Tirith on the GitHub runner and reports the verdict on the pull ' +
- 'request. Handing it the binary plan rather than exporting JSON first is one step ' +
- 'shorter, and renders the plan in memory, so no unmasked plan JSON is written to ' +
- 'the workspace. The setup below adds the policy and the permissions.',
},
{
id: 'gitlab',
label: 'GitLab CI',
- // Two jobs, not one: the caveat says the gate consumes the plan as an artifact, and
+ // Two jobs, not one: the gate consumes the plan as an artifact, and
// the producing job is what makes that sentence mean something.
command: `plan:
script:
@@ -168,14 +194,11 @@ tirith:
- tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error`,
prompt: false,
facts: ['No wrapper to install', 'Runs on your runner', 'Plan as an artifact'],
- caveat:
- 'No wrapper to install — GitLab calls the CLI directly, which is what the GitHub ' +
- 'Action does underneath. The job consumes the plan as an artifact.',
},
{
id: 'bitbucket',
label: 'Bitbucket',
- // "Plan in one step, gate in the next" -- the caveat already promised two steps and
+ // Plan in one step, gate in the next: the snippet has to show both, and
// the block only ever showed the second one.
command: `- step:
name: Terraform plan
@@ -191,34 +214,26 @@ tirith:
- tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error`,
prompt: false,
facts: ['Any Python 3.8 image', 'Runs on your runner', 'Two steps'],
- caveat:
- 'Plan in one step, gate in the next, passing plan.json between them as an ' +
- 'artifact. The same CLI as every other pipeline.',
},
{
id: 'anyci',
label: 'Any CI',
- // `tirith lint` is commented, not dropped: it is in development and not in 1.2.0,
- // so a reader pasting this block would get a failing step. A comment is inert in
- // shell and in YAML, which keeps the block runnable and still shows what is coming.
+ // `tirith lint` stays commented while 1.2.0 is the pinned release: it is on main but
+ // not in that tag, so a reader pasting this block would get a failing step. A comment
+ // is inert in shell and in YAML, which keeps the block runnable and still shows it.
command:
'pip install "git+https://github.com/StackGuardian/tirith.git@1.2.0"\n' +
- '# tirith lint .tirith/policies # in dev, not in 1.2.0\n' +
+ '# tirith lint .tirith/policies # not in 1.2.0 yet, on main\n' +
'tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error',
prompt: false,
facts: ['Gate on the exit code', 'Any runner', 'Two commands'],
- caveat:
- 'Two commands on anything that can produce a plan — Jenkins, Azure DevOps, CircleCI, ' +
- 'a cron job. Gate on the exit code, which every CI system already does. The lint step ' +
- 'is commented out because it has not shipped yet.',
},
{
id: 'cli',
label: 'Local CLI',
/*
- * The install line is here rather than in the caveat, which used to say "install
- * Tirith first" and then not say how -- the one tab whose whole job is the bare
- * command was the one that could not be run from what it showed.
+ * The install line is in the snippet itself. The tab whose whole job is the bare
+ * command has to be runnable from what it shows.
*
* Not PyPI: `py-tirith` is unpublished and the bare `tirith` name belongs to an
* unrelated monitoring package, so `pip install tirith` would quietly install
@@ -237,28 +252,6 @@ tirith:
'tirith --fail-on-error -policy-path .tirith/policies -input-path plan.json',
prompt: false,
facts: ['Nothing leaves your machine', 'Runs on your machine', 'No account'],
- caveat:
- 'The same CLI every pipeline above calls. Point it at a policy or a directory of ' +
- 'policies and the document you want to check — any JSON or YAML document, not only ' +
- 'a plan.',
- },
- ],
- assurances: [
- {
- k: 'Checks the plan',
- v: 'Evaluate the proposed change while the pipeline can still stop it.',
- },
- {
- k: 'Policies are JSON',
- v: 'Describe allowed values without maintaining a program in Rego or Python.',
- },
- {
- k: 'Failures explain why',
- v: 'See which check failed, which value was rejected, and why it failed.',
- },
- {
- k: 'Local first',
- v: 'Keep policies beside your code. Organization mode remains optional.',
},
],
};
@@ -276,128 +269,131 @@ tirith:
* reading BETA would label it without announcing anything -- but it is also a beta,
* and the reference page opens by saying so, so the sentence says so too rather than
* setting a second tag a few pixels from the first.
+ *
+ * Two entries, and two is the cap: a row of announcements announces nothing. Each cell is
+ * its own link, so a reader aiming at one cannot land on the other. An empty array removes
+ * the row. The skill pack leads: it is the newer thing and the one the rest of the page is
+ * built around, and the first cell is the one a reader's eye lands on.
*/
-const announcement = {
- tag: 'New',
- // No backticks: this is JSX text, not markdown, so they would render literally.
- // The renderer sets the command in .
- command: 'tirith ui',
- // A banner is read at a glance or not at all, so it carries the two things the tool is
- // for and nothing else. Validation as you type and serving the playground to a team are
- // the page it links to, not this line.
- body:
- 'explores a failing evaluation down to the resource that caused it, and builds ' +
- 'policies from a form.',
- to: '/docs/tirith-usage/interactive-interface/',
- linkLabel: 'Read more',
-};
+const announcements = [
+ {
+ id: 'skills',
+ tag: 'New',
+ // Named, not explained. This row is the smallest of the three places the pack appears,
+ // and its whole job is that a reader learns the option exists before they start typing.
+ command: 'skill pack',
+ body: 'teaches your coding agent the real schema, and how to put a gate in a pipeline.',
+ to: '/skills/',
+ linkLabel: 'Set it up',
+ },
+ {
+ id: 'ui',
+ tag: 'New',
+ // No backticks: this is JSX text, not markdown, so they would render literally.
+ // The renderer sets the command in .
+ command: 'tirith ui',
+ // A banner is read at a glance or not at all, so it carries the two things the tool is
+ // for and nothing else. Validation as you type and serving the playground to a team are
+ // the page it links to, not this line.
+ body:
+ 'explores a failing evaluation down to the resource that caused it, and builds ' +
+ 'policies from a form.',
+ to: '/docs/tirith-usage/interactive-interface/',
+ linkLabel: 'Read more',
+ },
+];
+
+
+/*
+ * The delegated route, shown in three sizes rather than as a section of its own.
+ *
+ * It was a section once. That was wrong: handing the job to an agent is not a fourth thing
+ * to do after the three steps, it is the same three steps typed at something else, and a
+ * section implied a reader had to choose it deliberately rather than notice it while
+ * choosing a platform. So it now appears beside the commands themselves, at whatever size
+ * the surrounding surface can carry:
+ *
+ * announcement a name, in the row that already announces `tirith ui`
+ * hero the leading tab of the install plate, with the command
+ * 03 setup the leading tab of the platform panel, with the command
+ *
+ * One installer string for all three, so there is one thing to keep correct.
+ */
+/* The installer itself lives in ../data/demoPhases, imported at the top of this file. */
/*
- * Cut roughly in half. This is the first section after the hero, where a reader is still
- * deciding whether to keep going, and three points that each took two sentences to make one
- * argument were the densest thing above the fold.
+ * One section, two registers per step.
*
- * Nothing was dropped. Each point kept its concrete example, which is the part that carries
- * it, and lost the clause restating the heading: "can slip through a busy pull request"
- * after a heading reading "manual review can miss things" says the same thing twice.
+ * This was two sections telling the same story: a four-step narrative of what happens, then
+ * a three-step instruction for what you do, whose steps mapped onto the narrative almost
+ * one to one (export the plan = step 1, commit a policy = step 3, run the gate = step 4).
+ * Each step now carries both: the sentence says what happens, the `do` line says what you
+ * type. The platform tabs sit underneath, answering "and how do I invoke that here" for
+ * whichever runner you use.
+ *
+ * All four note bullets went to the docs, which already carry them. None of them was a
+ * reason to adopt, and the CI integration page is where someone setting this up is reading.
*/
-const gap = {
+const how = {
num: '01',
- title: 'Why add a policy gate?',
- lede:
- 'A valid plan can still break a rule your team depends on, and the pipeline is the ' +
- 'last place that can stop it.',
- points: [
+ title: 'Add Tirith to your pipeline',
+ // One line. The four steps below say the rest, and the tabs make the last clause obvious.
+ lede: 'Your IaC tool produces the plan, Tirith checks it, and the exit code decides.',
+ steps: [
{
- k: 'Review misses things',
- v: 'An empty owner tag, an oversized volume, a destroy hidden inside a replacement.',
+ n: '1',
+ k: 'Your IaC tool plans',
+ do: 'tofu show -json tfplan > plan.json',
},
{
- k: 'After apply is too late',
- v: 'Cleanup and rollback, for something the plan already showed you.',
+ n: '2',
+ k: 'Tirith reads the plan',
+ do: '-input-path plan.json',
+ product: true,
},
{
- k: 'Scripts rot',
- v: 'Every one-off check carries its own parsing, exit codes and upkeep.',
+ n: '3',
+ k: 'Policies test the change',
+ do: 'commit rules under .tirith/policies',
},
- ],
-};
-
-const how = {
- num: '02',
- title: 'Put Tirith between plan and apply.',
- lede:
- 'Tirith fits into the pipeline you already have. Your IaC tool produces the plan, ' +
- 'Tirith checks it, and the exit code tells the pipeline whether to continue.',
- steps: [
- {n: '1', k: 'Your IaC tool plans', v: 'OpenTofu or Terraform exports the proposed change as plan.json.'},
{
- n: '2',
- k: 'Tirith reads the plan',
- v: 'The plan provider finds the resources and attributes each policy asks for, from either tool.',
- product: true,
+ n: '4',
+ k: 'CI continues or stops',
+ do: '--fail-on-error, so a violation exits 3',
},
- {n: '3', k: 'Policies test the change', v: 'JSON conditions check each matching value and produce one verdict.'},
- {n: '4', k: 'CI continues or stops', v: 'A pass moves on to apply. A failure explains the rejected values and, with fail-on-error, exits 3.'},
- ],
-};
-
-const setup = {
- num: '03',
- title: 'Add Tirith to your pipeline',
- lede:
- 'Three pieces make the gate work, and they are the same three everywhere: a policy, ' +
- 'the plan as JSON, and Tirith running with fail-on-error. Only the way you invoke it ' +
- 'changes between platforms.',
- steps: [
- {n: '1', k: 'Commit a policy', v: 'Put one or more JSON rules under .tirith/policies/.'},
- {n: '2', k: 'Export the plan', v: 'Run tofu show -json tfplan > plan.json (or terraform show).'},
- {n: '3', k: 'Run the gate', v: 'Set fail-on-error so a failed policy blocks the job.'},
- ],
- notes: [
- 'OpenTofu works identically — swap terraform for tofu; the plan JSON is the same',
- 'Policies are JSON files committed under .tirith/policies',
- 'On GitHub the Action adds the pull-request comment and check run, which need the two write permissions',
- 'Without fail-on-error, Tirith reports findings but does not block the job',
],
};
const proof = {
- num: '04',
+ num: '02',
title: 'Watch it catch a real mistake',
- lede:
- 'Five chapters, played out in a public demo repository on each of the three forges ' +
- 'above: add the local gate, watch it block an empty Owner tag, clear the failure with ' +
- 'a one-line fix, then move policy to the organization and publish state.',
+ lede: 'Five chapters in a public demo repository, from first gate to published state.',
};
/*
- * Both halves of this section are unshipped. The pre-commit hook needs
- * .pre-commit-hooks.yaml and the editor loop needs .vscode/tasks.json; neither file is in
- * this repository, and both drive `tirith lint`, which is not in the released CLI either
- * -- src/tirith/cli.py dispatches `platform` and `ui` and nothing else.
- *
- * Tagged rather than cut: the docs page it links to is written and the work is real. The
- * tense moves to the conditional so the section describes a plan, not a feature.
+ * Restored once `tirith lint` and `tirith fmt` landed and .pre-commit-hooks.yaml started
+ * publishing both ids. The tense is present because the commands exist; the one thing this
+ * repository still does not ship is .vscode/tasks.json, so the docs page gives those tasks
+ * inline rather than linking to a file that is not there.
*/
const anywhere = {
- num: '05',
+ num: '03',
title: 'Catch it before you push',
- tag: 'In dev',
- planned: true,
- lede:
- 'The gate will not have to wait for CI. The same checks are being wired into a ' +
- 'pre-commit hook and an editor task, which is where a policy an agent just wrote ' +
- 'should be proved. Neither has shipped yet.',
+ /*
+ * Corrected. This used to read "the gate does not have to wait for CI, the same checks
+ * run in a pre-commit hook", which was false in the way that matters: the hooks run
+ * `tirith lint` and `tirith fmt`, and both read the policy file, never the change. Shape,
+ * not meaning, in lint.py's own words. The old sentence even carried its own refutation
+ * ("neither needs a plan document"): a check that needs no plan cannot be the gate.
+ */
+ // The two cards carry the detail. This only has to draw the distinction.
+ lede: 'Two checks, and only one of them needs a plan.',
};
const specimenPlate = {
- // '06' until the section above it was hidden. See the restore note there before changing.
- num: '05',
+ num: '04',
title: 'See exactly what a policy checks',
- lede:
- 'A policy answers three questions: what to read, which values to inspect, and what ' +
- 'must be true. Change the threshold below and watch the verdict update.',
+ lede: 'Change the threshold below and watch the verdict update.',
};
const explore = {
@@ -424,18 +420,37 @@ const explore = {
{
glyph: 'org',
title: 'Tirith at scale',
- body: 'Share policies across repositories and keep run history in StackGuardian.',
+ body: 'One rule scales the same way: the platform reads what you already run, your agent writes the rules to match.',
to: '/at-scale/',
tag: 'Optional',
},
],
};
+/*
+ * The close, and the only place the "write your own rule" argument is now made.
+ *
+ * It was a numbered section as well, under this exact heading, which meant the page argued
+ * the point in the middle and then repeated the headline at the bottom. The points moved
+ * down here because this is where a reader is deciding what to do next, and the note that
+ * was already here is the instruction those three points justify. Proving a policy before
+ * trusting it is the skill pack's standing instruction and the docs' job, not a line the
+ * close has to carry.
+ *
+ * The at-scale bridge folded into the explore card of the same name rather than sitting as
+ * its own line: a sentence and a card linking to the same page, two lines apart, is one
+ * link too many.
+ */
const finale = {
title: 'Start with one rule.',
note:
- 'Choose something your team already checks by hand, commit it as a JSON policy, ' +
- 'and run it against the next plan.',
+ 'A catalogue covers the mistakes everyone makes. Pick the rule only you can state: ' +
+ 'something your team already checks by hand.',
+ points: [
+ {k: 'A policy is data', v: 'JSON with conditions in it. No rule language, no plugin.'},
+ {k: 'So an agent can write it', v: 'The skill pack gives it the real condition list, so it cannot invent one.'},
+ {k: 'The engine decides, not the model', v: 'Same evaluator, same exit code, your runner.'},
+ ],
links: [
{to: '/docs/tirith-installation/quick-installation/', label: 'Installation'},
{to: '/learn/', label: 'Learn to write a policy'},
@@ -569,10 +584,10 @@ function AgentView() {
the route plus .md, beside the HTML
-
- skill pack
+
+ skill packs
- drop-in instructions for a coding agent
+ writing policies, generating a set from your IaC, migrating from Sentinel
@@ -602,20 +617,25 @@ export default function Home() {
* Out here it is the navbar's neighbour, .hero's top padding opens every page on
* the same line, and the strip still precedes the letterhead.
*
- * The whole row is the link -- a reader aiming at "Read more" should not be able
- * to miss and hit nothing.
+ * Each cell is a link across its full width -- a reader aiming at "Read more"
+ * should not be able to miss and hit nothing -- but the row is no longer one
+ * link, because it now carries two destinations.
*/}
- {announcement ? (
-
- {announcement.tag}
-
- {announcement.command}{' '}
- {announcement.body}
-
-
- {announcement.linkLabel}
-
-
+ {announcements.length ? (
+
+ {announcements.map((item) => (
+
+ {item.tag}
+
+ {item.command}{' '}
+ {item.body}
+
+
+ {item.linkLabel}
+
+
+ ))}
+
) : null}
{/* ================= HERO ================= */}
@@ -699,12 +719,10 @@ export default function Home() {
{f}
))}
- {heroAction.caveat}
- {/* What it costs you, before anything asks the visitor to read further. */}
-
- {hero.assurances.map((a) => (
- -
- {a.k}
- {a.v}
-
- ))}
-
>
)}
@@ -735,57 +744,24 @@ export default function Home() {
) : (
<>
- {/* ================= 01 GAP ================= */}
-
-
-
- {gap.points.map((p) => (
-
- - {p.k}
- - {p.v}
-
- ))}
-
-
-
- {/* ================= 02 HOW IT WORKS ================= */}
-
+ {/* ================= 01 ADD IT TO YOUR PIPELINE ================= */}
+
{how.steps.map((step) => (
-
{step.n}
{step.k}
- {step.v}
+ {/* What you type for that step, so the flow is also the instructions. */}
+
+ {step.do}
+
))}
-
-
- {/* ================= 03 QUICK START ================= */}
-
-
-
-
- {setup.steps.map((step) => (
- -
- {step.n}
-
-
{step.k}
- {step.v}
-
-
- ))}
-
-
-
-
+
+
-
- {setup.notes.map((note) => (
- - {note}
- ))}
-
Full installation guide
@@ -794,59 +770,42 @@ export default function Home() {
Start with a worked policy
+
- {/* ================= 04 REAL PROOF ================= */}
+ {/* ================= 02 REAL PROOF ================= */}
- {/*
- * ================= RUNS ANYWHERE: HIDDEN =================
- *
- * Removed for now. Both halves of it were unshipped anyway: the pre-commit hook
- * needs .pre-commit-hooks.yaml and the editor loop needs .vscode/tasks.json, and
- * both drive `tirith lint`, none of which are in the released package. It was
- * already tagged In dev for that reason, so the page lost a tag rather than a
- * feature.
- *
- * A `false` guard rather than a comment: JSX children cannot take a line comment,
- * and the guard keeps the markup parsed so it cannot rot while switched off.
- *
- * TO RESTORE: change `false` to `true`, and put `specimenPlate.num` back to '06'.
- * It was moved to '05' to close the gap this left, because the section numerals are
- * set large on this page and 04 followed by 06 reads as a fault rather than a
- * choice. `anywhere` and the INTEGRATIONS import are both still in place.
- */}
- {false && (
-
-
-
- {INTEGRATIONS.map((item) => (
- -
-
-
-
-
- {item.title}
- {item.inDev ? (
- In dev
- ) : null}
-
- {item.body}
+ {/* ================= 03 RUNS ANYWHERE ================= */}
+
+
+
+ {INTEGRATIONS.map((item) => (
+ -
+
+
+
+
+ {item.title}
+ {item.inDev ? (
+ In dev
+ ) : null}
-
-
-
- ))}
-
-
- )}
+ {item.body}
+
+
+
+
+ ))}
+
+
- {/* ================= 05 THE SPECIMEN ================= */}
+ {/* ================= 04 THE SPECIMEN ================= */}
@@ -872,6 +831,14 @@ export default function Home() {
))}
+
+ {finale.points.map((pt) => (
+
+ - {pt.k}
+ - {pt.v}
+
+ ))}
+
Explore the focused guides
{explore.items.map((item) => (
diff --git a/documentation/src/pages/index.module.css b/documentation/src/pages/index.module.css
index 88fe4151..189dd86c 100644
--- a/documentation/src/pages/index.module.css
+++ b/documentation/src/pages/index.module.css
@@ -128,7 +128,7 @@
* The hero is the sheet's title plate and nothing else. The interactive specimen
* used to sit beside it in a third pane, which split the opening three ways and
* left the headline and the install command competing for the same attention.
- * The instrument now has its own plate below (§01), where it can be as dense as
+ * The instrument now has its own plate below (§06), where it can be as dense as
* it likes without taxing the first viewport.
*/
.hero {
@@ -158,24 +158,50 @@
* are three different sizes, and centring them lines up their boxes while leaving
* the type they contain visibly astray.
*/
+/*
+ * Two cells on one rule, divided rather than stacked: the row has to stay one line tall,
+ * because it sits above the letterhead and any height it takes is height the hero loses.
+ */
.announce {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ border-bottom: 1px solid var(--tp-rule);
+}
+
+.announceItem {
display: flex;
align-items: baseline;
gap: 0.75rem;
+ min-width: 0;
padding: 0.85rem var(--tp-pad);
- border-bottom: 1px solid var(--tp-rule);
color: var(--tp-soft);
font-size: 0.8rem;
line-height: 1.5;
}
-/* The row is the link, so the underline the theme would add belongs to nothing. */
-.announce:hover {
+.announceItem + .announceItem {
+ border-left: 1px solid var(--tp-rule);
+}
+
+/* The cell is the link, so the underline the theme would add belongs to nothing. */
+.announceItem:hover {
color: var(--tp-ink);
text-decoration: none;
}
-.announce .betaTag {
+/* One column below the fold-width where two cells would each get half a sentence. */
+@media (max-width: 860px) {
+ .announce {
+ grid-template-columns: minmax(0, 1fr);
+ }
+
+ .announceItem + .announceItem {
+ border-top: 1px solid var(--tp-rule);
+ border-left: 0;
+ }
+}
+
+.announceItem .betaTag {
flex: none;
/* Baseline alignment drops a bordered inline box by its own padding; this puts the
tag's cap-height back on the sentence's baseline instead of below it. */
@@ -331,15 +357,6 @@
margin-bottom: 1.25rem;
}
-.cost {
- max-width: 52ch;
- margin: 0 0 2rem;
- padding-left: 0.9rem;
- border-left: 2px solid var(--tp-accent);
- color: var(--tp-ink);
- font-size: 1rem;
- line-height: 1.55;
-}
.heroAction {
min-width: 0;
@@ -394,52 +411,11 @@
text-transform: uppercase;
}
-/*
- * The assurance band. Same hairline-ruled cells as .defs, but seated inside the hero
- * so it is read as part of the offer rather than as the first section of the
- * argument. It answers the question the StackGuardian mark in the navbar provokes.
- */
-.assure {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
- gap: 0;
- margin: 0;
- padding: 0;
- list-style: none;
- border-top: 1px solid var(--tp-rule);
-}
-.assure li {
- min-width: 0;
- padding: 1.15rem var(--tp-gut) 1.4rem 0;
- border-right: 1px solid var(--tp-rule);
-}
-.assure li:last-child {
- border-right: 0;
- padding-right: 0;
-}
-.assure li:not(:first-child) {
- padding-left: var(--tp-gut);
-}
-.assureK {
- display: block;
- margin-bottom: 0.4rem;
- font-family: var(--tp-display);
- font-size: 0.73rem;
- font-weight: 700;
- letter-spacing: -0.02em;
-}
-.assureV {
- display: block;
- max-width: 36ch;
- color: var(--tp-soft);
- font-size: 0.94rem;
- line-height: 1.55;
-}
/* ------------------------------------------------------- the copy field ----- */
@@ -480,6 +456,7 @@
and this one is the page's primary action. */
white-space: pre-wrap;
overflow-wrap: anywhere;
+ border-right: 1px solid var(--tp-rule);
}
.copyPrompt {
@@ -504,8 +481,13 @@
align-self: flex-start;
min-width: 5.5rem;
padding: 0.7rem 0.9rem;
+ /*
+ * No border-left. The button is a top-aligned chip, so an edge on it rendered as a short
+ * line floating beside a taller field rather than as a divider. The divider is
+ * .copyCommand's right edge instead, which stretches with the field however many lines
+ * the command wraps to.
+ */
border: 0;
- border-left: 1px solid var(--tp-rule);
background: var(--tp-ink);
color: var(--tp-paper);
font-family: var(--tp-display);
@@ -574,23 +556,7 @@
color: var(--tp-faint);
}
-.caveat {
- max-width: 48ch;
- margin: 0.9rem 0 0;
- color: var(--tp-soft);
- font-size: 0.85rem;
- line-height: 1.55;
-}
-.caveat code {
- padding: 0.05em 0.3em;
- border: 0;
- border-radius: 0;
- background: var(--tp-surface);
- color: var(--tp-soft);
- font-size: 0.95em;
- vertical-align: baseline;
-}
.heroLinks {
display: flex;
@@ -1823,15 +1789,19 @@
font-weight: 700;
}
-.howFlow h3,
-.quickSteps h3 {
+/*
+ * These two were `.howFlow h3, .quickSteps h3` and `.howFlow p, .quickSteps p`. Removing the
+ * dead `.quickSteps` halves matched the second line of each selector and deleted through the
+ * closing brace, orphaning `.howFlow h3,` and `.howFlow p,` onto the block below, which is
+ * how every heading and paragraph in the flow ended up painted with the accent card's fill.
+ */
+.howFlow h3 {
margin: 0;
font-size: 0.86rem;
line-height: 1.35;
}
-.howFlow p,
-.quickSteps p {
+.howFlow p {
margin: 0.6rem 0 0;
color: var(--tp-soft);
font-size: 0.94rem;
@@ -1844,6 +1814,61 @@
color: #fff;
}
+/*
+ * The h3 needs naming explicitly, and this is why: Infima ships
+ * `h1,h2,h3,h4,h5,h6{color:var(--ifm-heading-color)}` and this site points that variable at
+ * --tp-ink. A direct rule on the element beats a colour inherited from `.howProduct` on the
+ * li, so the card's own title rendered in body ink on the accent fill while the paragraph
+ * beside it, which did have an explicit override, looked correct. The title is the one
+ * thing on the card that has to carry, so it takes full white rather than the 0.82 the
+ * supporting text uses.
+ */
+/*
+ * The instruction line in each flow cell. Mono and quieter than the sentence above it: the
+ * cell says what happens, this says what you type, and they must not read as one voice.
+ */
+/* `.howFlow .howDo` rather than `.howDo !important`: `.howFlow p` already sets a margin at
+ (0,1,1), so the flat class lost and the fix was reached for with a hammer. */
+/* No divider: the sentence it used to separate the command from is gone, so a rule directly
+ under the title would be dividing a heading from its own instruction. */
+.howFlow .howDo {
+ margin: 0.8rem 0 0;
+}
+
+/*
+ * The chip is not ours: Infima styles every bare with its own background and border.
+ * Recolouring only the text is what broke the accent card, where near-white ink landed on
+ * Infima's light grey chip and became unreadable. Both states now set background, border
+ * and colour together, so the chip can never be lit from one palette and inked from another.
+ */
+.howFlow .howDo code {
+ border-color: rgba(0, 0, 0, 0.1);
+ background: var(--tp-paper);
+ color: var(--tp-soft);
+ font-family: var(--tp-mono);
+ font-size: 0.74rem;
+ line-height: 1.45;
+ overflow-wrap: anywhere;
+}
+
+
+.howFlow .howProduct .howDo code {
+ border-color: rgba(255, 255, 255, 0.32);
+ background: rgba(255, 255, 255, 0.14);
+ color: #fff;
+}
+
+
+[data-theme='dark'] .howFlow .howProduct .howDo code {
+ border-color: rgba(13, 13, 13, 0.3);
+ background: rgba(13, 13, 13, 0.12);
+ color: #0d0d0d;
+}
+
+.howFlow .howProduct h3 {
+ color: #fff;
+}
+
.howFlow .howProduct .howNum,
.howFlow .howProduct p {
color: rgba(255, 255, 255, 0.82);
@@ -1858,6 +1883,12 @@
color: #0d0d0d;
}
+/* Dark mode flips the accent to a light periwinkle, so the card's text goes dark to keep
+ contrast. Same Infima override needed here, in the other direction. */
+[data-theme='dark'] .howFlow .howProduct h3 {
+ color: #0d0d0d;
+}
+
[data-theme='dark'] .howFlow .howProduct .howNum,
[data-theme='dark'] .howFlow .howProduct p {
color: rgba(13, 13, 13, 0.78);
@@ -1867,35 +1898,9 @@
color: #0d0d0d;
}
-/* --------------------------------------------------------- 03 quick start --- */
-.quickStart {
- display: grid;
- grid-template-columns: minmax(18rem, 0.72fr) minmax(0, 1.28fr);
- border-top: 1px solid var(--tp-rule);
- border-left: 1px solid var(--tp-rule);
-}
-.quickSteps {
- min-width: 0;
- margin: 0;
- padding: 0;
- list-style: none;
-}
-.quickSteps li {
- display: grid;
- grid-template-columns: 1.8rem minmax(0, 1fr);
- gap: 0.85rem;
- padding: 1.2rem 1.25rem;
- border-right: 1px solid var(--tp-rule);
- border-bottom: 1px solid var(--tp-rule);
-}
-
-.quickSteps .stepNum {
- width: 1.8rem;
- height: 1.8rem;
-}
.quickCode {
min-width: 0;
@@ -1912,32 +1917,6 @@
font-size: 0.72rem;
}
-.quickNotes {
- display: grid;
- grid-template-columns: repeat(3, minmax(0, 1fr));
- margin: 0;
- padding: 0;
- border-left: 1px solid var(--tp-rule);
- list-style: none;
-}
-
-.quickNotes li {
- min-width: 0;
- padding: 0.9rem 1.1rem;
- border-right: 1px solid var(--tp-rule);
- border-bottom: 1px solid var(--tp-rule);
- color: var(--tp-soft);
- font-size: 0.82rem;
- line-height: 1.5;
-}
-
-.quickNotes li::before {
- content: '✓';
- margin-right: 0.55rem;
- color: var(--tp-accent);
- font-family: var(--tp-mono);
-}
-
.quickLinks {
display: flex;
flex-wrap: wrap;
@@ -2063,6 +2042,25 @@
color: var(--tp-faint);
}
+/*
+ * Marks the two walkthrough steps that need a StackGuardian organization. Set as a hairline
+ * chip in the eyebrow rather than a coloured tag: it is a qualification, not a warning, and
+ * anything louder would read as the step being unavailable rather than optional.
+ */
+.phasePlatform {
+ display: inline-block;
+ margin-left: 0.5rem;
+ padding: 0.05rem 0.34rem;
+ border: 1px solid var(--tp-rule);
+ border-radius: 2px;
+ color: var(--tp-faint);
+ font-size: 0.9em;
+ letter-spacing: 0.08em;
+ /* The eyebrow is uppercase; this stays lower case so it reads as an aside to it. */
+ text-transform: lowercase;
+ white-space: nowrap;
+}
+
.phaseSelectTitle {
font-family: var(--tp-display);
font-size: 0.8rem;
@@ -2338,19 +2336,6 @@
gap: 0.85rem;
}
-.stepNum {
- display: grid;
- place-items: center;
- width: 1.55rem;
- height: 1.55rem;
- flex: none;
- border: 1px solid var(--tp-accent);
- color: var(--tp-accent);
- font-family: var(--tp-display);
- font-size: 0.7rem;
- font-weight: 700;
- align-self: center;
-}
.stepTitle {
margin: 0;
@@ -3113,9 +3098,7 @@
padding-left: 0;
}
- .howFlow,
- .quickStart,
- .quickNotes {
+ .howFlow {
grid-template-columns: minmax(0, 1fr);
}
@@ -3123,9 +3106,7 @@
content: none !important;
}
- .quickSteps li,
.quickCode,
- .quickNotes li,
.exploreCard {
padding-left: 1rem;
padding-right: 1rem;
@@ -3182,21 +3163,6 @@
padding-right: 1rem;
}
- .assure li {
- padding-right: 0;
- padding-left: 0;
- border-right: 0;
- border-bottom: 1px solid var(--tp-rule);
- }
-
- .assure li:not(:first-child) {
- padding-left: 0;
- }
-
- .assure li:last-child {
- border-bottom: 0;
- }
-
.uiTabs li {
padding-right: 0;
padding-left: 0;
@@ -3401,3 +3367,6 @@
padding-right: 0;
}
}
+
+
+
diff --git a/documentation/src/pages/learn.js b/documentation/src/pages/learn.js
index d448d081..6b7a1ccc 100644
--- a/documentation/src/pages/learn.js
+++ b/documentation/src/pages/learn.js
@@ -230,7 +230,7 @@ export default function Learn() {
Tirith
- Three providers, eleven lessons
+ Three providers, twenty-three lessons
@@ -241,9 +241,9 @@ export default function Learn() {
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.
+ the same for all three, so the Terraform track teaches it in full and the
+ other two teach only what changes. Every step is editable: change a value,
+ run it, and watch the verdict, the messages and the exit code move with it.
@@ -224,12 +280,69 @@ export default function Skills() {
- {/* ================= 02 WHAT IT COVERS ================= */}
-
+ {/* ================= 02 THE STARRED SKILL ================= */}
+
+
+
+ {FEATURED.eyebrow}
+ “{FEATURED.ask}”
+
+ One sentence, and no need to name your CI: the pack covers GitHub Actions,
+ GitLab, Bitbucket, Jenkins, Azure DevOps, CircleCI and any container runner,
+ so the agent writes for whichever one it finds in the repository. What comes
+ back is that pipeline file, a policy under .tirith/policies, and
+ a job that fails on exit 3 and reports a tooling problem on exit{' '}
+ 1 instead of confusing the two.
+
+
+
+ {FEATURED.snippets.map((snippet) => (
+
+ {snippet.label}
+ capture(EVENTS.skillCopy, {client: `featured-${snippet.id}`})}
+ command={snippet.command}
+ label={`featured-${snippet.id}`}
+ prompt={false}
+ tone={snippet.id === 'pack' ? 'primary' : 'quiet'}
+ />
+
+ ))}
+
+
+
+ {FEATURED.files.map(([file, what]) => (
+ -
+
+
{file}
+
+ {what}
+
+ ))}
+
+
+
+ Every platform, written out
+
+
+ Install Tirith itself
+
+
+
+
+ {/* ================= 03 WHAT IT COVERS ================= */}
+
+
{SKILLS.map((group) => (
@@ -240,7 +353,7 @@ export default function Skills() {
{name}
+ href={`${REPO}/blob/main/.claude/skills/${group.dir || 'tirith-policies'}/${file}`}>
{file}
{what}
@@ -251,10 +364,10 @@ export default function Skills() {
))}
- {/* ================= 03 THE LOOP ================= */}
+ {/* ================= 04 THE LOOP ================= */}
@@ -277,9 +390,9 @@ export default function Skills() {
- {/* ================= 04 BOUNDARIES ================= */}
+ {/* ================= 05 BOUNDARIES ================= */}
-
+
{BOUNDARIES.map(([k, v]) => (
diff --git a/documentation/src/pages/skills.module.css b/documentation/src/pages/skills.module.css
index 6c42311a..c872866b 100644
--- a/documentation/src/pages/skills.module.css
+++ b/documentation/src/pages/skills.module.css
@@ -722,3 +722,108 @@
margin-top: 0.3rem;
}
}
+
+/* ---------------------------------------------------------------------------
+ * THE STARRED SKILL
+ *
+ * Two columns: the sentence someone actually types on the left, the two commands on the
+ * right. It reads as one plate rather than a card grid because it is one action, and it
+ * is the only skill on the page shown with its commands: everything under "What it
+ * covers" is a name and a file, which is right for a catalogue and wrong for the entry
+ * point.
+ * ------------------------------------------------------------------------ */
+.featured {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+ gap: 0 var(--tp-gut);
+ align-items: start;
+ padding: 1.75rem 0 1.9rem;
+ border-top: 1px solid var(--tp-rule);
+ border-bottom: 1px solid var(--tp-rule);
+}
+
+.featuredAsk {
+ display: grid;
+ gap: 0.7rem;
+ align-content: start;
+ min-width: 0;
+}
+
+.featuredEyebrow {
+ color: var(--tp-faint);
+ font-family: var(--tp-mono, monospace);
+ font-size: 0.7rem;
+ font-weight: 600;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+}
+
+/* The ask is set as the page's own voice rather than in a code field: it is a sentence
+ typed at an agent, not a command with syntax to get right. */
+.featuredQuote {
+ margin: 0;
+ font-family: var(--tp-display);
+ font-size: 1.15rem;
+ font-weight: 600;
+ line-height: 1.4;
+ letter-spacing: -0.02em;
+ text-wrap: balance;
+}
+
+.featuredNote {
+ margin: 0;
+ max-width: 46ch;
+ color: var(--tp-soft);
+ font-size: 0.88rem;
+ line-height: 1.6;
+}
+
+.featuredNote code {
+ font-size: 0.85em;
+}
+
+.featuredCommands {
+ display: grid;
+ gap: 1.1rem;
+ min-width: 0;
+}
+
+.featuredCommand {
+ display: grid;
+ gap: 0.45rem;
+ min-width: 0;
+}
+
+.featuredLabel {
+ color: var(--tp-faint);
+ font-size: 0.78rem;
+ letter-spacing: 0.02em;
+}
+
+.featuredFiles {
+ display: grid;
+ gap: 0.75rem;
+ margin: 1.5rem 0 0;
+ padding: 0;
+ list-style: none;
+}
+
+.featuredFiles > li {
+ display: grid;
+ grid-template-columns: minmax(0, 15rem) minmax(0, 1fr);
+ gap: 0 var(--tp-gut);
+ align-items: baseline;
+ min-width: 0;
+}
+
+@media (max-width: 860px) {
+ .featured {
+ grid-template-columns: minmax(0, 1fr);
+ gap: 1.6rem;
+ }
+
+ .featuredFiles > li {
+ grid-template-columns: minmax(0, 1fr);
+ gap: 0.25rem;
+ }
+}
diff --git a/documentation/static/docs/tirith-usage/agent-skills.md b/documentation/static/docs/tirith-usage/agent-skills.md
index eb688a3c..0066571e 100644
--- a/documentation/static/docs/tirith-usage/agent-skills.md
+++ b/documentation/static/docs/tirith-usage/agent-skills.md
@@ -19,9 +19,10 @@ guess from a plausible-looking shape.
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, and they are picked up in any
-repository you copy them into. A session that is already running may not see a newly installed
+Three skills under `.claude/skills/`: `tirith-policies`, for writing policies,
+`tirith-standards`, for generating a policy set from a repository you already have, and
+`tirith-migrate`, 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 | |
@@ -86,9 +87,30 @@ 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 |
+## Standards from a repository you already have
+
+`tirith-standards` starts from the Terraform or OpenTofu you already run rather than from a
+catalogue. It reads the code to work out which resource types are in use and which conventions
+are already followed, proposes a set of standards with the count of resources that would pass and
+fail today, and writes one policy per rule: required tags, naming conventions, allowed regions,
+permitted resource types, size ceilings, encryption defaults.
+
+Two things in it are worth knowing even if you never install it, because they decide whether a
+generated policy is usable:
+
+- **The policies never see your HCL.** The skill reads `.tf` files only to decide what to write
+ about; the rules themselves evaluate `terraform show -json`, where variables are resolved,
+ modules are expanded into addressed resources, and a name that is not known until apply is
+ `null`. A rule about a variable or a module block reads nothing.
+- **A rule scoped to one resource type needs a guard.** With the default `error_tolerance` an
+ absent resource type is severity `1` and the check fails, so the rule is red on every plan that
+ does not touch that type. At `error_tolerance: 1` it is skipped instead, every check is skipped,
+ and `final_result` is `null`, which exits `1`. Neither is green. The skill's fix is a `count`
+ evaluator, which returns `0` for an absent type rather than erroring, combined with `||`.
+
## Migrating from Sentinel
-The second skill, `tirith-migrate`, is for teams with existing HashiCorp Sentinel policies. It is a
+The third 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
diff --git a/documentation/static/docs/tirith-usage/ci-integration.md b/documentation/static/docs/tirith-usage/ci-integration.md
index 0a4d3ca2..f96f4e2f 100644
--- a/documentation/static/docs/tirith-usage/ci-integration.md
+++ b/documentation/static/docs/tirith-usage/ci-integration.md
@@ -179,11 +179,17 @@ pipelines:
- step:
name: Policy gate
script:
- - pip install "git+https://github.com/StackGuardian/tirith.git@1.2.0"
- # - tirith lint .tirith/policies # in dev, not in 1.2.0
+ # @main rather than @1.2.0, because the lint step below is not in 1.2.0 yet.
+ - pip install "git+https://github.com/StackGuardian/tirith.git@main"
+ - tirith lint .tirith/policies
- tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error
```
+Linting first is cheap and it fails for a different reason than the gate does: a policy that is
+malformed never gets as far as disagreeing with your infrastructure. It needs no plan document,
+so it can also run in a job that has no cloud credentials at all. See
+[lint and format](lint-and-fmt.md).
+
A worked repository is at
[tirith-bitbucket-demo](https://bitbucket.org/__refeed__/tirith-bitbucket-demo).
@@ -216,31 +222,30 @@ exit and the two cases become one.
## As a pre-commit hook
-[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/).
+[NOTE] Not in 1.2.0
+`tirith lint` and `tirith fmt` are on `main` and arrive in the next release, so pin `rev` to a
+branch until then. Everything else on this page works on 1.2.0.
-Catch a broken policy before it is committed, let alone before CI runs it. Tirith will publish a
-`tirith-lint` hook:
+Catch a broken policy before it is committed, let alone before CI runs it. Tirith publishes a
+`tirith-lint` and a `tirith-fmt` hook:
```yaml
repos:
- repo: https://github.com/StackGuardian/tirith
- rev: 1.2.0
+ rev: main
hooks:
- id: tirith-lint
+ - id: tirith-fmt
```
```bash
pre-commit install
-pre-commit run tirith-lint --all-files
+pre-commit run --all-files
```
-The hook runs only when a file under `.tirith/policies/` or a `*.tirith.json` changes. It lints
-the policy directory rather than the individual changed files, because `tirith lint` takes a
-single path.
+Both hooks run only when a file under `.tirith/` or a `*.tirith.json` changes, and both are
+handed the individual changed files rather than the whole directory: `tirith lint` and
+`tirith fmt` each take any number of paths, so a commit touching one policy checks one policy.
[NOTE] Why linting and not evaluation
Evaluating a policy needs a plan document, and producing one means running `terraform plan` —
diff --git a/documentation/static/docs/tirith-usage/cli-reference.md b/documentation/static/docs/tirith-usage/cli-reference.md
index 23511a93..5bd0d041 100644
--- a/documentation/static/docs/tirith-usage/cli-reference.md
+++ b/documentation/static/docs/tirith-usage/cli-reference.md
@@ -12,9 +12,23 @@ tirith -policy-path policy.json -input-path plan.json
Run with no arguments, `tirith` prints its help text and exits `0`.
-There is one subcommand, `tirith platform check`, which evaluates against the policies a
-StackGuardian organization enforces instead of local files. It has its own flags and its own page:
-[Platform Check](platform-check.md).
+Four subcommands sit beside it, each with its own flags and its own page:
+
+| Subcommand | What it does |
+|---|---|
+| [`tirith lint`](lint-and-fmt.md) | Check policy files for mistakes that would gate nothing or fail as a false violation. No plan document needed |
+| [`tirith fmt`](lint-and-fmt.md) | Rewrite policy files into the canonical layout |
+| [`tirith ui`](interactive-interface.md) | Explore results, build policies and experiment in an interactive interface. Needs the optional `tui` extra |
+| [`tirith platform check`](platform-check.md) | Evaluate against the policies a StackGuardian organization enforces instead of local files |
+
+A first argument that is not one of those four and does not begin with a dash is rejected by name,
+because the flat command below takes no positional arguments:
+
+```console
+$ tirith lnit .tirith/policies
+tirith: 'lnit' is not a tirith command. Commands: fmt, lint, platform, ui.
+Run 'tirith --help' for the local-evaluation options.
+```
## Flags
diff --git a/documentation/static/docs/tirith-usage/editor-and-local.md b/documentation/static/docs/tirith-usage/editor-and-local.md
index fe4e9c4a..f3dfdef1 100644
--- a/documentation/static/docs/tirith-usage/editor-and-local.md
+++ b/documentation/static/docs/tirith-usage/editor-and-local.md
@@ -1,25 +1,16 @@
# In your editor
Source: https://stackguardian.github.io/tirith/docs/tirith-usage/editor-and-local/
-Summary: In development — VS Code tasks, a pre-commit hook, and the local loop to use when an AI agent is drafting the policy. The lint half is not in the released package yet.
+Summary: VS Code tasks, a pre-commit hook, and the local loop to use when an AI agent is drafting the policy.
-[WARNING] In development — the lint half has not shipped
+[NOTE] Not in 1.2.0
+`tirith lint` and `tirith fmt` are on `main` and will arrive in the next release. Until then,
+install from the branch rather than the tag:
+`pip install "git+https://github.com/StackGuardian/tirith.git@main"`. Evaluation, the second half
+of the loop, works on any installed version.
-`tirith lint`, the pre-commit hook and the VS Code tasks below are **in development**. They are
-not in the released package: `pip install "git+https://github.com/StackGuardian/tirith.git@1.2.0"`
-gives you `tirith`, `tirith ui` and `tirith platform check`, and no `lint` subcommand. The
-`tirith-lint` hook id and the task file are not in the repository yet either.
-
-**What works today** is the second half of the loop — evaluating a policy against a document with
-`tirith -policy-path … -input-path … --fail-on-error`. That runs locally, offline, on any
-installed version.
-
-This page is published now so the shape is reviewable. Follow
-[the repository](https://github.com/StackGuardian/tirith) for the release, or
-[tell us what the loop is missing](https://github.com/StackGuardian/tirith/issues/new/choose).
-
-CI is the last place a policy should fail. This page is about the loop before that — running
-Tirith on your own machine, while the code is still being written.
+CI is the last place a policy should fail. This page is about the loop before that, running
+Tirith on your own machine while the code is still being written.
It matters most when an agent is drafting the policy for you. Generated policy JSON is plausible
by construction: it parses, it looks right, and a policy that matches nothing is indistinguishable
@@ -28,28 +19,34 @@ from one that works until you evaluate it.
## The loop
```bash
-tirith lint .tirith/policies # shape — in dev
-tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error # meaning — ships
+tirith fmt .tirith/policies # layout
+tirith lint .tirith/policies # shape
+tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error # meaning
```
-Linting checks the **shape** — that every condition type exists, that no `provider_args` key
-belongs to a different provider, that `eval_expression` names every evaluator. Only evaluation
-checks the **meaning**.
+Linting checks the **shape**: that every condition type exists, that no `provider_args` key
+belongs to a different provider, that `eval_expression` names every evaluator, that
+`error_tolerance` is inside `condition` where the engine reads it. Only evaluation checks the
+**meaning**. [Lint and format](lint-and-fmt.md) covers both commands in full.
Run both against a document that *should* fail. A guardrail only ever seen passing is a guardrail
nobody has tested.
## VS Code tasks
-**In development.** The lint task below calls a subcommand the released package does not have; the
-evaluate task works today.
-
Drop this in `.vscode/tasks.json` and the loop becomes one keystroke:
```json
{
"version": "2.0.0",
"tasks": [
+ {
+ "label": "Tirith: format policies",
+ "type": "shell",
+ "command": "tirith fmt .tirith/policies",
+ "problemMatcher": [],
+ "group": "test"
+ },
{
"label": "Tirith: lint policies",
"type": "shell",
@@ -70,29 +67,29 @@ Drop this in `.vscode/tasks.json` and the loop becomes one keystroke:
```
`Tirith: check the plan` is the default test task, so **⇧⌘B** / **Ctrl+Shift+B** runs the whole
-loop. The complete file — including a task to refresh `plan.json` and one to open the result in
-the interactive explorer — is
-[`.vscode/tasks.json`](https://github.com/StackGuardian/tirith/blob/main/.vscode/tasks.json).
+loop: lint first, then evaluate, because `dependsOn` will not run the second if the first exits
+non-zero.
## Catch it at commit time
-**In development.** The `tirith-lint` hook id is not published yet, so this configuration will not
-resolve — `pre-commit` fails with an unknown hook rather than installing anything.
+Tirith publishes both commands as pre-commit hooks:
```yaml
repos:
- repo: https://github.com/StackGuardian/tirith
- rev: 1.2.0
+ rev: main
hooks:
- id: tirith-lint
+ - id: tirith-fmt
```
```bash
pre-commit install
```
-The hook runs only when a policy file changes, needs no network, and exits `3` when a policy has
-an error. See [CI integration](ci-integration.md) for why it lints rather than evaluates.
+Both run only on the policy files a commit actually touches, need no network, and exit `3` on a
+finding. See [CI integration](ci-integration.md) for why they lint rather than evaluate, and
+[lint and format](lint-and-fmt.md) for the flags and the files they read.
## When an agent is writing the policy
diff --git a/documentation/static/docs/tirith-usage/exit-codes.md b/documentation/static/docs/tirith-usage/exit-codes.md
index 3e17a977..1dd150d6 100644
--- a/documentation/static/docs/tirith-usage/exit-codes.md
+++ b/documentation/static/docs/tirith-usage/exit-codes.md
@@ -3,18 +3,21 @@
Source: https://stackguardian.github.io/tirith/docs/tirith-usage/exit-codes/
Summary: The complete Tirith exit-code contract, and how to gate a CI job on it.
-Tirith's exit codes are a contract shared by both surfaces — local evaluation (`tirith`) and
-platform evaluation (`tirith platform check`) — so a caller scripting both only has to learn one
-vocabulary.
+Tirith's exit codes are one contract shared by every surface: local evaluation (`tirith`),
+platform evaluation (`tirith platform check`), and the two file commands, `tirith lint` and
+`tirith fmt`. A caller scripting more than one only has to learn a single vocabulary.
| Code | Meaning |
|---|---|
-| `0` | Policies passed, or nothing was in scope to gate on |
-| `1` | Tirith could not complete the evaluation — bad input, a policy it could not evaluate, unreachable API |
-| `2` | Timed out waiting for a StackGuardian run (`tirith platform check` only; local evaluation never produces it) |
-| `3` | A policy failed. Only with `--fail-on-error`, on either surface |
+| `0` | Policies passed, nothing was in scope to gate on, or the files were clean |
+| `1` | Tirith could not complete the run: bad input, a policy it could not evaluate, an unreachable API, a path that does not exist |
+| `3` | A policy said no. On the evaluation surfaces only with `--fail-on-error`; `lint` and `fmt` need no flag |
| `130` | Interrupted (Ctrl-C) |
+`2` is deliberately absent. It used to be a timeout code that nothing ever returned, and
+`argparse` already exits `2` of its own accord on a usage error, so a caller seeing `2` has
+passed a bad argument. Do not write a pipeline that branches on it.
+
## `3` is deliberately not `1`
`3` means a check ran and said no: your infrastructure violates a policy. `1` means Tirith could
@@ -28,6 +31,21 @@ Both surfaces **fail closed**: anything that leaves the verdict unknown exits no
of `--fail-on-error`. That flag governs policy verdicts, not tool health — a run that produced no
verdict must never look like a pass.
+## `tirith lint` and `tirith fmt`
+
+Both use the same three codes, and neither takes `--fail-on-error`: a linter that cannot fail is
+not a linter, and there is no existing green pipeline to protect because nothing has run them
+before.
+
+| Command | `0` | `3` | `1` |
+|---|---|---|---|
+| `tirith lint` | every policy is clean | a policy has an error-level finding, or a warning under `--strict` | a path is missing, or nothing was found to lint |
+| `tirith fmt` | nothing to change, or the changes were written | `--check` found files that would change | a path is missing, a file is not valid JSON, or nothing was found |
+
+"Nothing was found" being `1` rather than `0` is the same fail-closed rule the evaluation
+surfaces follow. A hook pointed at the wrong directory reports a problem instead of quietly
+passing every commit. See [lint and format](lint-and-fmt.md).
+
## Without `--fail-on-error`
The local command exits `0` whether the policy passed or failed, with the verdict in the output.
diff --git a/documentation/static/docs/tirith-usage/lint-and-fmt.md b/documentation/static/docs/tirith-usage/lint-and-fmt.md
new file mode 100644
index 00000000..7857ca80
--- /dev/null
+++ b/documentation/static/docs/tirith-usage/lint-and-fmt.md
@@ -0,0 +1,205 @@
+# Lint and format
+
+Source: https://stackguardian.github.io/tirith/docs/tirith-usage/lint-and-fmt/
+Summary: tirith lint checks policy files for mistakes that would gate nothing or fail as a false violation. tirith fmt rewrites them into one canonical layout. Neither needs a plan document.
+
+Two commands that read policy files and nothing else. Neither needs a plan document, an account,
+or a network call, which is what lets them run in a pre-commit hook and in a slim CI image without
+the optional `tui` extra.
+
+```bash
+tirith lint .tirith/policies # is the policy well formed?
+tirith fmt .tirith/policies # rewrite it into the canonical layout
+```
+
+[NOTE] Not in 1.2.0
+Both commands are on `main` and will arrive in the next release. To use them today, install from
+the branch rather than the `1.2.0` tag:
+
+```bash
+pip install "git+https://github.com/StackGuardian/tirith.git@main"
+```
+
+## `tirith lint`
+
+Lint checks the **shape**, not the meaning. It runs the same validator the
+[interactive interface](interactive-interface.md) runs on every keystroke, so the two never
+disagree, and it catches the class of mistake that otherwise reaches CI looking like a real
+infrastructure violation:
+
+- a condition `type` that does not exist, against the closed list of
+ [thirteen conditions](../tirith-reference/evaluators.md)
+- a `provider_args` key that belongs to a different provider than the one in
+ `meta.required_provider`
+- an evaluator that `eval_expression` never names, so it is computed and then discarded
+- `error_tolerance` placed on the evaluator instead of inside `condition`, where it is silently
+ ignored and the check still fails
+
+What lint cannot tell you is whether a well-formed policy matches anything. Only evaluating it
+against a document that *should* fail answers that:
+
+```bash
+tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error
+```
+
+### Usage
+
+```
+tirith lint [PATH ...] [--json] [--strict] [--quiet]
+```
+
+| Flag | What it does |
+|---|---|
+| `--json` | Print the report as a JSON document instead of text |
+| `--strict` | Treat warnings as errors when deciding the exit code |
+| `--quiet` | Print findings only, with no summary line and no skipped files |
+
+Findings are printed one per line, in a form an editor or a log scraper can parse:
+
+```
+.tirith/policies/s3.json:evaluators[0].condition: error: unknown condition type 'EqualTo'
+.tirith/policies/s3.json:eval_expression: warning: evaluator 'bucket_acl' is never referenced
+2 policies, 1 errors, 1 warnings
+```
+
+`--json` returns the same report structured, so an editor integration or another program does not
+have to parse the text:
+
+```json
+{
+ "files": [
+ {
+ "path": ".tirith/policies/s3.json",
+ "findings": [
+ {"severity": "error", "where": "evaluators[0].condition", "message": "unknown condition type 'EqualTo'"}
+ ]
+ }
+ ],
+ "skipped": [],
+ "missing": [],
+ "summary": {"policies": 2, "errors": 1, "warnings": 1, "ignored": 0},
+ "exit_status": 3
+}
+```
+
+### Exit codes
+
+| Code | Meaning |
+|---|---|
+| `0` | Every policy is clean |
+| `3` | A policy has an error-level finding, or a warning under `--strict` |
+| `1` | A path does not exist, or nothing was found to lint |
+
+`3` rather than `1` for a bad policy is deliberate, and matches the rest of Tirith: the linter
+saying no about a policy is a verdict, not a tool failure. See [exit codes](exit-codes.md).
+
+## `tirith fmt`
+
+Format rewrites a policy into one canonical layout, so that two people writing the same policy
+produce the same bytes and a diff shows a change of meaning rather than a change of key order.
+
+It reorders keys and normalises whitespace. It never changes a value, adds a key, or reorders a
+list, and the result parses back to a document equal to the one it read.
+
+### Usage
+
+```
+tirith fmt [PATH ...] [--check] [--diff]
+```
+
+| Flag | What it does |
+|---|---|
+| `--check` | Do not write. Exit `3` if any file would change |
+| `--diff` | Print a unified diff of what would change. Implies `--check` |
+
+With no flags it rewrites the files in place and prints the path of each one it changed.
+
+### The layout
+
+Whitespace is exactly `json.dumps(indent=2, ensure_ascii=False)` plus one trailing newline, which
+means a policy written by any tool using the Python standard library is already canonical without
+knowing `fmt` exists. Non-ASCII characters stay as written rather than being escaped.
+
+Keys are ordered:
+
+| Object | Order |
+|---|---|
+| top level | `$schema`, `meta`, `evaluators`, `eval_expression` |
+| `meta` | `version`, `required_provider`, `id`, `name`, `description`, `severity`, `enforcement`, `tags`, `remediation` |
+| each evaluator | `id`, `description`, `provider_args`, `condition` |
+| `provider_args` | `operation_type` first, then the provider's own keys in their original order |
+| `condition` | `type`, `value`, `error_tolerance` |
+
+A key the document has that is not in these lists keeps its original relative order after the
+listed ones, so an unrecognised key is preserved rather than dropped or sorted somewhere
+surprising.
+
+### Exit codes
+
+| Code | Meaning |
+|---|---|
+| `0` | Nothing to change, or the changes were written |
+| `3` | `--check` found files that would change |
+| `1` | A path is missing, a file is not valid JSON, or nothing was found |
+
+## Which files they read
+
+Both commands take any number of paths, and both default to the same place when given none:
+`.tirith/policies` if that directory exists, otherwise the current directory.
+
+A directory is searched recursively for `*.json`. A **policy** is a JSON object with at least one
+of the three top-level keys the engine reads, `meta`, `evaluators` or `eval_expression`; anything
+else that parses as JSON is left alone. That is what lets you point either command at a directory
+that also holds a `package.json` or a plan document without producing findings about files that
+were never policies.
+
+- A JSON file named **explicitly on the command line** that is not a policy is reported as skipped
+ by name, so a typo'd path to a plan document does not read as a clean run.
+- A JSON file met **while walking a directory** that is not a policy is counted rather than
+ listed. Input documents living beside their policies are expected there.
+- A `.json` file inside a policy directory that does not parse at all is reported as an error, not
+ ignored. An unparseable policy is a defect.
+
+These directories are never descended into: `.git`, `.terraform`, `node_modules`, `__pycache__`,
+`.venv`, `venv`. Note that `.tirith` is deliberately not among them, since that is where policies
+conventionally live and skipping dot-directories wholesale would skip the one that matters.
+
+## In a pre-commit hook
+
+This repository publishes both commands as hooks, so a commit that touches one policy lints and
+formats that one policy:
+
+```yaml
+repos:
+ - repo: https://github.com/StackGuardian/tirith
+ rev: main
+ hooks:
+ - id: tirith-lint
+ - id: tirith-fmt
+```
+
+```bash
+pre-commit install
+pre-commit run --all-files
+```
+
+Both hooks match `.tirith/*.json` and `*.tirith.json` by default. Override `files` if your
+policies live somewhere else.
+
+Linting at commit time rather than evaluating is a deliberate choice. Evaluating needs a plan
+document, and producing one means running `terraform plan`, which is too slow for a commit hook
+and needs cloud credentials a hook has no business holding. See
+[CI integration](ci-integration.md) for the full argument, and for where evaluation belongs.
+
+## In CI
+
+`fmt --check` is the CI shape, next to whatever else already checks formatting:
+
+```bash
+tirith fmt --check .tirith/policies
+tirith lint .tirith/policies
+```
+
+Both exit `3` on a finding, so a pipeline that already distinguishes `3` from `1` treats a badly
+formatted or invalid policy exactly as it treats a policy violation, and a missing path as the
+tooling problem it is.
diff --git a/documentation/static/llms-full.txt b/documentation/static/llms-full.txt
index ac35d44f..a58713ed 100644
--- a/documentation/static/llms-full.txt
+++ b/documentation/static/llms-full.txt
@@ -2718,9 +2718,23 @@ tirith -policy-path policy.json -input-path plan.json
Run with no arguments, `tirith` prints its help text and exits `0`.
-There is one subcommand, `tirith platform check`, which evaluates against the policies a
-StackGuardian organization enforces instead of local files. It has its own flags and its own page:
-[Platform Check](platform-check.md).
+Four subcommands sit beside it, each with its own flags and its own page:
+
+| Subcommand | What it does |
+|---|---|
+| [`tirith lint`](lint-and-fmt.md) | Check policy files for mistakes that would gate nothing or fail as a false violation. No plan document needed |
+| [`tirith fmt`](lint-and-fmt.md) | Rewrite policy files into the canonical layout |
+| [`tirith ui`](interactive-interface.md) | Explore results, build policies and experiment in an interactive interface. Needs the optional `tui` extra |
+| [`tirith platform check`](platform-check.md) | Evaluate against the policies a StackGuardian organization enforces instead of local files |
+
+A first argument that is not one of those four and does not begin with a dash is rejected by name,
+because the flat command below takes no positional arguments:
+
+```console
+$ tirith lnit .tirith/policies
+tirith: 'lnit' is not a tirith command. Commands: fmt, lint, platform, ui.
+Run 'tirith --help' for the local-evaluation options.
+```
## Flags
@@ -2849,24 +2863,235 @@ Prints the version number (for example `1.2.0`) and exits `0`.
This split is deliberate so that redirecting stdout captures only the verdict.
+==============================================================================
+# Lint and format
+Source: https://stackguardian.github.io/tirith/docs/tirith-usage/lint-and-fmt/
+Summary: tirith lint checks policy files for mistakes that would gate nothing or fail as a false violation. tirith fmt rewrites them into one canonical layout. Neither needs a plan document.
+==============================================================================
+
+Two commands that read policy files and nothing else. Neither needs a plan document, an account,
+or a network call, which is what lets them run in a pre-commit hook and in a slim CI image without
+the optional `tui` extra.
+
+```bash
+tirith lint .tirith/policies # is the policy well formed?
+tirith fmt .tirith/policies # rewrite it into the canonical layout
+```
+
+[NOTE] Not in 1.2.0
+Both commands are on `main` and will arrive in the next release. To use them today, install from
+the branch rather than the `1.2.0` tag:
+
+```bash
+pip install "git+https://github.com/StackGuardian/tirith.git@main"
+```
+
+## `tirith lint`
+
+Lint checks the **shape**, not the meaning. It runs the same validator the
+[interactive interface](interactive-interface.md) runs on every keystroke, so the two never
+disagree, and it catches the class of mistake that otherwise reaches CI looking like a real
+infrastructure violation:
+
+- a condition `type` that does not exist, against the closed list of
+ [thirteen conditions](../tirith-reference/evaluators.md)
+- a `provider_args` key that belongs to a different provider than the one in
+ `meta.required_provider`
+- an evaluator that `eval_expression` never names, so it is computed and then discarded
+- `error_tolerance` placed on the evaluator instead of inside `condition`, where it is silently
+ ignored and the check still fails
+
+What lint cannot tell you is whether a well-formed policy matches anything. Only evaluating it
+against a document that *should* fail answers that:
+
+```bash
+tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error
+```
+
+### Usage
+
+```
+tirith lint [PATH ...] [--json] [--strict] [--quiet]
+```
+
+| Flag | What it does |
+|---|---|
+| `--json` | Print the report as a JSON document instead of text |
+| `--strict` | Treat warnings as errors when deciding the exit code |
+| `--quiet` | Print findings only, with no summary line and no skipped files |
+
+Findings are printed one per line, in a form an editor or a log scraper can parse:
+
+```
+.tirith/policies/s3.json:evaluators[0].condition: error: unknown condition type 'EqualTo'
+.tirith/policies/s3.json:eval_expression: warning: evaluator 'bucket_acl' is never referenced
+2 policies, 1 errors, 1 warnings
+```
+
+`--json` returns the same report structured, so an editor integration or another program does not
+have to parse the text:
+
+```json
+{
+ "files": [
+ {
+ "path": ".tirith/policies/s3.json",
+ "findings": [
+ {"severity": "error", "where": "evaluators[0].condition", "message": "unknown condition type 'EqualTo'"}
+ ]
+ }
+ ],
+ "skipped": [],
+ "missing": [],
+ "summary": {"policies": 2, "errors": 1, "warnings": 1, "ignored": 0},
+ "exit_status": 3
+}
+```
+
+### Exit codes
+
+| Code | Meaning |
+|---|---|
+| `0` | Every policy is clean |
+| `3` | A policy has an error-level finding, or a warning under `--strict` |
+| `1` | A path does not exist, or nothing was found to lint |
+
+`3` rather than `1` for a bad policy is deliberate, and matches the rest of Tirith: the linter
+saying no about a policy is a verdict, not a tool failure. See [exit codes](exit-codes.md).
+
+## `tirith fmt`
+
+Format rewrites a policy into one canonical layout, so that two people writing the same policy
+produce the same bytes and a diff shows a change of meaning rather than a change of key order.
+
+It reorders keys and normalises whitespace. It never changes a value, adds a key, or reorders a
+list, and the result parses back to a document equal to the one it read.
+
+### Usage
+
+```
+tirith fmt [PATH ...] [--check] [--diff]
+```
+
+| Flag | What it does |
+|---|---|
+| `--check` | Do not write. Exit `3` if any file would change |
+| `--diff` | Print a unified diff of what would change. Implies `--check` |
+
+With no flags it rewrites the files in place and prints the path of each one it changed.
+
+### The layout
+
+Whitespace is exactly `json.dumps(indent=2, ensure_ascii=False)` plus one trailing newline, which
+means a policy written by any tool using the Python standard library is already canonical without
+knowing `fmt` exists. Non-ASCII characters stay as written rather than being escaped.
+
+Keys are ordered:
+
+| Object | Order |
+|---|---|
+| top level | `$schema`, `meta`, `evaluators`, `eval_expression` |
+| `meta` | `version`, `required_provider`, `id`, `name`, `description`, `severity`, `enforcement`, `tags`, `remediation` |
+| each evaluator | `id`, `description`, `provider_args`, `condition` |
+| `provider_args` | `operation_type` first, then the provider's own keys in their original order |
+| `condition` | `type`, `value`, `error_tolerance` |
+
+A key the document has that is not in these lists keeps its original relative order after the
+listed ones, so an unrecognised key is preserved rather than dropped or sorted somewhere
+surprising.
+
+### Exit codes
+
+| Code | Meaning |
+|---|---|
+| `0` | Nothing to change, or the changes were written |
+| `3` | `--check` found files that would change |
+| `1` | A path is missing, a file is not valid JSON, or nothing was found |
+
+## Which files they read
+
+Both commands take any number of paths, and both default to the same place when given none:
+`.tirith/policies` if that directory exists, otherwise the current directory.
+
+A directory is searched recursively for `*.json`. A **policy** is a JSON object with at least one
+of the three top-level keys the engine reads, `meta`, `evaluators` or `eval_expression`; anything
+else that parses as JSON is left alone. That is what lets you point either command at a directory
+that also holds a `package.json` or a plan document without producing findings about files that
+were never policies.
+
+- A JSON file named **explicitly on the command line** that is not a policy is reported as skipped
+ by name, so a typo'd path to a plan document does not read as a clean run.
+- A JSON file met **while walking a directory** that is not a policy is counted rather than
+ listed. Input documents living beside their policies are expected there.
+- A `.json` file inside a policy directory that does not parse at all is reported as an error, not
+ ignored. An unparseable policy is a defect.
+
+These directories are never descended into: `.git`, `.terraform`, `node_modules`, `__pycache__`,
+`.venv`, `venv`. Note that `.tirith` is deliberately not among them, since that is where policies
+conventionally live and skipping dot-directories wholesale would skip the one that matters.
+
+## In a pre-commit hook
+
+This repository publishes both commands as hooks, so a commit that touches one policy lints and
+formats that one policy:
+
+```yaml
+repos:
+ - repo: https://github.com/StackGuardian/tirith
+ rev: main
+ hooks:
+ - id: tirith-lint
+ - id: tirith-fmt
+```
+
+```bash
+pre-commit install
+pre-commit run --all-files
+```
+
+Both hooks match `.tirith/*.json` and `*.tirith.json` by default. Override `files` if your
+policies live somewhere else.
+
+Linting at commit time rather than evaluating is a deliberate choice. Evaluating needs a plan
+document, and producing one means running `terraform plan`, which is too slow for a commit hook
+and needs cloud credentials a hook has no business holding. See
+[CI integration](ci-integration.md) for the full argument, and for where evaluation belongs.
+
+## In CI
+
+`fmt --check` is the CI shape, next to whatever else already checks formatting:
+
+```bash
+tirith fmt --check .tirith/policies
+tirith lint .tirith/policies
+```
+
+Both exit `3` on a finding, so a pipeline that already distinguishes `3` from `1` treats a badly
+formatted or invalid policy exactly as it treats a policy violation, and a missing path as the
+tooling problem it is.
+
+
==============================================================================
# Exit Codes
Source: https://stackguardian.github.io/tirith/docs/tirith-usage/exit-codes/
Summary: The complete Tirith exit-code contract, and how to gate a CI job on it.
==============================================================================
-Tirith's exit codes are a contract shared by both surfaces — local evaluation (`tirith`) and
-platform evaluation (`tirith platform check`) — so a caller scripting both only has to learn one
-vocabulary.
+Tirith's exit codes are one contract shared by every surface: local evaluation (`tirith`),
+platform evaluation (`tirith platform check`), and the two file commands, `tirith lint` and
+`tirith fmt`. A caller scripting more than one only has to learn a single vocabulary.
| Code | Meaning |
|---|---|
-| `0` | Policies passed, or nothing was in scope to gate on |
-| `1` | Tirith could not complete the evaluation — bad input, a policy it could not evaluate, unreachable API |
-| `2` | Timed out waiting for a StackGuardian run (`tirith platform check` only; local evaluation never produces it) |
-| `3` | A policy failed. Only with `--fail-on-error`, on either surface |
+| `0` | Policies passed, nothing was in scope to gate on, or the files were clean |
+| `1` | Tirith could not complete the run: bad input, a policy it could not evaluate, an unreachable API, a path that does not exist |
+| `3` | A policy said no. On the evaluation surfaces only with `--fail-on-error`; `lint` and `fmt` need no flag |
| `130` | Interrupted (Ctrl-C) |
+`2` is deliberately absent. It used to be a timeout code that nothing ever returned, and
+`argparse` already exits `2` of its own accord on a usage error, so a caller seeing `2` has
+passed a bad argument. Do not write a pipeline that branches on it.
+
## `3` is deliberately not `1`
`3` means a check ran and said no: your infrastructure violates a policy. `1` means Tirith could
@@ -2880,6 +3105,21 @@ Both surfaces **fail closed**: anything that leaves the verdict unknown exits no
of `--fail-on-error`. That flag governs policy verdicts, not tool health — a run that produced no
verdict must never look like a pass.
+## `tirith lint` and `tirith fmt`
+
+Both use the same three codes, and neither takes `--fail-on-error`: a linter that cannot fail is
+not a linter, and there is no existing green pipeline to protect because nothing has run them
+before.
+
+| Command | `0` | `3` | `1` |
+|---|---|---|---|
+| `tirith lint` | every policy is clean | a policy has an error-level finding, or a warning under `--strict` | a path is missing, or nothing was found to lint |
+| `tirith fmt` | nothing to change, or the changes were written | `--check` found files that would change | a path is missing, a file is not valid JSON, or nothing was found |
+
+"Nothing was found" being `1` rather than `0` is the same fail-closed rule the evaluation
+surfaces follow. A hook pointed at the wrong directory reports a problem instead of quietly
+passing every commit. See [lint and format](lint-and-fmt.md).
+
## Without `--fail-on-error`
The local command exits `0` whether the policy passed or failed, with the verdict in the output.
@@ -3119,11 +3359,17 @@ pipelines:
- step:
name: Policy gate
script:
- - pip install "git+https://github.com/StackGuardian/tirith.git@1.2.0"
- # - tirith lint .tirith/policies # in dev, not in 1.2.0
+ # @main rather than @1.2.0, because the lint step below is not in 1.2.0 yet.
+ - pip install "git+https://github.com/StackGuardian/tirith.git@main"
+ - tirith lint .tirith/policies
- tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error
```
+Linting first is cheap and it fails for a different reason than the gate does: a policy that is
+malformed never gets as far as disagreeing with your infrastructure. It needs no plan document,
+so it can also run in a job that has no cloud credentials at all. See
+[lint and format](lint-and-fmt.md).
+
A worked repository is at
[tirith-bitbucket-demo](https://bitbucket.org/__refeed__/tirith-bitbucket-demo).
@@ -3156,31 +3402,30 @@ exit and the two cases become one.
## As a pre-commit hook
-[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/).
+[NOTE] Not in 1.2.0
+`tirith lint` and `tirith fmt` are on `main` and arrive in the next release, so pin `rev` to a
+branch until then. Everything else on this page works on 1.2.0.
-Catch a broken policy before it is committed, let alone before CI runs it. Tirith will publish a
-`tirith-lint` hook:
+Catch a broken policy before it is committed, let alone before CI runs it. Tirith publishes a
+`tirith-lint` and a `tirith-fmt` hook:
```yaml
repos:
- repo: https://github.com/StackGuardian/tirith
- rev: 1.2.0
+ rev: main
hooks:
- id: tirith-lint
+ - id: tirith-fmt
```
```bash
pre-commit install
-pre-commit run tirith-lint --all-files
+pre-commit run --all-files
```
-The hook runs only when a file under `.tirith/policies/` or a `*.tirith.json` changes. It lints
-the policy directory rather than the individual changed files, because `tirith lint` takes a
-single path.
+Both hooks run only when a file under `.tirith/` or a `*.tirith.json` changes, and both are
+handed the individual changed files rather than the whole directory: `tirith lint` and
+`tirith fmt` each take any number of paths, so a commit touching one policy checks one policy.
[NOTE] Why linting and not evaluation
Evaluating a policy needs a plan document, and producing one means running `terraform plan` —
@@ -3327,26 +3572,17 @@ path the serving process can. Keep it on `localhost` unless you have a reason no
==============================================================================
# In your editor
Source: https://stackguardian.github.io/tirith/docs/tirith-usage/editor-and-local/
-Summary: In development — VS Code tasks, a pre-commit hook, and the local loop to use when an AI agent is drafting the policy. The lint half is not in the released package yet.
+Summary: VS Code tasks, a pre-commit hook, and the local loop to use when an AI agent is drafting the policy.
==============================================================================
-[WARNING] In development — the lint half has not shipped
-
-`tirith lint`, the pre-commit hook and the VS Code tasks below are **in development**. They are
-not in the released package: `pip install "git+https://github.com/StackGuardian/tirith.git@1.2.0"`
-gives you `tirith`, `tirith ui` and `tirith platform check`, and no `lint` subcommand. The
-`tirith-lint` hook id and the task file are not in the repository yet either.
-
-**What works today** is the second half of the loop — evaluating a policy against a document with
-`tirith -policy-path … -input-path … --fail-on-error`. That runs locally, offline, on any
-installed version.
-
-This page is published now so the shape is reviewable. Follow
-[the repository](https://github.com/StackGuardian/tirith) for the release, or
-[tell us what the loop is missing](https://github.com/StackGuardian/tirith/issues/new/choose).
+[NOTE] Not in 1.2.0
+`tirith lint` and `tirith fmt` are on `main` and will arrive in the next release. Until then,
+install from the branch rather than the tag:
+`pip install "git+https://github.com/StackGuardian/tirith.git@main"`. Evaluation, the second half
+of the loop, works on any installed version.
-CI is the last place a policy should fail. This page is about the loop before that — running
-Tirith on your own machine, while the code is still being written.
+CI is the last place a policy should fail. This page is about the loop before that, running
+Tirith on your own machine while the code is still being written.
It matters most when an agent is drafting the policy for you. Generated policy JSON is plausible
by construction: it parses, it looks right, and a policy that matches nothing is indistinguishable
@@ -3355,28 +3591,34 @@ from one that works until you evaluate it.
## The loop
```bash
-tirith lint .tirith/policies # shape — in dev
-tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error # meaning — ships
+tirith fmt .tirith/policies # layout
+tirith lint .tirith/policies # shape
+tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error # meaning
```
-Linting checks the **shape** — that every condition type exists, that no `provider_args` key
-belongs to a different provider, that `eval_expression` names every evaluator. Only evaluation
-checks the **meaning**.
+Linting checks the **shape**: that every condition type exists, that no `provider_args` key
+belongs to a different provider, that `eval_expression` names every evaluator, that
+`error_tolerance` is inside `condition` where the engine reads it. Only evaluation checks the
+**meaning**. [Lint and format](lint-and-fmt.md) covers both commands in full.
Run both against a document that *should* fail. A guardrail only ever seen passing is a guardrail
nobody has tested.
## VS Code tasks
-**In development.** The lint task below calls a subcommand the released package does not have; the
-evaluate task works today.
-
Drop this in `.vscode/tasks.json` and the loop becomes one keystroke:
```json
{
"version": "2.0.0",
"tasks": [
+ {
+ "label": "Tirith: format policies",
+ "type": "shell",
+ "command": "tirith fmt .tirith/policies",
+ "problemMatcher": [],
+ "group": "test"
+ },
{
"label": "Tirith: lint policies",
"type": "shell",
@@ -3397,29 +3639,29 @@ Drop this in `.vscode/tasks.json` and the loop becomes one keystroke:
```
`Tirith: check the plan` is the default test task, so **⇧⌘B** / **Ctrl+Shift+B** runs the whole
-loop. The complete file — including a task to refresh `plan.json` and one to open the result in
-the interactive explorer — is
-[`.vscode/tasks.json`](https://github.com/StackGuardian/tirith/blob/main/.vscode/tasks.json).
+loop: lint first, then evaluate, because `dependsOn` will not run the second if the first exits
+non-zero.
## Catch it at commit time
-**In development.** The `tirith-lint` hook id is not published yet, so this configuration will not
-resolve — `pre-commit` fails with an unknown hook rather than installing anything.
+Tirith publishes both commands as pre-commit hooks:
```yaml
repos:
- repo: https://github.com/StackGuardian/tirith
- rev: 1.2.0
+ rev: main
hooks:
- id: tirith-lint
+ - id: tirith-fmt
```
```bash
pre-commit install
```
-The hook runs only when a policy file changes, needs no network, and exits `3` when a policy has
-an error. See [CI integration](ci-integration.md) for why it lints rather than evaluates.
+Both run only on the policy files a commit actually touches, need no network, and exit `3` on a
+finding. See [CI integration](ci-integration.md) for why they lint rather than evaluate, and
+[lint and format](lint-and-fmt.md) for the flags and the files they read.
## When an agent is writing the policy
@@ -3474,9 +3716,10 @@ guess from a plausible-looking shape.
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, and they are picked up in any
-repository you copy them into. A session that is already running may not see a newly installed
+Three skills under `.claude/skills/`: `tirith-policies`, for writing policies,
+`tirith-standards`, for generating a policy set from a repository you already have, and
+`tirith-migrate`, 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 | |
@@ -3541,9 +3784,30 @@ 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 |
+## Standards from a repository you already have
+
+`tirith-standards` starts from the Terraform or OpenTofu you already run rather than from a
+catalogue. It reads the code to work out which resource types are in use and which conventions
+are already followed, proposes a set of standards with the count of resources that would pass and
+fail today, and writes one policy per rule: required tags, naming conventions, allowed regions,
+permitted resource types, size ceilings, encryption defaults.
+
+Two things in it are worth knowing even if you never install it, because they decide whether a
+generated policy is usable:
+
+- **The policies never see your HCL.** The skill reads `.tf` files only to decide what to write
+ about; the rules themselves evaluate `terraform show -json`, where variables are resolved,
+ modules are expanded into addressed resources, and a name that is not known until apply is
+ `null`. A rule about a variable or a module block reads nothing.
+- **A rule scoped to one resource type needs a guard.** With the default `error_tolerance` an
+ absent resource type is severity `1` and the check fails, so the rule is red on every plan that
+ does not touch that type. At `error_tolerance: 1` it is skipped instead, every check is skipped,
+ and `final_result` is `null`, which exits `1`. Neither is green. The skill's fix is a `count`
+ evaluator, which returns `0` for an absent type rather than erroring, combined with `||`.
+
## Migrating from Sentinel
-The second skill, `tirith-migrate`, is for teams with existing HashiCorp Sentinel policies. It is a
+The third 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
diff --git a/documentation/static/llms.txt b/documentation/static/llms.txt
index ae0d71e6..3b167a7e 100644
--- a/documentation/static/llms.txt
+++ b/documentation/static/llms.txt
@@ -8,6 +8,16 @@
Policies are data, not code: a policy is a JSON file, so it is diffable and reviewable by
anyone on the team. There is no rule DSL and no embedded language.
+## Minimal working example
+
+```bash
+terraform plan -out=tfplan -input=false
+terraform show -json tfplan > plan.json
+tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error
+```
+
+Without `--fail-on-error`, Tirith reports findings but does not fail the job.
+
## Facts an answer about Tirith usually gets wrong
- **Not on PyPI.** `pip install tirith` installs an unrelated project of the same name, and
@@ -17,12 +27,14 @@ anyone on the team. There is no rule DSL and no embedded language.
- **The interactive interface needs an extra**, and the same git URL:
`pip install 'py-tirith[tui] @ git+https://github.com/StackGuardian/tirith.git'`, then
`tirith ui`. Requires Python 3.9; Tirith itself supports 3.8 and newer.
-- **`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.
+- **`tirith lint` and `tirith fmt` exist, but not in 1.2.0.** They are on `main` and arrive in
+ the next release; install `@main` to use them today. The CLI dispatches four subcommands:
+ `lint`, `fmt`, `ui` and `platform`. Lint checks a policy's shape without a plan document; fmt
+ rewrites it into the canonical layout. Both exit `3` on a finding, with no `--fail-on-error`.
- **Exit codes are a contract, not a convention.** `0` passed, `3` a policy failed, `1`
Tirith could not reach a verdict. `3` is deliberately not `1`: a caller has to be able to page
- the platform team on one and the change author on the other. (`ERROR_TIMEOUT = 2` exists in
- `status.py` but is never returned; do not write a pipeline that branches on it.)
+ the platform team on one and the change author on the other. (`2` is not a Tirith code at all:
+ argparse exits `2` on a usage error, so a caller seeing it passed a bad argument.)
- **`final_result: null` is not a pass.** It means every check was skipped, so the policy
evaluated nothing, and it exits `1`. A check that could not run is reported as unevaluated
rather than as success.
@@ -35,18 +47,29 @@ anyone on the team. There is no rule DSL and no embedded language.
- **Thirteen condition types ship**: Equals, NotEquals, GreaterThan, GreaterThanEqualTo,
LessThan, LessThanEqualTo, Contains, NotContains, ContainedIn, NotContainedIn, IsEmpty,
IsNotEmpty, RegexMatch.
+- **The argument key differs per provider, and a wrong key is ignored rather than rejected.**
+ `terraform_plan` reads `terraform_resource_attribute` alongside `terraform_resource_type`;
+ `kubernetes` reads `attribute_path` alongside `kubernetes_kind`; `json` reads `key_path`;
+ `sg_workflow` reads `workflow_attribute`. Give an evaluator the wrong key and it reads
+ nothing and the check passes. This is the highest-cost mistake in the schema.
- **`error_tolerance` belongs inside the `condition` object**, not on the evaluator. Placed on
the evaluator it is silently ignored and the check still fails.
-
-## Minimal working example
-
-```bash
-terraform plan -out=tfplan -input=false
-terraform show -json tfplan > plan.json
-tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error
-```
-
-Without `--fail-on-error`, Tirith reports findings but does not fail the job.
+- **`error_tolerance` is a threshold, not a switch.** A problem of severity N fails the check
+ when `N > error_tolerance` and is skipped otherwise; the default is `0`. The plan provider
+ raises severity `0` for a resource being deleted, `1` for a resource type absent from the
+ plan, and `2` for an attribute absent from the resource. So the default skips deletes and
+ fails everything else.
+- **A policy scoped to one resource type has no green outcome on a plan that does not touch
+ that type.** At the default tolerance the absent type is severity `1`, the check fails, exit
+ `3`. At `error_tolerance: 1` it is skipped, so every check is skipped, `final_result` is
+ `null`, exit `1`. Guard it instead: `count` returns `0` for an absent type rather than
+ raising severity `1`, so an evaluator of
+ `{"operation_type": "count", "terraform_resource_type": T}` with `Equals: 0`, combined as
+ `eval_expression: "not_applicable || the_rule"`, passes cleanly on unrelated plans and still
+ fails on real violations.
+- **The pre-commit hooks are published.** `.pre-commit-hooks.yaml` carries `tirith-lint` and
+ `tirith-fmt`, both run on the policy files a commit touches. Pin `rev` to a branch until lint
+ and fmt are in a release.
## Fetching this documentation
@@ -67,8 +90,10 @@ instruction to run a policy against a document that should fail before calling i
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
+Three skills: tirith-policies (eleven markdown files and a worked example), tirith-standards
+(generating a policy set from an existing Terraform or OpenTofu repository, including the guard
+above) and tirith-migrate (Sentinel translation). 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.
@@ -103,6 +128,7 @@ documentation/static/skill.sh in the repository.
## Running it
- [CLI reference](https://stackguardian.github.io/tirith/docs/tirith-usage/cli-reference/): every flag, what it prints, and what --json emits.
+- [Lint and format](https://stackguardian.github.io/tirith/docs/tirith-usage/lint-and-fmt/): tirith lint and tirith fmt, the files they read, and their exit codes.
- [Exit codes](https://stackguardian.github.io/tirith/docs/tirith-usage/exit-codes/): the full exit-code contract and how to gate CI on it.
- [CI integration](https://stackguardian.github.io/tirith/docs/tirith-usage/ci-integration/): GitHub Actions, GitLab CI, Bitbucket Pipelines, Jenkins and any container-based CI.
- [The interactive interface](https://stackguardian.github.io/tirith/docs/tirith-usage/interactive-interface/): tirith ui, in beta.
@@ -118,4 +144,4 @@ documentation/static/skill.sh in the repository.
## Optional
- [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.
-- [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.
+- [In your editor](https://stackguardian.github.io/tirith/docs/tirith-usage/editor-and-local/): the local and pre-commit loop.
diff --git a/documentation/static/skill.sh b/documentation/static/skill.sh
index 261819c4..adea5170 100644
--- a/documentation/static/skill.sh
+++ b/documentation/static/skill.sh
@@ -8,8 +8,9 @@
# 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 two skills into .claude/skills/ -- tirith-policies (eleven markdown
-# files plus a worked example) and tirith-migrate (Sentinel-to-Tirith translation, with its
+# What it does: downloads three skills into .claude/skills/ -- tirith-policies (eleven markdown
+# files plus a worked example), tirith-standards (generating a policy set from an existing
+# Terraform or OpenTofu repository) 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.
@@ -30,11 +31,21 @@ PACK=".claude/skills/tirith-policies"
DEST="."
CURSOR=0
MIGRATE_PACK=".claude/skills/tirith-migrate"
+STANDARDS_PACK=".claude/skills/tirith-standards"
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-standards, relative to its own pack root. One path per line so the list stays diffable.
+STANDARDS_FILES="SKILL.md
+reference/standards.md
+examples/org-standards/README.md
+examples/org-standards/naming.json
+examples/org-standards/should-fail.json
+examples/org-standards/should-pass.json
+examples/org-standards/no-bucket.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
@@ -108,6 +119,11 @@ for f in $MIGRATE_FILES; do
mkdir -p "$TMP/migrate/$(dirname "$f")"
fetch "$MBASE/$f" "$TMP/migrate/$f"
done
+SBASE="https://raw.githubusercontent.com/$REPO/$REF/$STANDARDS_PACK"
+for f in $STANDARDS_FILES; do
+ mkdir -p "$TMP/standards/$(dirname "$f")"
+ fetch "$SBASE/$f" "$TMP/standards/$f"
+done
mkdir -p "$TARGET/reference" "$TARGET/$EXAMPLE"
cp "$TMP/SKILL.md" "$TARGET/SKILL.md"
@@ -124,7 +140,14 @@ for f in $MIGRATE_FILES; do
cp "$TMP/migrate/$f" "$MTARGET/$f"
done
+STARGET="$DEST/$STANDARDS_PACK"
+for f in $STANDARDS_FILES; do
+ mkdir -p "$STARGET/$(dirname "$f")"
+ cp "$TMP/standards/$f" "$STARGET/$f"
+done
+
printf 'Installed the Tirith skill: %s\n' "$TARGET"
+printf 'Installed the standards skill: %s\n' "$STARGET"
printf 'Installed the migration skill: %s\n' "$MTARGET"
if [ "$CURSOR" -eq 1 ]; then
From 8586bb02ae49b17bf49e478bace15badb2342508 Mon Sep 17 00:00:00 2001
From: Akash S
Date: Tue, 8 Sep 2026 15:15:26 +0530
Subject: [PATCH 12/13] tirith-migrate: Azure Policy as a second source
Akshat needs Azure Policy built-ins translated into Tirith for a demo in the Azure test
subscription. The migrate skill was built so a new source is a reference file plus verified
examples; this adds both.
reference/azure-policy.md leads with the two decisions that make the translation more than a
rename. Polarity: an Azure `if` describes the violation, so every leaf becomes its opposite and
`anyOf` in the violation is `&&` in the compliance test, while `allOf` over two attributes of one
resource is approximate (stricter) because Tirith cannot bind both tests to one resource. Target:
Terraform plan via the alias-to-azurerm crosswalk, or ARM template via the json provider and the
type-guard idiom. Then the operator table, effects (deny, audit, the remediation effects that are
not evaluation), modes (Indexed is error_tolerance 2 on location/tags), parameters (-var, and why
a type list cannot be one), and what to expect.
The crosswalk rows are attributes the tirith-policy-corpus evaluates end to end in its
Powerpipe terraform-azure translations, plus the azurerm renames (https_traffic_only_enabled,
allow_nested_items_to_be_public). The corpus also classified 713 Azure built-ins for the ARM
target: 376 translated, all approximate because the json provider scopes the document, and 110
blocked with taxonomy codes. reference/azure-policy-corpus.md lists all 486 by GUID and display
name so an agent looks a built-in up before translating it.
The corpus skipped exactly the built-ins a subscription is assigned first, because they are
parameter-driven and untyped: Allowed locations, Require a tag, Allowed and Not allowed resource
types, Allowed VM SKUs. examples/azure-policy/ translates each of those to the Terraform target,
plus Secure transfer (Terraform and ARM), an NSG rule as the approximate case, and Inherit a tag
(modify) refused in words. Every fixture was run: 19 plans, each exiting as its notes say. Two
engine facts were verified for them: `count` honours exclude_resource_types over `*`, which
makes an allow-list of resource types expressible, and `count` of an absent type is 0.
---
.claude/skills/tirith-migrate/SKILL.md | 14 +-
.../examples/azure-policy/README.md | 24 +
.../azure-policy/allowed-locations/notes.md | 16 +
.../allowed-locations/policy.json | 27 +
.../allowed-locations/should-fail.json | 99 ++++
.../allowed-locations/should-pass.json | 99 ++++
.../allowed-locations/source.json | 63 +++
.../allowed-locations/variables.json | 7 +
.../allowed-resource-types/notes.md | 17 +
.../allowed-resource-types/policy.json | 32 ++
.../allowed-resource-types/should-fail.json | 78 +++
.../allowed-resource-types/should-pass.json | 93 ++++
.../allowed-resource-types/source.json | 53 ++
.../azure-policy/allowed-vm-skus/notes.md | 13 +
.../azure-policy/allowed-vm-skus/policy.json | 52 ++
.../allowed-vm-skus/should-fail.json | 52 ++
.../allowed-vm-skus/should-pass.json | 74 +++
.../azure-policy/allowed-vm-skus/source.json | 47 ++
.../allowed-vm-skus/variables.json | 7 +
.../inherit-tag-from-resource-group/notes.md | 15 +
.../source.json | 56 ++
.../not-allowed-resource-types/notes.md | 15 +
.../not-allowed-resource-types/policy.json | 57 ++
.../should-fail.json | 74 +++
.../should-pass.json | 74 +++
.../not-allowed-resource-types/source.json | 58 ++
.../nsg-rdp-from-internet/diverges.json | 64 +++
.../nsg-rdp-from-internet/notes.md | 20 +
.../nsg-rdp-from-internet/policy.json | 31 ++
.../nsg-rdp-from-internet/should-fail.json | 35 ++
.../nsg-rdp-from-internet/should-pass.json | 64 +++
.../nsg-rdp-from-internet/source.json | 120 +++++
.../azure-policy/require-tag/notes.md | 17 +
.../azure-policy/require-tag/policy.json | 27 +
.../require-tag/should-fail-null-tags.json | 55 ++
.../azure-policy/require-tag/should-fail.json | 79 +++
.../azure-policy/require-tag/should-pass.json | 79 +++
.../azure-policy/require-tag/source.json | 36 ++
.../azure-policy/require-tag/variables.json | 3 +
.../storage-secure-transfer/arm/policy.json | 46 ++
.../arm/should-fail.json | 20 +
.../arm/should-pass-no-storage.json | 15 +
.../arm/should-pass.json | 20 +
.../storage-secure-transfer/notes.md | 24 +
.../storage-secure-transfer/policy.json | 41 ++
.../should-fail-azurerm3.json | 53 ++
.../storage-secure-transfer/should-fail.json | 53 ++
.../storage-secure-transfer/should-pass.json | 53 ++
.../storage-secure-transfer/source.json | 66 +++
.../reference/azure-policy-corpus.md | 504 ++++++++++++++++++
.../tirith-migrate/reference/azure-policy.md | 179 +++++++
.../docs/tirith-usage/agent-skills.md | 10 +-
.../static/docs/tirith-usage/agent-skills.md | 10 +-
documentation/static/llms-full.txt | 10 +-
documentation/static/skill.sh | 78 ++-
55 files changed, 3001 insertions(+), 27 deletions(-)
create mode 100644 .claude/skills/tirith-migrate/examples/azure-policy/README.md
create mode 100644 .claude/skills/tirith-migrate/examples/azure-policy/allowed-locations/notes.md
create mode 100644 .claude/skills/tirith-migrate/examples/azure-policy/allowed-locations/policy.json
create mode 100644 .claude/skills/tirith-migrate/examples/azure-policy/allowed-locations/should-fail.json
create mode 100644 .claude/skills/tirith-migrate/examples/azure-policy/allowed-locations/should-pass.json
create mode 100644 .claude/skills/tirith-migrate/examples/azure-policy/allowed-locations/source.json
create mode 100644 .claude/skills/tirith-migrate/examples/azure-policy/allowed-locations/variables.json
create mode 100644 .claude/skills/tirith-migrate/examples/azure-policy/allowed-resource-types/notes.md
create mode 100644 .claude/skills/tirith-migrate/examples/azure-policy/allowed-resource-types/policy.json
create mode 100644 .claude/skills/tirith-migrate/examples/azure-policy/allowed-resource-types/should-fail.json
create mode 100644 .claude/skills/tirith-migrate/examples/azure-policy/allowed-resource-types/should-pass.json
create mode 100644 .claude/skills/tirith-migrate/examples/azure-policy/allowed-resource-types/source.json
create mode 100644 .claude/skills/tirith-migrate/examples/azure-policy/allowed-vm-skus/notes.md
create mode 100644 .claude/skills/tirith-migrate/examples/azure-policy/allowed-vm-skus/policy.json
create mode 100644 .claude/skills/tirith-migrate/examples/azure-policy/allowed-vm-skus/should-fail.json
create mode 100644 .claude/skills/tirith-migrate/examples/azure-policy/allowed-vm-skus/should-pass.json
create mode 100644 .claude/skills/tirith-migrate/examples/azure-policy/allowed-vm-skus/source.json
create mode 100644 .claude/skills/tirith-migrate/examples/azure-policy/allowed-vm-skus/variables.json
create mode 100644 .claude/skills/tirith-migrate/examples/azure-policy/inherit-tag-from-resource-group/notes.md
create mode 100644 .claude/skills/tirith-migrate/examples/azure-policy/inherit-tag-from-resource-group/source.json
create mode 100644 .claude/skills/tirith-migrate/examples/azure-policy/not-allowed-resource-types/notes.md
create mode 100644 .claude/skills/tirith-migrate/examples/azure-policy/not-allowed-resource-types/policy.json
create mode 100644 .claude/skills/tirith-migrate/examples/azure-policy/not-allowed-resource-types/should-fail.json
create mode 100644 .claude/skills/tirith-migrate/examples/azure-policy/not-allowed-resource-types/should-pass.json
create mode 100644 .claude/skills/tirith-migrate/examples/azure-policy/not-allowed-resource-types/source.json
create mode 100644 .claude/skills/tirith-migrate/examples/azure-policy/nsg-rdp-from-internet/diverges.json
create mode 100644 .claude/skills/tirith-migrate/examples/azure-policy/nsg-rdp-from-internet/notes.md
create mode 100644 .claude/skills/tirith-migrate/examples/azure-policy/nsg-rdp-from-internet/policy.json
create mode 100644 .claude/skills/tirith-migrate/examples/azure-policy/nsg-rdp-from-internet/should-fail.json
create mode 100644 .claude/skills/tirith-migrate/examples/azure-policy/nsg-rdp-from-internet/should-pass.json
create mode 100644 .claude/skills/tirith-migrate/examples/azure-policy/nsg-rdp-from-internet/source.json
create mode 100644 .claude/skills/tirith-migrate/examples/azure-policy/require-tag/notes.md
create mode 100644 .claude/skills/tirith-migrate/examples/azure-policy/require-tag/policy.json
create mode 100644 .claude/skills/tirith-migrate/examples/azure-policy/require-tag/should-fail-null-tags.json
create mode 100644 .claude/skills/tirith-migrate/examples/azure-policy/require-tag/should-fail.json
create mode 100644 .claude/skills/tirith-migrate/examples/azure-policy/require-tag/should-pass.json
create mode 100644 .claude/skills/tirith-migrate/examples/azure-policy/require-tag/source.json
create mode 100644 .claude/skills/tirith-migrate/examples/azure-policy/require-tag/variables.json
create mode 100644 .claude/skills/tirith-migrate/examples/azure-policy/storage-secure-transfer/arm/policy.json
create mode 100644 .claude/skills/tirith-migrate/examples/azure-policy/storage-secure-transfer/arm/should-fail.json
create mode 100644 .claude/skills/tirith-migrate/examples/azure-policy/storage-secure-transfer/arm/should-pass-no-storage.json
create mode 100644 .claude/skills/tirith-migrate/examples/azure-policy/storage-secure-transfer/arm/should-pass.json
create mode 100644 .claude/skills/tirith-migrate/examples/azure-policy/storage-secure-transfer/notes.md
create mode 100644 .claude/skills/tirith-migrate/examples/azure-policy/storage-secure-transfer/policy.json
create mode 100644 .claude/skills/tirith-migrate/examples/azure-policy/storage-secure-transfer/should-fail-azurerm3.json
create mode 100644 .claude/skills/tirith-migrate/examples/azure-policy/storage-secure-transfer/should-fail.json
create mode 100644 .claude/skills/tirith-migrate/examples/azure-policy/storage-secure-transfer/should-pass.json
create mode 100644 .claude/skills/tirith-migrate/examples/azure-policy/storage-secure-transfer/source.json
create mode 100644 .claude/skills/tirith-migrate/reference/azure-policy-corpus.md
create mode 100644 .claude/skills/tirith-migrate/reference/azure-policy.md
diff --git a/.claude/skills/tirith-migrate/SKILL.md b/.claude/skills/tirith-migrate/SKILL.md
index 2d893fd3..f7e0d580 100644
--- a/.claude/skills/tirith-migrate/SKILL.md
+++ b/.claude/skills/tirith-migrate/SKILL.md
@@ -1,6 +1,6 @@
---
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.
+description: Translate existing policy-as-code into Tirith policies. HashiCorp Sentinel and Azure Policy 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, when given an Azure Policy definition, initiative or assignment (a policyRule with if/then), or when asked what a Sentinel or Azure policy would look like in Tirith. Requires the tirith-policies skill for the target vocabulary.
---
# Migrate policies to Tirith
@@ -22,14 +22,16 @@ page. Everything below assumes that vocabulary.
| Source | Reference | Status |
| --- | --- | --- |
| HashiCorp Sentinel | `reference/sentinel.md`, corpus in `reference/sentinel-corpus.md` | Measured against 110 public policies |
+| Azure Policy | `reference/azure-policy.md`, corpus in `reference/azure-policy-corpus.md` | 713 built-ins classified; Terraform and ARM targets |
| 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.
+1. **Inventory.** List every source policy. Read the policy-set manifest (`sentinel.hcl`, an
+ Azure initiative or assignment) for enforcement levels and parameter values. Note which
+ policies are registered twice with different parameters; they translate once. For Azure,
+ decide the target first: Terraform plan or ARM template.
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.
@@ -81,6 +83,10 @@ page. Everything below assumes that vocabulary.
## Worked examples
+`examples/azure-policy/` holds eight Azure built-ins, the ones a customer assigns first, each with
+the verbatim definition, the Tirith policy for a Terraform plan (one also for an ARM template), and
+the plans that prove it. `examples/azure-policy/README.md` is the index.
+
`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:
diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/README.md b/.claude/skills/tirith-migrate/examples/azure-policy/README.md
new file mode 100644
index 00000000..6db9531d
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/azure-policy/README.md
@@ -0,0 +1,24 @@
+# Azure Policy migrations
+
+Eight built-ins from `Azure/azure-policy`, chosen because they are the ones a customer assigns
+first and because the public corpus skipped them (parameter-driven, or untyped). Each directory has
+`source.json` (the definition, verbatim), `notes.md`, and for a translation `policy.json` with
+`should-fail.json` and `should-pass.json`. Parameterised ones add `variables.json`; approximate ones
+add `diverges.json`.
+
+```bash
+cd allowed-locations
+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
+```
+
+| Example | Effect, mode | Fidelity | Shows |
+| --- | --- | --- | --- |
+| `allowed-locations` | deny, Indexed | exact | `notIn` to `ContainedIn`; Indexed mode is `error_tolerance: 2`; a role assignment is skipped |
+| `require-tag` | deny, Indexed | exact | `tags['x'] exists false` to `Contains` on the map; `tags: null` fails |
+| `not-allowed-resource-types` | deny, All | exact | `count` `Equals 0` per forbidden type; one Azure type is three azurerm types |
+| `allowed-resource-types` | deny, Indexed | approximate | `count` over `*` with the allow-list excluded; non-Azure resources must be excluded by hand |
+| `allowed-vm-skus` | deny, Indexed | exact | `sku.name` is `size` or `vm_size`; three evaluators, `&&` |
+| `storage-secure-transfer` | audit, Indexed | exact | provider version rename handled with `\|\|`; an ARM version in `arm/` shows the json type guard |
+| `nsg-rdp-from-internet` | audit, All | approximate | four tests on one rule cannot be bound; `diverges.json` shows the stricter verdict |
+| `inherit-tag-from-resource-group` | modify | not expressible | refused in words |
diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/allowed-locations/notes.md b/.claude/skills/tirith-migrate/examples/azure-policy/allowed-locations/notes.md
new file mode 100644
index 00000000..d24caf02
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/azure-policy/allowed-locations/notes.md
@@ -0,0 +1,16 @@
+# Allowed locations: exact
+
+`if: allOf [location notIn P, location notEquals global, type notEquals b2cDirectories]`, effect from
+a parameter, mode Indexed. Violation when the location is outside the list and is not `global`.
+
+Compliance is `location ContainedIn P`. `global` is appended to the list in `variables.json`, which
+is what Azure's second leaf does. The third leaf exempts one type that has no Terraform resource.
+Indexed mode is `error_tolerance: 2` over `*`: a resource type with no `location`, the role
+assignment in both plans, is skipped rather than failed.
+
+| Plan | Azure | Tirith |
+| --- | --- | --- |
+| `should-fail.json` (a VM in eastus) | deny | exit 3 |
+| `should-pass.json` | pass | exit 0 |
+
+`listOfAllowedLocations` has no default in the definition. The value comes from the assignment.
diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/allowed-locations/policy.json b/.claude/skills/tirith-migrate/examples/azure-policy/allowed-locations/policy.json
new file mode 100644
index 00000000..65dd4e15
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/azure-policy/allowed-locations/policy.json
@@ -0,0 +1,27 @@
+{
+ "meta": {
+ "version": "v1",
+ "required_provider": "stackguardian/terraform_plan",
+ "id": "e56962a6-4747-49cd-b67b-bf8b01975c4c",
+ "name": "Allowed locations",
+ "description": "Every resource that has a location is deployed in one of listOfAllowedLocations. Azure exempts location 'global'; keep it in the list.",
+ "enforcement": "deny"
+ },
+ "evaluators": [
+ {
+ "id": "location_allowed",
+ "description": "location is in listOfAllowedLocations. error_tolerance 2 is Azure's Indexed mode: a resource type without a location is out of scope",
+ "provider_args": {
+ "operation_type": "attribute",
+ "terraform_resource_type": "*",
+ "terraform_resource_attribute": "location"
+ },
+ "condition": {
+ "type": "ContainedIn",
+ "value": "{{ var.listOfAllowedLocations }}",
+ "error_tolerance": 2
+ }
+ }
+ ],
+ "eval_expression": "location_allowed"
+}
diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/allowed-locations/should-fail.json b/.claude/skills/tirith-migrate/examples/azure-policy/allowed-locations/should-fail.json
new file mode 100644
index 00000000..99cdd59e
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/azure-policy/allowed-locations/should-fail.json
@@ -0,0 +1,99 @@
+{
+ "format_version": "1.2",
+ "terraform_version": "1.9.5",
+ "resource_changes": [
+ {
+ "address": "azurerm_resource_group.rg",
+ "mode": "managed",
+ "type": "azurerm_resource_group",
+ "name": "rg",
+ "provider_name": "registry.terraform.io/hashicorp/azurerm",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "name": "rg-app",
+ "location": "westeurope",
+ "tags": null
+ },
+ "after_unknown": {
+ "id": true
+ }
+ }
+ },
+ {
+ "address": "azurerm_storage_account.sa",
+ "mode": "managed",
+ "type": "azurerm_storage_account",
+ "name": "sa",
+ "provider_name": "registry.terraform.io/hashicorp/azurerm",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "name": "stapp001",
+ "location": "westeurope",
+ "resource_group_name": "rg-app",
+ "account_tier": "Standard",
+ "account_replication_type": "LRS",
+ "https_traffic_only_enabled": true,
+ "min_tls_version": "TLS1_2",
+ "tags": null
+ },
+ "after_unknown": {
+ "id": true
+ }
+ }
+ },
+ {
+ "address": "azurerm_linux_virtual_machine.vm",
+ "mode": "managed",
+ "type": "azurerm_linux_virtual_machine",
+ "name": "vm",
+ "provider_name": "registry.terraform.io/hashicorp/azurerm",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "name": "vm-app",
+ "location": "eastus",
+ "size": "Standard_B2s",
+ "resource_group_name": "rg-app",
+ "admin_username": "azureuser",
+ "tags": null,
+ "disable_password_authentication": true
+ },
+ "after_unknown": {
+ "id": true
+ }
+ }
+ },
+ {
+ "address": "azurerm_role_assignment.reader",
+ "mode": "managed",
+ "type": "azurerm_role_assignment",
+ "name": "reader",
+ "provider_name": "registry.terraform.io/hashicorp/azurerm",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "scope": "/subscriptions/x/resourceGroups/rg-app",
+ "role_definition_name": "Reader",
+ "principal_id": "00000000-0000-0000-0000-000000000000"
+ },
+ "after_unknown": {
+ "id": true
+ }
+ }
+ }
+ ]
+}
diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/allowed-locations/should-pass.json b/.claude/skills/tirith-migrate/examples/azure-policy/allowed-locations/should-pass.json
new file mode 100644
index 00000000..ac466296
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/azure-policy/allowed-locations/should-pass.json
@@ -0,0 +1,99 @@
+{
+ "format_version": "1.2",
+ "terraform_version": "1.9.5",
+ "resource_changes": [
+ {
+ "address": "azurerm_resource_group.rg",
+ "mode": "managed",
+ "type": "azurerm_resource_group",
+ "name": "rg",
+ "provider_name": "registry.terraform.io/hashicorp/azurerm",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "name": "rg-app",
+ "location": "westeurope",
+ "tags": null
+ },
+ "after_unknown": {
+ "id": true
+ }
+ }
+ },
+ {
+ "address": "azurerm_storage_account.sa",
+ "mode": "managed",
+ "type": "azurerm_storage_account",
+ "name": "sa",
+ "provider_name": "registry.terraform.io/hashicorp/azurerm",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "name": "stapp001",
+ "location": "westeurope",
+ "resource_group_name": "rg-app",
+ "account_tier": "Standard",
+ "account_replication_type": "LRS",
+ "https_traffic_only_enabled": true,
+ "min_tls_version": "TLS1_2",
+ "tags": null
+ },
+ "after_unknown": {
+ "id": true
+ }
+ }
+ },
+ {
+ "address": "azurerm_linux_virtual_machine.vm",
+ "mode": "managed",
+ "type": "azurerm_linux_virtual_machine",
+ "name": "vm",
+ "provider_name": "registry.terraform.io/hashicorp/azurerm",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "name": "vm-app",
+ "location": "westeurope",
+ "size": "Standard_B2s",
+ "resource_group_name": "rg-app",
+ "admin_username": "azureuser",
+ "tags": null,
+ "disable_password_authentication": true
+ },
+ "after_unknown": {
+ "id": true
+ }
+ }
+ },
+ {
+ "address": "azurerm_role_assignment.reader",
+ "mode": "managed",
+ "type": "azurerm_role_assignment",
+ "name": "reader",
+ "provider_name": "registry.terraform.io/hashicorp/azurerm",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "scope": "/subscriptions/x/resourceGroups/rg-app",
+ "role_definition_name": "Reader",
+ "principal_id": "00000000-0000-0000-0000-000000000000"
+ },
+ "after_unknown": {
+ "id": true
+ }
+ }
+ }
+ ]
+}
diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/allowed-locations/source.json b/.claude/skills/tirith-migrate/examples/azure-policy/allowed-locations/source.json
new file mode 100644
index 00000000..d5c08fe5
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/azure-policy/allowed-locations/source.json
@@ -0,0 +1,63 @@
+{
+ "properties": {
+ "displayName": "Allowed locations",
+ "policyType": "BuiltIn",
+ "mode": "Indexed",
+ "description": "This policy enables you to restrict the locations your organization can specify when deploying resources. Use to enforce your geo-compliance requirements. Excludes resource groups, Microsoft.AzureActiveDirectory/b2cDirectories, and resources that use the 'global' region.",
+ "metadata": {
+ "version": "1.1.0",
+ "category": "General"
+ },
+ "version": "1.1.0",
+ "parameters": {
+ "listOfAllowedLocations": {
+ "type": "Array",
+ "metadata": {
+ "description": "The list of locations that can be specified when deploying resources.",
+ "strongType": "location",
+ "displayName": "Allowed locations"
+ }
+ },
+ "effect": {
+ "type": "String",
+ "defaultValue": "Deny",
+ "allowedValues": [
+ "Audit",
+ "Deny",
+ "Disabled"
+ ],
+ "metadata": {
+ "displayName": "Effect",
+ "description": "Enable or disable the execution of the policy"
+ }
+ }
+ },
+ "policyRule": {
+ "if": {
+ "allOf": [
+ {
+ "field": "location",
+ "notIn": "[parameters('listOfAllowedLocations')]"
+ },
+ {
+ "field": "location",
+ "notEquals": "global"
+ },
+ {
+ "field": "type",
+ "notEquals": "Microsoft.AzureActiveDirectory/b2cDirectories"
+ }
+ ]
+ },
+ "then": {
+ "effect": "[parameters('effect')]"
+ }
+ },
+ "versions": [
+ "1.1.0",
+ "1.0.0"
+ ]
+ },
+ "id": "/providers/Microsoft.Authorization/policyDefinitions/e56962a6-4747-49cd-b67b-bf8b01975c4c",
+ "name": "e56962a6-4747-49cd-b67b-bf8b01975c4c"
+}
\ No newline at end of file
diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/allowed-locations/variables.json b/.claude/skills/tirith-migrate/examples/azure-policy/allowed-locations/variables.json
new file mode 100644
index 00000000..6a80e1de
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/azure-policy/allowed-locations/variables.json
@@ -0,0 +1,7 @@
+{
+ "listOfAllowedLocations": [
+ "westeurope",
+ "northeurope",
+ "global"
+ ]
+}
diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/allowed-resource-types/notes.md b/.claude/skills/tirith-migrate/examples/azure-policy/allowed-resource-types/notes.md
new file mode 100644
index 00000000..973e1bcc
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/azure-policy/allowed-resource-types/notes.md
@@ -0,0 +1,17 @@
+# Allowed resource types: approximate
+
+`if: not {type in P}`, mode Indexed. Violation when a resource's type is outside the allow-list.
+
+No operation yields a resource's type as a value, so the test is inverted onto `count`: over `*`
+with `exclude_resource_types` set to the allow-list, the count of everything else must be `0`
+(verified: `exclude_resource_types` is honoured by `count`).
+
+Approximate for two reasons. Azure sees only Azure resources; a Terraform plan also holds
+`random_id`, `null_resource`, data sources and other providers' resources, and each counts as a
+violation unless excluded by hand, as `random_id` is here. And the allow-list must name azurerm
+types, so one Azure type expands to several rows.
+
+| Plan | Azure | Tirith |
+| --- | --- | --- |
+| `should-fail.json` (a VM outside the list) | deny | exit 3 |
+| `should-pass.json` (with a `random_id`, excluded) | pass | exit 0 |
diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/allowed-resource-types/policy.json b/.claude/skills/tirith-migrate/examples/azure-policy/allowed-resource-types/policy.json
new file mode 100644
index 00000000..94ecf7e0
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/azure-policy/allowed-resource-types/policy.json
@@ -0,0 +1,32 @@
+{
+ "meta": {
+ "version": "v1",
+ "required_provider": "stackguardian/terraform_plan",
+ "id": "a08ec900-254a-4555-9bf5-e42af04b5c5c",
+ "name": "Allowed resource types",
+ "description": "Only the listed azurerm types appear in the plan. count over * with the allowed types excluded must be 0. Non-Azure resources (random_*, null_resource, data sources) count too; exclude them explicitly, Azure never sees them.",
+ "enforcement": "deny"
+ },
+ "evaluators": [
+ {
+ "id": "only_allowed_types",
+ "description": "Number of resources whose type is not in the allow-list",
+ "provider_args": {
+ "operation_type": "count",
+ "terraform_resource_type": "*",
+ "exclude_resource_types": [
+ "azurerm_resource_group",
+ "azurerm_storage_account",
+ "azurerm_storage_container",
+ "azurerm_role_assignment",
+ "random_id"
+ ]
+ },
+ "condition": {
+ "type": "Equals",
+ "value": 0
+ }
+ }
+ ],
+ "eval_expression": "only_allowed_types"
+}
diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/allowed-resource-types/should-fail.json b/.claude/skills/tirith-migrate/examples/azure-policy/allowed-resource-types/should-fail.json
new file mode 100644
index 00000000..c06a411e
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/azure-policy/allowed-resource-types/should-fail.json
@@ -0,0 +1,78 @@
+{
+ "format_version": "1.2",
+ "terraform_version": "1.9.5",
+ "resource_changes": [
+ {
+ "address": "azurerm_resource_group.rg",
+ "mode": "managed",
+ "type": "azurerm_resource_group",
+ "name": "rg",
+ "provider_name": "registry.terraform.io/hashicorp/azurerm",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "name": "rg-app",
+ "location": "westeurope",
+ "tags": null
+ },
+ "after_unknown": {
+ "id": true
+ }
+ }
+ },
+ {
+ "address": "azurerm_storage_account.sa",
+ "mode": "managed",
+ "type": "azurerm_storage_account",
+ "name": "sa",
+ "provider_name": "registry.terraform.io/hashicorp/azurerm",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "name": "stapp001",
+ "location": "westeurope",
+ "resource_group_name": "rg-app",
+ "account_tier": "Standard",
+ "account_replication_type": "LRS",
+ "https_traffic_only_enabled": true,
+ "min_tls_version": "TLS1_2",
+ "tags": null
+ },
+ "after_unknown": {
+ "id": true
+ }
+ }
+ },
+ {
+ "address": "azurerm_linux_virtual_machine.vm",
+ "mode": "managed",
+ "type": "azurerm_linux_virtual_machine",
+ "name": "vm",
+ "provider_name": "registry.terraform.io/hashicorp/azurerm",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "name": "vm-app",
+ "location": "westeurope",
+ "size": "Standard_B2s",
+ "resource_group_name": "rg-app",
+ "admin_username": "azureuser",
+ "tags": null,
+ "disable_password_authentication": true
+ },
+ "after_unknown": {
+ "id": true
+ }
+ }
+ }
+ ]
+}
diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/allowed-resource-types/should-pass.json b/.claude/skills/tirith-migrate/examples/azure-policy/allowed-resource-types/should-pass.json
new file mode 100644
index 00000000..ce500977
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/azure-policy/allowed-resource-types/should-pass.json
@@ -0,0 +1,93 @@
+{
+ "format_version": "1.2",
+ "terraform_version": "1.9.5",
+ "resource_changes": [
+ {
+ "address": "azurerm_resource_group.rg",
+ "mode": "managed",
+ "type": "azurerm_resource_group",
+ "name": "rg",
+ "provider_name": "registry.terraform.io/hashicorp/azurerm",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "name": "rg-app",
+ "location": "westeurope",
+ "tags": null
+ },
+ "after_unknown": {
+ "id": true
+ }
+ }
+ },
+ {
+ "address": "azurerm_storage_account.sa",
+ "mode": "managed",
+ "type": "azurerm_storage_account",
+ "name": "sa",
+ "provider_name": "registry.terraform.io/hashicorp/azurerm",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "name": "stapp001",
+ "location": "westeurope",
+ "resource_group_name": "rg-app",
+ "account_tier": "Standard",
+ "account_replication_type": "LRS",
+ "https_traffic_only_enabled": true,
+ "min_tls_version": "TLS1_2",
+ "tags": null
+ },
+ "after_unknown": {
+ "id": true
+ }
+ }
+ },
+ {
+ "address": "azurerm_role_assignment.reader",
+ "mode": "managed",
+ "type": "azurerm_role_assignment",
+ "name": "reader",
+ "provider_name": "registry.terraform.io/hashicorp/azurerm",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "scope": "/subscriptions/x/resourceGroups/rg-app",
+ "role_definition_name": "Reader",
+ "principal_id": "00000000-0000-0000-0000-000000000000"
+ },
+ "after_unknown": {
+ "id": true
+ }
+ }
+ },
+ {
+ "address": "random_id.suffix",
+ "mode": "managed",
+ "type": "random_id",
+ "name": "suffix",
+ "provider_name": "registry.terraform.io/hashicorp/azurerm",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "byte_length": 4
+ },
+ "after_unknown": {
+ "id": true
+ }
+ }
+ }
+ ]
+}
diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/allowed-resource-types/source.json b/.claude/skills/tirith-migrate/examples/azure-policy/allowed-resource-types/source.json
new file mode 100644
index 00000000..8662208a
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/azure-policy/allowed-resource-types/source.json
@@ -0,0 +1,53 @@
+{
+ "properties": {
+ "displayName": "Allowed resource types",
+ "policyType": "BuiltIn",
+ "mode": "Indexed",
+ "description": "This policy enables you to specify the resource types that your organization can deploy. Only resource types that support 'tags' and 'location' will be affected by this policy. To restrict all resources please duplicate this policy and change the 'mode' to 'All'.",
+ "metadata": {
+ "version": "1.1.0",
+ "category": "General"
+ },
+ "version": "1.1.0",
+ "parameters": {
+ "listOfResourceTypesAllowed": {
+ "type": "Array",
+ "metadata": {
+ "description": "The list of resource types that can be deployed.",
+ "displayName": "Allowed resource types",
+ "strongType": "resourceTypes"
+ }
+ },
+ "effect": {
+ "type": "String",
+ "defaultValue": "Deny",
+ "allowedValues": [
+ "Audit",
+ "Deny",
+ "Disabled"
+ ],
+ "metadata": {
+ "displayName": "Effect",
+ "description": "Enable or disable the execution of the policy"
+ }
+ }
+ },
+ "policyRule": {
+ "if": {
+ "not": {
+ "field": "type",
+ "in": "[parameters('listOfResourceTypesAllowed')]"
+ }
+ },
+ "then": {
+ "effect": "[parameters('effect')]"
+ }
+ },
+ "versions": [
+ "1.1.0",
+ "1.0.0"
+ ]
+ },
+ "id": "/providers/Microsoft.Authorization/policyDefinitions/a08ec900-254a-4555-9bf5-e42af04b5c5c",
+ "name": "a08ec900-254a-4555-9bf5-e42af04b5c5c"
+}
\ No newline at end of file
diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/allowed-vm-skus/notes.md b/.claude/skills/tirith-migrate/examples/azure-policy/allowed-vm-skus/notes.md
new file mode 100644
index 00000000..1a8f2930
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/azure-policy/allowed-vm-skus/notes.md
@@ -0,0 +1,13 @@
+# Allowed virtual machine size SKUs: exact
+
+`if: allOf [type equals Microsoft.Compute/virtualMachines, not {sku.name in P}]`, effect Deny.
+
+The type leaf is scope; the `not in` becomes `ContainedIn`. `sku.name` is `size` on
+`azurerm_linux_virtual_machine` and `azurerm_windows_virtual_machine` and `vm_size` on the legacy
+`azurerm_virtual_machine`: three evaluators joined with `&&`, each at `error_tolerance: 1` so a plan
+without one of the three types is not refused for it.
+
+| Plan | Azure | Tirith |
+| --- | --- | --- |
+| `should-fail.json` (Standard_E64s_v5) | deny | exit 3 |
+| `should-pass.json` (a Linux and a Windows VM, both allowed) | pass | exit 0 |
diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/allowed-vm-skus/policy.json b/.claude/skills/tirith-migrate/examples/azure-policy/allowed-vm-skus/policy.json
new file mode 100644
index 00000000..dd41ce46
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/azure-policy/allowed-vm-skus/policy.json
@@ -0,0 +1,52 @@
+{
+ "meta": {
+ "version": "v1",
+ "required_provider": "stackguardian/terraform_plan",
+ "id": "cccc23c7-8427-4f53-ad12-b6a63eb452b3",
+ "name": "Allowed virtual machine size SKUs",
+ "description": "Every virtual machine uses a size from listOfAllowedSKUs. sku.name is `size` on the linux and windows resources and `vm_size` on the legacy one.",
+ "enforcement": "deny"
+ },
+ "evaluators": [
+ {
+ "id": "linux_vm_size_allowed",
+ "provider_args": {
+ "operation_type": "attribute",
+ "terraform_resource_type": "azurerm_linux_virtual_machine",
+ "terraform_resource_attribute": "size"
+ },
+ "condition": {
+ "type": "ContainedIn",
+ "value": "{{ var.listOfAllowedSKUs }}",
+ "error_tolerance": 1
+ }
+ },
+ {
+ "id": "windows_vm_size_allowed",
+ "provider_args": {
+ "operation_type": "attribute",
+ "terraform_resource_type": "azurerm_windows_virtual_machine",
+ "terraform_resource_attribute": "size"
+ },
+ "condition": {
+ "type": "ContainedIn",
+ "value": "{{ var.listOfAllowedSKUs }}",
+ "error_tolerance": 1
+ }
+ },
+ {
+ "id": "legacy_vm_size_allowed",
+ "provider_args": {
+ "operation_type": "attribute",
+ "terraform_resource_type": "azurerm_virtual_machine",
+ "terraform_resource_attribute": "vm_size"
+ },
+ "condition": {
+ "type": "ContainedIn",
+ "value": "{{ var.listOfAllowedSKUs }}",
+ "error_tolerance": 1
+ }
+ }
+ ],
+ "eval_expression": "linux_vm_size_allowed && windows_vm_size_allowed && legacy_vm_size_allowed"
+}
diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/allowed-vm-skus/should-fail.json b/.claude/skills/tirith-migrate/examples/azure-policy/allowed-vm-skus/should-fail.json
new file mode 100644
index 00000000..1cf4e725
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/azure-policy/allowed-vm-skus/should-fail.json
@@ -0,0 +1,52 @@
+{
+ "format_version": "1.2",
+ "terraform_version": "1.9.5",
+ "resource_changes": [
+ {
+ "address": "azurerm_resource_group.rg",
+ "mode": "managed",
+ "type": "azurerm_resource_group",
+ "name": "rg",
+ "provider_name": "registry.terraform.io/hashicorp/azurerm",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "name": "rg-app",
+ "location": "westeurope",
+ "tags": null
+ },
+ "after_unknown": {
+ "id": true
+ }
+ }
+ },
+ {
+ "address": "azurerm_linux_virtual_machine.vm",
+ "mode": "managed",
+ "type": "azurerm_linux_virtual_machine",
+ "name": "vm",
+ "provider_name": "registry.terraform.io/hashicorp/azurerm",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "name": "vm-app",
+ "location": "westeurope",
+ "size": "Standard_E64s_v5",
+ "resource_group_name": "rg-app",
+ "admin_username": "azureuser",
+ "tags": null,
+ "disable_password_authentication": true
+ },
+ "after_unknown": {
+ "id": true
+ }
+ }
+ }
+ ]
+}
diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/allowed-vm-skus/should-pass.json b/.claude/skills/tirith-migrate/examples/azure-policy/allowed-vm-skus/should-pass.json
new file mode 100644
index 00000000..7d61efe1
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/azure-policy/allowed-vm-skus/should-pass.json
@@ -0,0 +1,74 @@
+{
+ "format_version": "1.2",
+ "terraform_version": "1.9.5",
+ "resource_changes": [
+ {
+ "address": "azurerm_resource_group.rg",
+ "mode": "managed",
+ "type": "azurerm_resource_group",
+ "name": "rg",
+ "provider_name": "registry.terraform.io/hashicorp/azurerm",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "name": "rg-app",
+ "location": "westeurope",
+ "tags": null
+ },
+ "after_unknown": {
+ "id": true
+ }
+ }
+ },
+ {
+ "address": "azurerm_linux_virtual_machine.vm",
+ "mode": "managed",
+ "type": "azurerm_linux_virtual_machine",
+ "name": "vm",
+ "provider_name": "registry.terraform.io/hashicorp/azurerm",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "name": "vm-app",
+ "location": "westeurope",
+ "size": "Standard_D2s_v5",
+ "resource_group_name": "rg-app",
+ "admin_username": "azureuser",
+ "tags": null,
+ "disable_password_authentication": true
+ },
+ "after_unknown": {
+ "id": true
+ }
+ }
+ },
+ {
+ "address": "azurerm_windows_virtual_machine.win",
+ "mode": "managed",
+ "type": "azurerm_windows_virtual_machine",
+ "name": "win",
+ "provider_name": "registry.terraform.io/hashicorp/azurerm",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "name": "vm-win",
+ "location": "westeurope",
+ "size": "Standard_B2s",
+ "admin_username": "azureuser"
+ },
+ "after_unknown": {
+ "id": true
+ }
+ }
+ }
+ ]
+}
diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/allowed-vm-skus/source.json b/.claude/skills/tirith-migrate/examples/azure-policy/allowed-vm-skus/source.json
new file mode 100644
index 00000000..a0de0170
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/azure-policy/allowed-vm-skus/source.json
@@ -0,0 +1,47 @@
+{
+ "properties": {
+ "displayName": "Allowed virtual machine size SKUs",
+ "policyType": "BuiltIn",
+ "mode": "Indexed",
+ "description": "This policy enables you to specify a set of virtual machine size SKUs that your organization can deploy.",
+ "metadata": {
+ "version": "1.0.1",
+ "category": "Compute"
+ },
+ "version": "1.0.1",
+ "parameters": {
+ "listOfAllowedSKUs": {
+ "type": "Array",
+ "metadata": {
+ "description": "The list of size SKUs that can be specified for virtual machines.",
+ "displayName": "Allowed Size SKUs",
+ "strongType": "VMSKUs"
+ }
+ }
+ },
+ "policyRule": {
+ "if": {
+ "allOf": [
+ {
+ "field": "type",
+ "equals": "Microsoft.Compute/virtualMachines"
+ },
+ {
+ "not": {
+ "field": "Microsoft.Compute/virtualMachines/sku.name",
+ "in": "[parameters('listOfAllowedSKUs')]"
+ }
+ }
+ ]
+ },
+ "then": {
+ "effect": "Deny"
+ }
+ },
+ "versions": [
+ "1.0.1"
+ ]
+ },
+ "id": "/providers/Microsoft.Authorization/policyDefinitions/cccc23c7-8427-4f53-ad12-b6a63eb452b3",
+ "name": "cccc23c7-8427-4f53-ad12-b6a63eb452b3"
+}
\ No newline at end of file
diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/allowed-vm-skus/variables.json b/.claude/skills/tirith-migrate/examples/azure-policy/allowed-vm-skus/variables.json
new file mode 100644
index 00000000..b0cec81a
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/azure-policy/allowed-vm-skus/variables.json
@@ -0,0 +1,7 @@
+{
+ "listOfAllowedSKUs": [
+ "Standard_B2s",
+ "Standard_D2s_v5",
+ "Standard_D4s_v5"
+ ]
+}
diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/inherit-tag-from-resource-group/notes.md b/.claude/skills/tirith-migrate/examples/azure-policy/inherit-tag-from-resource-group/notes.md
new file mode 100644
index 00000000..1a49711b
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/azure-policy/inherit-tag-from-resource-group/notes.md
@@ -0,0 +1,15 @@
+# Inherit a tag from the resource group if missing: not expressible
+
+**What it does.** Effect `modify`: when a resource lacks `tags[tagName]` and its resource group has
+that tag, Azure adds the tag to the resource at deployment time.
+
+**What Tirith cannot do.** Two things. It does not modify anything; it evaluates a document and
+returns a verdict. And the `if` reads `resourceGroup().tags[...]`, the tags of a *different*
+resource resolved at request time, which no attribute of the resource under evaluation carries.
+
+**What to say to the customer.** The evaluating half of the intent is `require-tag`: refuse a
+resource without the tag. The remediation half stays in Azure Policy, or moves into the Terraform
+module as a `default_tags`-style local merged into every resource. `modify`, `append`,
+`deployIfNotExists` and `denyAction` all land here.
+
+No `policy.json` is shipped on purpose.
diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/inherit-tag-from-resource-group/source.json b/.claude/skills/tirith-migrate/examples/azure-policy/inherit-tag-from-resource-group/source.json
new file mode 100644
index 00000000..24940247
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/azure-policy/inherit-tag-from-resource-group/source.json
@@ -0,0 +1,56 @@
+{
+ "properties": {
+ "displayName": "Inherit a tag from the resource group if missing",
+ "policyType": "BuiltIn",
+ "mode": "Indexed",
+ "description": "Adds the specified tag with its value from the parent resource group when any resource missing this tag is created or updated. Existing resources can be remediated by triggering a remediation task. If the tag exists with a different value it will not be changed.",
+ "metadata": {
+ "version": "1.0.0",
+ "category": "Tags"
+ },
+ "version": "1.0.0",
+ "parameters": {
+ "tagName": {
+ "type": "String",
+ "metadata": {
+ "displayName": "Tag Name",
+ "description": "Name of the tag, such as 'environment'"
+ }
+ }
+ },
+ "policyRule": {
+ "if": {
+ "allOf": [
+ {
+ "field": "[concat('tags[', parameters('tagName'), ']')]",
+ "exists": "false"
+ },
+ {
+ "value": "[resourceGroup().tags[parameters('tagName')]]",
+ "notEquals": ""
+ }
+ ]
+ },
+ "then": {
+ "effect": "modify",
+ "details": {
+ "roleDefinitionIds": [
+ "/providers/microsoft.authorization/roleDefinitions/b24988ac-6180-42a0-ab88-20f7382dd24c"
+ ],
+ "operations": [
+ {
+ "operation": "add",
+ "field": "[concat('tags[', parameters('tagName'), ']')]",
+ "value": "[resourceGroup().tags[parameters('tagName')]]"
+ }
+ ]
+ }
+ }
+ },
+ "versions": [
+ "1.0.0"
+ ]
+ },
+ "id": "/providers/Microsoft.Authorization/policyDefinitions/ea3f2387-9b95-492a-a190-fcdc54f7b070",
+ "name": "ea3f2387-9b95-492a-a190-fcdc54f7b070"
+}
\ No newline at end of file
diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/not-allowed-resource-types/notes.md b/.claude/skills/tirith-migrate/examples/azure-policy/not-allowed-resource-types/notes.md
new file mode 100644
index 00000000..eb7681d8
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/azure-policy/not-allowed-resource-types/notes.md
@@ -0,0 +1,15 @@
+# Not allowed resource types: exact
+
+`if: allOf [type in P, value "[field('type')]" exists true]`, mode All. The second leaf is an ARM
+expression that is always true; it exists to make the definition well-formed and is dropped.
+
+Compliance is "no resource of any forbidden type": operation `count` per type, `Equals 0`. `count`
+returns `0` for a type absent from the plan (verified), so no tolerance is needed. One Azure type is
+often several azurerm types: `Microsoft.Compute/virtualMachines` is `azurerm_virtual_machine`,
+`azurerm_linux_virtual_machine` and `azurerm_windows_virtual_machine`. A `-var` cannot feed
+`terraform_resource_type`, so the assignment's list is written out.
+
+| Plan | Azure | Tirith |
+| --- | --- | --- |
+| `should-fail.json` (a public IP) | deny | exit 3 |
+| `should-pass.json` | pass | exit 0 |
diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/not-allowed-resource-types/policy.json b/.claude/skills/tirith-migrate/examples/azure-policy/not-allowed-resource-types/policy.json
new file mode 100644
index 00000000..d28df568
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/azure-policy/not-allowed-resource-types/policy.json
@@ -0,0 +1,57 @@
+{
+ "meta": {
+ "version": "v1",
+ "required_provider": "stackguardian/terraform_plan",
+ "id": "6c112d4e-5bc7-47ae-a041-ea2d9dccd749",
+ "name": "Not allowed resource types",
+ "description": "No resource of a forbidden type is in the plan. Microsoft.Network/publicIPAddresses is azurerm_public_ip; Microsoft.Compute/virtualMachines is three azurerm types, one evaluator each.",
+ "enforcement": "deny"
+ },
+ "evaluators": [
+ {
+ "id": "no_public_ip",
+ "provider_args": {
+ "operation_type": "count",
+ "terraform_resource_type": "azurerm_public_ip"
+ },
+ "condition": {
+ "type": "Equals",
+ "value": 0
+ }
+ },
+ {
+ "id": "no_vm_legacy",
+ "provider_args": {
+ "operation_type": "count",
+ "terraform_resource_type": "azurerm_virtual_machine"
+ },
+ "condition": {
+ "type": "Equals",
+ "value": 0
+ }
+ },
+ {
+ "id": "no_vm_linux",
+ "provider_args": {
+ "operation_type": "count",
+ "terraform_resource_type": "azurerm_linux_virtual_machine"
+ },
+ "condition": {
+ "type": "Equals",
+ "value": 0
+ }
+ },
+ {
+ "id": "no_vm_windows",
+ "provider_args": {
+ "operation_type": "count",
+ "terraform_resource_type": "azurerm_windows_virtual_machine"
+ },
+ "condition": {
+ "type": "Equals",
+ "value": 0
+ }
+ }
+ ],
+ "eval_expression": "no_public_ip && no_vm_legacy && no_vm_linux && no_vm_windows"
+}
diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/not-allowed-resource-types/should-fail.json b/.claude/skills/tirith-migrate/examples/azure-policy/not-allowed-resource-types/should-fail.json
new file mode 100644
index 00000000..e215112d
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/azure-policy/not-allowed-resource-types/should-fail.json
@@ -0,0 +1,74 @@
+{
+ "format_version": "1.2",
+ "terraform_version": "1.9.5",
+ "resource_changes": [
+ {
+ "address": "azurerm_resource_group.rg",
+ "mode": "managed",
+ "type": "azurerm_resource_group",
+ "name": "rg",
+ "provider_name": "registry.terraform.io/hashicorp/azurerm",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "name": "rg-app",
+ "location": "westeurope",
+ "tags": null
+ },
+ "after_unknown": {
+ "id": true
+ }
+ }
+ },
+ {
+ "address": "azurerm_storage_account.sa",
+ "mode": "managed",
+ "type": "azurerm_storage_account",
+ "name": "sa",
+ "provider_name": "registry.terraform.io/hashicorp/azurerm",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "name": "stapp001",
+ "location": "westeurope",
+ "resource_group_name": "rg-app",
+ "account_tier": "Standard",
+ "account_replication_type": "LRS",
+ "https_traffic_only_enabled": true,
+ "min_tls_version": "TLS1_2",
+ "tags": null
+ },
+ "after_unknown": {
+ "id": true
+ }
+ }
+ },
+ {
+ "address": "azurerm_public_ip.pip",
+ "mode": "managed",
+ "type": "azurerm_public_ip",
+ "name": "pip",
+ "provider_name": "registry.terraform.io/hashicorp/azurerm",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "name": "pip-app",
+ "location": "westeurope",
+ "allocation_method": "Static"
+ },
+ "after_unknown": {
+ "id": true
+ }
+ }
+ }
+ ]
+}
diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/not-allowed-resource-types/should-pass.json b/.claude/skills/tirith-migrate/examples/azure-policy/not-allowed-resource-types/should-pass.json
new file mode 100644
index 00000000..8664bfe4
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/azure-policy/not-allowed-resource-types/should-pass.json
@@ -0,0 +1,74 @@
+{
+ "format_version": "1.2",
+ "terraform_version": "1.9.5",
+ "resource_changes": [
+ {
+ "address": "azurerm_resource_group.rg",
+ "mode": "managed",
+ "type": "azurerm_resource_group",
+ "name": "rg",
+ "provider_name": "registry.terraform.io/hashicorp/azurerm",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "name": "rg-app",
+ "location": "westeurope",
+ "tags": null
+ },
+ "after_unknown": {
+ "id": true
+ }
+ }
+ },
+ {
+ "address": "azurerm_storage_account.sa",
+ "mode": "managed",
+ "type": "azurerm_storage_account",
+ "name": "sa",
+ "provider_name": "registry.terraform.io/hashicorp/azurerm",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "name": "stapp001",
+ "location": "westeurope",
+ "resource_group_name": "rg-app",
+ "account_tier": "Standard",
+ "account_replication_type": "LRS",
+ "https_traffic_only_enabled": true,
+ "min_tls_version": "TLS1_2",
+ "tags": null
+ },
+ "after_unknown": {
+ "id": true
+ }
+ }
+ },
+ {
+ "address": "azurerm_role_assignment.reader",
+ "mode": "managed",
+ "type": "azurerm_role_assignment",
+ "name": "reader",
+ "provider_name": "registry.terraform.io/hashicorp/azurerm",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "scope": "/subscriptions/x/resourceGroups/rg-app",
+ "role_definition_name": "Reader",
+ "principal_id": "00000000-0000-0000-0000-000000000000"
+ },
+ "after_unknown": {
+ "id": true
+ }
+ }
+ }
+ ]
+}
diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/not-allowed-resource-types/source.json b/.claude/skills/tirith-migrate/examples/azure-policy/not-allowed-resource-types/source.json
new file mode 100644
index 00000000..fb0cb64e
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/azure-policy/not-allowed-resource-types/source.json
@@ -0,0 +1,58 @@
+{
+ "properties": {
+ "displayName": "Not allowed resource types",
+ "policyType": "BuiltIn",
+ "mode": "All",
+ "description": "Restrict which resource types can be deployed in your environment. Limiting resource types can reduce the complexity and attack surface of your environment while also helping to manage costs. Compliance results are only shown for non-compliant resources.",
+ "metadata": {
+ "version": "2.0.0",
+ "category": "General"
+ },
+ "version": "2.0.0",
+ "parameters": {
+ "listOfResourceTypesNotAllowed": {
+ "type": "Array",
+ "metadata": {
+ "description": "The list of resource types that cannot be deployed.",
+ "displayName": "Not allowed resource types",
+ "strongType": "resourceTypes"
+ }
+ },
+ "effect": {
+ "type": "string",
+ "defaultValue": "Deny",
+ "allowedValues": [
+ "Audit",
+ "Deny",
+ "Disabled"
+ ],
+ "metadata": {
+ "displayName": "Effect",
+ "description": "Enable or disable the execution of the policy"
+ }
+ }
+ },
+ "policyRule": {
+ "if": {
+ "allOf": [
+ {
+ "field": "type",
+ "in": "[parameters('listOfResourceTypesNotAllowed')]"
+ },
+ {
+ "value": "[field('type')]",
+ "exists": true
+ }
+ ]
+ },
+ "then": {
+ "effect": "[parameters('effect')]"
+ }
+ },
+ "versions": [
+ "2.0.0"
+ ]
+ },
+ "id": "/providers/Microsoft.Authorization/policyDefinitions/6c112d4e-5bc7-47ae-a041-ea2d9dccd749",
+ "name": "6c112d4e-5bc7-47ae-a041-ea2d9dccd749"
+}
\ No newline at end of file
diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/nsg-rdp-from-internet/diverges.json b/.claude/skills/tirith-migrate/examples/azure-policy/nsg-rdp-from-internet/diverges.json
new file mode 100644
index 00000000..f4e93c71
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/azure-policy/nsg-rdp-from-internet/diverges.json
@@ -0,0 +1,64 @@
+{
+ "format_version": "1.2",
+ "terraform_version": "1.9.5",
+ "resource_changes": [
+ {
+ "address": "azurerm_network_security_rule.allow-https-world",
+ "mode": "managed",
+ "type": "azurerm_network_security_rule",
+ "name": "allow-https-world",
+ "provider_name": "registry.terraform.io/hashicorp/azurerm",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "name": "allow-https-world",
+ "priority": 100,
+ "direction": "Inbound",
+ "access": "Allow",
+ "protocol": "Tcp",
+ "source_port_range": "*",
+ "destination_port_range": "443",
+ "source_address_prefix": "Internet",
+ "destination_address_prefix": "*",
+ "resource_group_name": "rg-app",
+ "network_security_group_name": "nsg-app"
+ },
+ "after_unknown": {
+ "id": true
+ }
+ }
+ },
+ {
+ "address": "azurerm_network_security_rule.allow-rdp-vpn",
+ "mode": "managed",
+ "type": "azurerm_network_security_rule",
+ "name": "allow-rdp-vpn",
+ "provider_name": "registry.terraform.io/hashicorp/azurerm",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "name": "allow-rdp-vpn",
+ "priority": 100,
+ "direction": "Inbound",
+ "access": "Allow",
+ "protocol": "Tcp",
+ "source_port_range": "*",
+ "destination_port_range": "3389",
+ "source_address_prefix": "10.0.0.0/8",
+ "destination_address_prefix": "*",
+ "resource_group_name": "rg-app",
+ "network_security_group_name": "nsg-app"
+ },
+ "after_unknown": {
+ "id": true
+ }
+ }
+ }
+ ]
+}
diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/nsg-rdp-from-internet/notes.md b/.claude/skills/tirith-migrate/examples/azure-policy/nsg-rdp-from-internet/notes.md
new file mode 100644
index 00000000..4212be9c
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/azure-policy/nsg-rdp-from-internet/notes.md
@@ -0,0 +1,20 @@
+# RDP access from the Internet should be blocked: approximate, stricter
+
+The definition (deprecated upstream, but the shape is the one every NSG policy has) fires when a
+`securityRules` resource has `access Allow` **and** `direction Inbound` **and** a destination port
+covering 3389 **and** a source that is the Internet, all on the same rule. The port test includes
+ARM expressions splitting `3000-4000` ranges and a `count`/`where` over `destinationPortRanges[*]`.
+
+Tirith evaluators are per resource and `eval_expression` combines whole-plan verdicts, so four
+tests cannot be bound to one rule (issue #316), and port-range arithmetic is not expressible
+(issue #338). The translation keeps the test that carries the intent: no rule's source is the
+Internet. Stricter: a rule opening 443 to the world is refused, where Azure passes it.
+
+| Plan | Azure | Tirith |
+| --- | --- | --- |
+| `should-fail.json` (RDP from Internet) | audit | exit 3 |
+| `should-pass.json` (RDP and HTTPS from the VPC only) | pass | exit 0 |
+| `diverges.json` (HTTPS from Internet, RDP internal) | **pass** | **exit 3** |
+
+If the team accepts "no inbound rule from the Internet at all" as the house rule, say so in the
+report and this becomes the policy. If they need the port-specific version, it waits on #316.
diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/nsg-rdp-from-internet/policy.json b/.claude/skills/tirith-migrate/examples/azure-policy/nsg-rdp-from-internet/policy.json
new file mode 100644
index 00000000..ba77d34d
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/azure-policy/nsg-rdp-from-internet/policy.json
@@ -0,0 +1,31 @@
+{
+ "meta": {
+ "version": "v1",
+ "required_provider": "stackguardian/terraform_plan",
+ "id": "e372f825-a257-4fb8-9175-797a8a8627d6",
+ "name": "RDP access from the Internet should be blocked",
+ "description": "Stricter than the source: Azure refuses a rule only when access, direction, port and source all match at once; Tirith cannot bind four tests to one rule, so any inbound allow rule from the Internet is refused whatever its port. Port ranges like 3000-4000 are not expressible.",
+ "enforcement": "audit"
+ },
+ "evaluators": [
+ {
+ "id": "rule_is_inbound_allow_from_internet",
+ "description": "Detects rules whose source is the Internet. With direction and access folded into the plan under review, this is the test that carries the intent",
+ "provider_args": {
+ "operation_type": "attribute",
+ "terraform_resource_type": "azurerm_network_security_rule",
+ "terraform_resource_attribute": "source_address_prefix"
+ },
+ "condition": {
+ "type": "NotContainedIn",
+ "value": [
+ "*",
+ "Internet",
+ "0.0.0.0/0"
+ ],
+ "error_tolerance": 1
+ }
+ }
+ ],
+ "eval_expression": "rule_is_inbound_allow_from_internet"
+}
diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/nsg-rdp-from-internet/should-fail.json b/.claude/skills/tirith-migrate/examples/azure-policy/nsg-rdp-from-internet/should-fail.json
new file mode 100644
index 00000000..c621a48a
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/azure-policy/nsg-rdp-from-internet/should-fail.json
@@ -0,0 +1,35 @@
+{
+ "format_version": "1.2",
+ "terraform_version": "1.9.5",
+ "resource_changes": [
+ {
+ "address": "azurerm_network_security_rule.allow-rdp",
+ "mode": "managed",
+ "type": "azurerm_network_security_rule",
+ "name": "allow-rdp",
+ "provider_name": "registry.terraform.io/hashicorp/azurerm",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "name": "allow-rdp",
+ "priority": 100,
+ "direction": "Inbound",
+ "access": "Allow",
+ "protocol": "Tcp",
+ "source_port_range": "*",
+ "destination_port_range": "3389",
+ "source_address_prefix": "Internet",
+ "destination_address_prefix": "*",
+ "resource_group_name": "rg-app",
+ "network_security_group_name": "nsg-app"
+ },
+ "after_unknown": {
+ "id": true
+ }
+ }
+ }
+ ]
+}
diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/nsg-rdp-from-internet/should-pass.json b/.claude/skills/tirith-migrate/examples/azure-policy/nsg-rdp-from-internet/should-pass.json
new file mode 100644
index 00000000..56bf1cc4
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/azure-policy/nsg-rdp-from-internet/should-pass.json
@@ -0,0 +1,64 @@
+{
+ "format_version": "1.2",
+ "terraform_version": "1.9.5",
+ "resource_changes": [
+ {
+ "address": "azurerm_network_security_rule.allow-rdp-vpn",
+ "mode": "managed",
+ "type": "azurerm_network_security_rule",
+ "name": "allow-rdp-vpn",
+ "provider_name": "registry.terraform.io/hashicorp/azurerm",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "name": "allow-rdp-vpn",
+ "priority": 100,
+ "direction": "Inbound",
+ "access": "Allow",
+ "protocol": "Tcp",
+ "source_port_range": "*",
+ "destination_port_range": "3389",
+ "source_address_prefix": "10.0.0.0/8",
+ "destination_address_prefix": "*",
+ "resource_group_name": "rg-app",
+ "network_security_group_name": "nsg-app"
+ },
+ "after_unknown": {
+ "id": true
+ }
+ }
+ },
+ {
+ "address": "azurerm_network_security_rule.allow-https",
+ "mode": "managed",
+ "type": "azurerm_network_security_rule",
+ "name": "allow-https",
+ "provider_name": "registry.terraform.io/hashicorp/azurerm",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "name": "allow-https",
+ "priority": 100,
+ "direction": "Inbound",
+ "access": "Allow",
+ "protocol": "Tcp",
+ "source_port_range": "*",
+ "destination_port_range": "443",
+ "source_address_prefix": "10.0.0.0/8",
+ "destination_address_prefix": "*",
+ "resource_group_name": "rg-app",
+ "network_security_group_name": "nsg-app"
+ },
+ "after_unknown": {
+ "id": true
+ }
+ }
+ }
+ ]
+}
diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/nsg-rdp-from-internet/source.json b/.claude/skills/tirith-migrate/examples/azure-policy/nsg-rdp-from-internet/source.json
new file mode 100644
index 00000000..84247487
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/azure-policy/nsg-rdp-from-internet/source.json
@@ -0,0 +1,120 @@
+{
+ "properties": {
+ "displayName": "[Deprecated]: RDP access from the Internet should be blocked",
+ "policyType": "BuiltIn",
+ "mode": "All",
+ "description": "This policy is deprecated. This policy audits any network security rule that allows RDP access from Internet",
+ "metadata": {
+ "version": "2.0.0-deprecated",
+ "category": "Network",
+ "deprecated": true
+ },
+ "version": "2.0.0",
+ "parameters": {
+ "effect": {
+ "type": "string",
+ "defaultValue": "Audit",
+ "allowedValues": [
+ "Audit",
+ "Disabled"
+ ],
+ "metadata": {
+ "displayName": "Effect",
+ "description": "Enable or disable the execution of the policy"
+ }
+ }
+ },
+ "policyRule": {
+ "if": {
+ "allOf": [
+ {
+ "field": "type",
+ "equals": "Microsoft.Network/networkSecurityGroups/securityRules"
+ },
+ {
+ "allOf": [
+ {
+ "field": "Microsoft.Network/networkSecurityGroups/securityRules/access",
+ "equals": "Allow"
+ },
+ {
+ "field": "Microsoft.Network/networkSecurityGroups/securityRules/direction",
+ "equals": "Inbound"
+ },
+ {
+ "anyOf": [
+ {
+ "field": "Microsoft.Network/networkSecurityGroups/securityRules/destinationPortRange",
+ "equals": "*"
+ },
+ {
+ "field": "Microsoft.Network/networkSecurityGroups/securityRules/destinationPortRange",
+ "equals": "3389"
+ },
+ {
+ "value": "[if(and(not(empty(field('Microsoft.Network/networkSecurityGroups/securityRules/destinationPortRange'))), contains(field('Microsoft.Network/networkSecurityGroups/securityRules/destinationPortRange'),'-')), and(lessOrEquals(int(first(split(field('Microsoft.Network/networkSecurityGroups/securityRules/destinationPortRange'), '-'))),3389),greaterOrEquals(int(last(split(field('Microsoft.Network/networkSecurityGroups/securityRules/destinationPortRange'), '-'))),3389)), 'false')]",
+ "equals": "true"
+ },
+ {
+ "count": {
+ "field": "Microsoft.Network/networkSecurityGroups/securityRules/destinationPortRanges[*]",
+ "where": {
+ "value": "[if(and(not(empty(first(field('Microsoft.Network/networkSecurityGroups/securityRules/destinationPortRanges[*]')))), contains(first(field('Microsoft.Network/networkSecurityGroups/securityRules/destinationPortRanges[*]')),'-')), and(lessOrEquals(int(first(split(first(field('Microsoft.Network/networkSecurityGroups/securityRules/destinationPortRanges[*]')), '-'))),3389),greaterOrEquals(int(last(split(first(field('Microsoft.Network/networkSecurityGroups/securityRules/destinationPortRanges[*]')), '-'))),3389)) , 'false')]",
+ "equals": "true"
+ }
+ },
+ "greater": 0
+ },
+ {
+ "not": {
+ "field": "Microsoft.Network/networkSecurityGroups/securityRules/destinationPortRanges[*]",
+ "notEquals": "*"
+ }
+ },
+ {
+ "not": {
+ "field": "Microsoft.Network/networkSecurityGroups/securityRules/destinationPortRanges[*]",
+ "notEquals": "3389"
+ }
+ }
+ ]
+ },
+ {
+ "anyOf": [
+ {
+ "field": "Microsoft.Network/networkSecurityGroups/securityRules/sourceAddressPrefix",
+ "equals": "*"
+ },
+ {
+ "field": "Microsoft.Network/networkSecurityGroups/securityRules/sourceAddressPrefix",
+ "equals": "Internet"
+ },
+ {
+ "not": {
+ "field": "Microsoft.Network/networkSecurityGroups/securityRules/sourceAddressPrefixes[*]",
+ "notEquals": "*"
+ }
+ },
+ {
+ "not": {
+ "field": "Microsoft.Network/networkSecurityGroups/securityRules/sourceAddressPrefixes[*]",
+ "notEquals": "Internet"
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ "then": {
+ "effect": "[parameters('effect')]"
+ }
+ },
+ "versions": [
+ "2.0.0"
+ ]
+ },
+ "id": "/providers/Microsoft.Authorization/policyDefinitions/e372f825-a257-4fb8-9175-797a8a8627d6",
+ "name": "e372f825-a257-4fb8-9175-797a8a8627d6"
+}
\ No newline at end of file
diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/require-tag/notes.md b/.claude/skills/tirith-migrate/examples/azure-policy/require-tag/notes.md
new file mode 100644
index 00000000..0a52c821
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/azure-policy/require-tag/notes.md
@@ -0,0 +1,17 @@
+# Require a tag on resources: exact
+
+`if: field "[concat('tags[', parameters('tagName'), ']')]" exists false`, effect deny, mode Indexed.
+
+Compliance is the key's presence in the tags map: `Contains {{ var.tagName }}` on `tags`, which
+tests keys on a map (verified). Indexed mode is `error_tolerance: 2`: a type with no `tags`
+attribute at all (the role assignment) is skipped.
+
+A resource that supports tags and sets none has `"tags": null` in the plan, present but null.
+`Contains` on null fails as an unsupported type, so the untagged resource is refused, which is
+Azure's verdict too. `should-fail-null-tags.json` proves it.
+
+| Plan | Azure | Tirith |
+| --- | --- | --- |
+| `should-fail.json` (storage account lacks CostCenter) | deny | exit 3 |
+| `should-fail-null-tags.json` (storage account has no tags at all) | deny | exit 3 |
+| `should-pass.json` | pass | exit 0 |
diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/require-tag/policy.json b/.claude/skills/tirith-migrate/examples/azure-policy/require-tag/policy.json
new file mode 100644
index 00000000..de3115b7
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/azure-policy/require-tag/policy.json
@@ -0,0 +1,27 @@
+{
+ "meta": {
+ "version": "v1",
+ "required_provider": "stackguardian/terraform_plan",
+ "id": "871b6d14-10aa-478d-b590-94f262ecfa99",
+ "name": "Require a tag on resources",
+ "description": "Every resource that supports tags carries the tag named by tagName. Azure evaluates tags[''] exists; Tirith tests the key of the tags map.",
+ "enforcement": "deny"
+ },
+ "evaluators": [
+ {
+ "id": "tag_present",
+ "description": "tags map contains the key tagName. error_tolerance 2 is Indexed mode: a type with no tags attribute is out of scope. A resource that supports tags but sets none has tags: null in the plan, which Contains fails, so the untagged resource is refused as Azure would refuse it",
+ "provider_args": {
+ "operation_type": "attribute",
+ "terraform_resource_type": "*",
+ "terraform_resource_attribute": "tags"
+ },
+ "condition": {
+ "type": "Contains",
+ "value": "{{ var.tagName }}",
+ "error_tolerance": 2
+ }
+ }
+ ],
+ "eval_expression": "tag_present"
+}
diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/require-tag/should-fail-null-tags.json b/.claude/skills/tirith-migrate/examples/azure-policy/require-tag/should-fail-null-tags.json
new file mode 100644
index 00000000..08503de7
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/azure-policy/require-tag/should-fail-null-tags.json
@@ -0,0 +1,55 @@
+{
+ "format_version": "1.2",
+ "terraform_version": "1.9.5",
+ "resource_changes": [
+ {
+ "address": "azurerm_resource_group.rg",
+ "mode": "managed",
+ "type": "azurerm_resource_group",
+ "name": "rg",
+ "provider_name": "registry.terraform.io/hashicorp/azurerm",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "name": "rg-app",
+ "location": "westeurope",
+ "tags": {
+ "CostCenter": "cc-42"
+ }
+ },
+ "after_unknown": {
+ "id": true
+ }
+ }
+ },
+ {
+ "address": "azurerm_storage_account.sa",
+ "mode": "managed",
+ "type": "azurerm_storage_account",
+ "name": "sa",
+ "provider_name": "registry.terraform.io/hashicorp/azurerm",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "name": "stapp001",
+ "location": "westeurope",
+ "resource_group_name": "rg-app",
+ "account_tier": "Standard",
+ "account_replication_type": "LRS",
+ "https_traffic_only_enabled": true,
+ "min_tls_version": "TLS1_2",
+ "tags": null
+ },
+ "after_unknown": {
+ "id": true
+ }
+ }
+ }
+ ]
+}
diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/require-tag/should-fail.json b/.claude/skills/tirith-migrate/examples/azure-policy/require-tag/should-fail.json
new file mode 100644
index 00000000..10382d4f
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/azure-policy/require-tag/should-fail.json
@@ -0,0 +1,79 @@
+{
+ "format_version": "1.2",
+ "terraform_version": "1.9.5",
+ "resource_changes": [
+ {
+ "address": "azurerm_resource_group.rg",
+ "mode": "managed",
+ "type": "azurerm_resource_group",
+ "name": "rg",
+ "provider_name": "registry.terraform.io/hashicorp/azurerm",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "name": "rg-app",
+ "location": "westeurope",
+ "tags": {
+ "CostCenter": "cc-42",
+ "Owner": "platform"
+ }
+ },
+ "after_unknown": {
+ "id": true
+ }
+ }
+ },
+ {
+ "address": "azurerm_storage_account.sa",
+ "mode": "managed",
+ "type": "azurerm_storage_account",
+ "name": "sa",
+ "provider_name": "registry.terraform.io/hashicorp/azurerm",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "name": "stapp001",
+ "location": "westeurope",
+ "resource_group_name": "rg-app",
+ "account_tier": "Standard",
+ "account_replication_type": "LRS",
+ "https_traffic_only_enabled": true,
+ "min_tls_version": "TLS1_2",
+ "tags": {
+ "Owner": "platform"
+ }
+ },
+ "after_unknown": {
+ "id": true
+ }
+ }
+ },
+ {
+ "address": "azurerm_role_assignment.reader",
+ "mode": "managed",
+ "type": "azurerm_role_assignment",
+ "name": "reader",
+ "provider_name": "registry.terraform.io/hashicorp/azurerm",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "scope": "/subscriptions/x/resourceGroups/rg-app",
+ "role_definition_name": "Reader",
+ "principal_id": "00000000-0000-0000-0000-000000000000"
+ },
+ "after_unknown": {
+ "id": true
+ }
+ }
+ }
+ ]
+}
diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/require-tag/should-pass.json b/.claude/skills/tirith-migrate/examples/azure-policy/require-tag/should-pass.json
new file mode 100644
index 00000000..178a5663
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/azure-policy/require-tag/should-pass.json
@@ -0,0 +1,79 @@
+{
+ "format_version": "1.2",
+ "terraform_version": "1.9.5",
+ "resource_changes": [
+ {
+ "address": "azurerm_resource_group.rg",
+ "mode": "managed",
+ "type": "azurerm_resource_group",
+ "name": "rg",
+ "provider_name": "registry.terraform.io/hashicorp/azurerm",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "name": "rg-app",
+ "location": "westeurope",
+ "tags": {
+ "CostCenter": "cc-42"
+ }
+ },
+ "after_unknown": {
+ "id": true
+ }
+ }
+ },
+ {
+ "address": "azurerm_storage_account.sa",
+ "mode": "managed",
+ "type": "azurerm_storage_account",
+ "name": "sa",
+ "provider_name": "registry.terraform.io/hashicorp/azurerm",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "name": "stapp001",
+ "location": "westeurope",
+ "resource_group_name": "rg-app",
+ "account_tier": "Standard",
+ "account_replication_type": "LRS",
+ "https_traffic_only_enabled": true,
+ "min_tls_version": "TLS1_2",
+ "tags": {
+ "CostCenter": "cc-42",
+ "Owner": "platform"
+ }
+ },
+ "after_unknown": {
+ "id": true
+ }
+ }
+ },
+ {
+ "address": "azurerm_role_assignment.reader",
+ "mode": "managed",
+ "type": "azurerm_role_assignment",
+ "name": "reader",
+ "provider_name": "registry.terraform.io/hashicorp/azurerm",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "scope": "/subscriptions/x/resourceGroups/rg-app",
+ "role_definition_name": "Reader",
+ "principal_id": "00000000-0000-0000-0000-000000000000"
+ },
+ "after_unknown": {
+ "id": true
+ }
+ }
+ }
+ ]
+}
diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/require-tag/source.json b/.claude/skills/tirith-migrate/examples/azure-policy/require-tag/source.json
new file mode 100644
index 00000000..e61c0afa
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/azure-policy/require-tag/source.json
@@ -0,0 +1,36 @@
+{
+ "properties": {
+ "displayName": "Require a tag on resources",
+ "policyType": "BuiltIn",
+ "mode": "Indexed",
+ "description": "Enforces existence of a tag. Does not apply to resource groups.",
+ "metadata": {
+ "version": "1.0.1",
+ "category": "Tags"
+ },
+ "version": "1.0.1",
+ "parameters": {
+ "tagName": {
+ "type": "String",
+ "metadata": {
+ "displayName": "Tag Name",
+ "description": "Name of the tag, such as 'environment'"
+ }
+ }
+ },
+ "policyRule": {
+ "if": {
+ "field": "[concat('tags[', parameters('tagName'), ']')]",
+ "exists": "false"
+ },
+ "then": {
+ "effect": "deny"
+ }
+ },
+ "versions": [
+ "1.0.1"
+ ]
+ },
+ "id": "/providers/Microsoft.Authorization/policyDefinitions/871b6d14-10aa-478d-b590-94f262ecfa99",
+ "name": "871b6d14-10aa-478d-b590-94f262ecfa99"
+}
\ No newline at end of file
diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/require-tag/variables.json b/.claude/skills/tirith-migrate/examples/azure-policy/require-tag/variables.json
new file mode 100644
index 00000000..5dfb8338
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/azure-policy/require-tag/variables.json
@@ -0,0 +1,3 @@
+{
+ "tagName": "CostCenter"
+}
diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/storage-secure-transfer/arm/policy.json b/.claude/skills/tirith-migrate/examples/azure-policy/storage-secure-transfer/arm/policy.json
new file mode 100644
index 00000000..8061da63
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/azure-policy/storage-secure-transfer/arm/policy.json
@@ -0,0 +1,46 @@
+{
+ "meta": {
+ "version": "v1",
+ "required_provider": "stackguardian/json",
+ "id": "404c3081-a854-4457-ae30-26a93ef643f9",
+ "name": "Secure transfer to storage accounts should be enabled (ARM template)",
+ "description": "The json provider reads the whole ARM template. The guard passes a template with no storage account; otherwise every storage account's supportsHttpsTrafficOnly must be true and an absent property fails, as Azure's `notEquals true` does.",
+ "enforcement": "audit"
+ },
+ "evaluators": [
+ {
+ "id": "no_storage_account",
+ "description": "Document-scope guard: no Microsoft.Storage/storageAccounts in the template. ARM type strings are case-insensitive in Azure and compared exactly here, so common spellings are listed",
+ "provider_args": {
+ "operation_type": "get_value",
+ "key_path": "resources.*.type"
+ },
+ "condition": {
+ "type": "NotContainedIn",
+ "value": [
+ "Microsoft.Storage/storageAccounts",
+ "microsoft.storage/storageaccounts",
+ "Microsoft.Storage/StorageAccounts"
+ ],
+ "error_tolerance": 2
+ }
+ },
+ {
+ "id": "https_only",
+ "description": "Every storage account's supportsHttpsTrafficOnly is true; absent fails (error_tolerance 1)",
+ "provider_args": {
+ "operation_type": "get_value",
+ "key_path": "resources.*.properties.supportsHttpsTrafficOnly"
+ },
+ "condition": {
+ "type": "ContainedIn",
+ "value": [
+ true,
+ "true"
+ ],
+ "error_tolerance": 1
+ }
+ }
+ ],
+ "eval_expression": "no_storage_account || https_only"
+}
diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/storage-secure-transfer/arm/should-fail.json b/.claude/skills/tirith-migrate/examples/azure-policy/storage-secure-transfer/arm/should-fail.json
new file mode 100644
index 00000000..aae1f194
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/azure-policy/storage-secure-transfer/arm/should-fail.json
@@ -0,0 +1,20 @@
+{
+ "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
+ "contentVersion": "1.0.0.0",
+ "resources": [
+ {
+ "type": "Microsoft.Storage/storageAccounts",
+ "apiVersion": "2023-01-01",
+ "name": "stapp001",
+ "location": "westeurope",
+ "sku": {
+ "name": "Standard_LRS"
+ },
+ "kind": "StorageV2",
+ "properties": {
+ "supportsHttpsTrafficOnly": false,
+ "minimumTlsVersion": "TLS1_2"
+ }
+ }
+ ]
+}
diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/storage-secure-transfer/arm/should-pass-no-storage.json b/.claude/skills/tirith-migrate/examples/azure-policy/storage-secure-transfer/arm/should-pass-no-storage.json
new file mode 100644
index 00000000..89676cc0
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/azure-policy/storage-secure-transfer/arm/should-pass-no-storage.json
@@ -0,0 +1,15 @@
+{
+ "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
+ "contentVersion": "1.0.0.0",
+ "resources": [
+ {
+ "type": "Microsoft.KeyVault/vaults",
+ "apiVersion": "2023-02-01",
+ "name": "kv-app",
+ "location": "westeurope",
+ "properties": {
+ "enableSoftDelete": true
+ }
+ }
+ ]
+}
diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/storage-secure-transfer/arm/should-pass.json b/.claude/skills/tirith-migrate/examples/azure-policy/storage-secure-transfer/arm/should-pass.json
new file mode 100644
index 00000000..c168733f
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/azure-policy/storage-secure-transfer/arm/should-pass.json
@@ -0,0 +1,20 @@
+{
+ "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
+ "contentVersion": "1.0.0.0",
+ "resources": [
+ {
+ "type": "Microsoft.Storage/storageAccounts",
+ "apiVersion": "2023-01-01",
+ "name": "stapp001",
+ "location": "westeurope",
+ "sku": {
+ "name": "Standard_LRS"
+ },
+ "kind": "StorageV2",
+ "properties": {
+ "supportsHttpsTrafficOnly": true,
+ "minimumTlsVersion": "TLS1_2"
+ }
+ }
+ ]
+}
diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/storage-secure-transfer/notes.md b/.claude/skills/tirith-migrate/examples/azure-policy/storage-secure-transfer/notes.md
new file mode 100644
index 00000000..bce0c7f9
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/azure-policy/storage-secure-transfer/notes.md
@@ -0,0 +1,24 @@
+# Secure transfer to storage accounts should be enabled: exact
+
+`if: allOf [type equals storageAccounts, anyOf [allOf [apiVersion less 2019-04-01, supportsHttpsTrafficOnly exists false], supportsHttpsTrafficOnly equals false]]`.
+
+The API-version branch is a request-time expression with no plan counterpart and is dropped; on
+the current API the property always exists, so compliance is `supportsHttpsTrafficOnly Equals true`.
+
+azurerm renamed the attribute in 4.0: `https_traffic_only_enabled`, previously
+`enable_https_traffic_only`. Two evaluators, one per name, each at `error_tolerance: 2` so the
+absent name is skipped, joined with `||`. Whichever name the plan carries decides.
+
+`arm/` holds the same policy for an ARM template on the json provider, in the corpus's type-guard
+idiom: `no_storage_account || https_only`. A template without a storage account passes
+(`should-pass-no-storage.json`); one with the property false, or absent, fails.
+
+| Plan | Azure | Tirith |
+| --- | --- | --- |
+| `should-fail.json` (4.x name, false) | audit | exit 3 |
+| `should-fail-azurerm3.json` (3.x name, false) | audit | exit 3 |
+| `should-pass.json` | pass | exit 0 |
+| `arm/should-fail.json`, `arm/should-pass.json`, `arm/should-pass-no-storage.json` | | 3, 0, 0 |
+
+Effect is `audit`: `meta.enforcement` says so, and the CLI cannot make it advisory. Run audit
+policies in their own directory and decide what exit 3 means for that run.
diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/storage-secure-transfer/policy.json b/.claude/skills/tirith-migrate/examples/azure-policy/storage-secure-transfer/policy.json
new file mode 100644
index 00000000..9e176a68
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/azure-policy/storage-secure-transfer/policy.json
@@ -0,0 +1,41 @@
+{
+ "meta": {
+ "version": "v1",
+ "required_provider": "stackguardian/terraform_plan",
+ "id": "404c3081-a854-4457-ae30-26a93ef643f9",
+ "name": "Secure transfer to storage accounts should be enabled",
+ "description": "supportsHttpsTrafficOnly is true on every storage account. azurerm 4.x names it https_traffic_only_enabled, 3.x enable_https_traffic_only; one evaluator per name, OR-ed, each skipping when its name is absent.",
+ "enforcement": "audit"
+ },
+ "evaluators": [
+ {
+ "id": "https_only_v4",
+ "description": "azurerm >= 4.0 attribute name",
+ "provider_args": {
+ "operation_type": "attribute",
+ "terraform_resource_type": "azurerm_storage_account",
+ "terraform_resource_attribute": "https_traffic_only_enabled"
+ },
+ "condition": {
+ "type": "Equals",
+ "value": true,
+ "error_tolerance": 2
+ }
+ },
+ {
+ "id": "https_only_v3",
+ "description": "azurerm 3.x attribute name",
+ "provider_args": {
+ "operation_type": "attribute",
+ "terraform_resource_type": "azurerm_storage_account",
+ "terraform_resource_attribute": "enable_https_traffic_only"
+ },
+ "condition": {
+ "type": "Equals",
+ "value": true,
+ "error_tolerance": 2
+ }
+ }
+ ],
+ "eval_expression": "https_only_v4 || https_only_v3"
+}
diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/storage-secure-transfer/should-fail-azurerm3.json b/.claude/skills/tirith-migrate/examples/azure-policy/storage-secure-transfer/should-fail-azurerm3.json
new file mode 100644
index 00000000..baace7cd
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/azure-policy/storage-secure-transfer/should-fail-azurerm3.json
@@ -0,0 +1,53 @@
+{
+ "format_version": "1.2",
+ "terraform_version": "1.9.5",
+ "resource_changes": [
+ {
+ "address": "azurerm_resource_group.rg",
+ "mode": "managed",
+ "type": "azurerm_resource_group",
+ "name": "rg",
+ "provider_name": "registry.terraform.io/hashicorp/azurerm",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "name": "rg-app",
+ "location": "westeurope",
+ "tags": null
+ },
+ "after_unknown": {
+ "id": true
+ }
+ }
+ },
+ {
+ "address": "azurerm_storage_account.sa",
+ "mode": "managed",
+ "type": "azurerm_storage_account",
+ "name": "sa",
+ "provider_name": "registry.terraform.io/hashicorp/azurerm",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "name": "stapp001",
+ "location": "westeurope",
+ "resource_group_name": "rg-app",
+ "account_tier": "Standard",
+ "account_replication_type": "LRS",
+ "min_tls_version": "TLS1_2",
+ "tags": null,
+ "enable_https_traffic_only": false
+ },
+ "after_unknown": {
+ "id": true
+ }
+ }
+ }
+ ]
+}
diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/storage-secure-transfer/should-fail.json b/.claude/skills/tirith-migrate/examples/azure-policy/storage-secure-transfer/should-fail.json
new file mode 100644
index 00000000..f1f8ea3f
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/azure-policy/storage-secure-transfer/should-fail.json
@@ -0,0 +1,53 @@
+{
+ "format_version": "1.2",
+ "terraform_version": "1.9.5",
+ "resource_changes": [
+ {
+ "address": "azurerm_resource_group.rg",
+ "mode": "managed",
+ "type": "azurerm_resource_group",
+ "name": "rg",
+ "provider_name": "registry.terraform.io/hashicorp/azurerm",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "name": "rg-app",
+ "location": "westeurope",
+ "tags": null
+ },
+ "after_unknown": {
+ "id": true
+ }
+ }
+ },
+ {
+ "address": "azurerm_storage_account.sa",
+ "mode": "managed",
+ "type": "azurerm_storage_account",
+ "name": "sa",
+ "provider_name": "registry.terraform.io/hashicorp/azurerm",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "name": "stapp001",
+ "location": "westeurope",
+ "resource_group_name": "rg-app",
+ "account_tier": "Standard",
+ "account_replication_type": "LRS",
+ "https_traffic_only_enabled": false,
+ "min_tls_version": "TLS1_2",
+ "tags": null
+ },
+ "after_unknown": {
+ "id": true
+ }
+ }
+ }
+ ]
+}
diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/storage-secure-transfer/should-pass.json b/.claude/skills/tirith-migrate/examples/azure-policy/storage-secure-transfer/should-pass.json
new file mode 100644
index 00000000..751ccc02
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/azure-policy/storage-secure-transfer/should-pass.json
@@ -0,0 +1,53 @@
+{
+ "format_version": "1.2",
+ "terraform_version": "1.9.5",
+ "resource_changes": [
+ {
+ "address": "azurerm_resource_group.rg",
+ "mode": "managed",
+ "type": "azurerm_resource_group",
+ "name": "rg",
+ "provider_name": "registry.terraform.io/hashicorp/azurerm",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "name": "rg-app",
+ "location": "westeurope",
+ "tags": null
+ },
+ "after_unknown": {
+ "id": true
+ }
+ }
+ },
+ {
+ "address": "azurerm_storage_account.sa",
+ "mode": "managed",
+ "type": "azurerm_storage_account",
+ "name": "sa",
+ "provider_name": "registry.terraform.io/hashicorp/azurerm",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "name": "stapp001",
+ "location": "westeurope",
+ "resource_group_name": "rg-app",
+ "account_tier": "Standard",
+ "account_replication_type": "LRS",
+ "https_traffic_only_enabled": true,
+ "min_tls_version": "TLS1_2",
+ "tags": null
+ },
+ "after_unknown": {
+ "id": true
+ }
+ }
+ }
+ ]
+}
diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/storage-secure-transfer/source.json b/.claude/skills/tirith-migrate/examples/azure-policy/storage-secure-transfer/source.json
new file mode 100644
index 00000000..51778218
--- /dev/null
+++ b/.claude/skills/tirith-migrate/examples/azure-policy/storage-secure-transfer/source.json
@@ -0,0 +1,66 @@
+{
+ "properties": {
+ "displayName": "Secure transfer to storage accounts should be enabled",
+ "policyType": "BuiltIn",
+ "mode": "Indexed",
+ "description": "Audit requirement of Secure transfer in your storage account. Secure transfer is an option that forces your storage account to accept requests only from secure connections (HTTPS). Use of HTTPS ensures authentication between the server and the service and protects data in transit from network layer attacks such as man-in-the-middle, eavesdropping, and session-hijacking",
+ "metadata": {
+ "version": "2.0.0",
+ "category": "Storage"
+ },
+ "version": "2.0.0",
+ "parameters": {
+ "effect": {
+ "type": "string",
+ "defaultValue": "Audit",
+ "allowedValues": [
+ "Audit",
+ "Deny",
+ "Disabled"
+ ],
+ "metadata": {
+ "displayName": "Effect",
+ "description": "The effect determines what happens when the policy rule is evaluated to match"
+ }
+ }
+ },
+ "policyRule": {
+ "if": {
+ "allOf": [
+ {
+ "field": "type",
+ "equals": "Microsoft.Storage/storageAccounts"
+ },
+ {
+ "anyOf": [
+ {
+ "allOf": [
+ {
+ "value": "[requestContext().apiVersion]",
+ "less": "2019-04-01"
+ },
+ {
+ "field": "Microsoft.Storage/storageAccounts/supportsHttpsTrafficOnly",
+ "exists": "false"
+ }
+ ]
+ },
+ {
+ "field": "Microsoft.Storage/storageAccounts/supportsHttpsTrafficOnly",
+ "equals": "false"
+ }
+ ]
+ }
+ ]
+ },
+ "then": {
+ "effect": "[parameters('effect')]"
+ }
+ },
+ "versions": [
+ "2.0.0"
+ ]
+ },
+ "id": "/providers/Microsoft.Authorization/policyDefinitions/404c3081-a854-4457-ae30-26a93ef643f9",
+ "name": "404c3081-a854-4457-ae30-26a93ef643f9"
+}
\ No newline at end of file
diff --git a/.claude/skills/tirith-migrate/reference/azure-policy-corpus.md b/.claude/skills/tirith-migrate/reference/azure-policy-corpus.md
new file mode 100644
index 00000000..2eae9cfa
--- /dev/null
+++ b/.claude/skills/tirith-migrate/reference/azure-policy-corpus.md
@@ -0,0 +1,504 @@
+# Azure Policy built-ins, as the corpus classified them
+
+From `StackGuardian/tirith-policy-corpus`: 376 definitions translated to Tirith for ARM templates (json provider,
+all approximate because the json provider scopes the document, not the resource) and 110 blocked with a taxonomy
+code. Look a built-in up by GUID or display name before translating it. Paths are relative to the corpus repository.
+
+Taxonomy: T5 per-resource disjunction across attributes · T9 filtered count · T10 value transforms · T14 Kubernetes.Data mode ·
+T18 per-resource scoping predicate (issue #316) · T19 assignment-time parameter with no default · T0 vacuous upstream check.
+
+## Translated
+
+| GUID | Display name | Corpus path |
+| --- | --- | --- |
+| `028282f7-374f-43c4-ac82-334b82c1ad80` | Webapp Slots should use App Service Environment. | `policies/azure-policy/arm/AZPOL_028282f7-374f-43c4-ac82-334b82c1ad80.json` |
+| `040732e8-d947-40b8-95d6-854c95024bf8` | Azure Kubernetes Service Private Clusters should be enabled | `policies/azure-policy/arm/AZPOL_040732e8-d947-40b8-95d6-854c95024bf8.json` |
+| `051cba44-2429-45b9-9649-46cec11c7119` | Azure API for FHIR should use a customer-managed key to encrypt data at rest | `policies/azure-policy/arm/AZPOL_051cba44-2429-45b9-9649-46cec11c7119.json` |
+| `055aa869-bc98-4af8-bafc-23f1ab6ffe2c` | Azure Web Application Firewall should be enabled for Azure Front Door entry-points | `policies/azure-policy/arm/AZPOL_055aa869-bc98-4af8-bafc-23f1ab6ffe2c.json` |
+| `05f397c0-4504-4e68-ae17-80ef283f6a5d` | Public network access on App Configuration should be SecuredByPerimeter | `policies/azure-policy/arm/AZPOL_05f397c0-4504-4e68-ae17-80ef283f6a5d.json` |
+| `0602787f-9896-402a-a6e1-39ee63ee435e` | Event Hub Namespaces should disable public network access | `policies/azure-policy/arm/AZPOL_0602787f-9896-402a-a6e1-39ee63ee435e.json` |
+| `0656cf40-485c-427b-b992-703a4ecf4f88` | Azure Managed Grafana workspaces should disable service account | `policies/azure-policy/arm/AZPOL_0656cf40-485c-427b-b992-703a4ecf4f88.json` |
+| `06a78e20-9358-41c9-923c-fb736d382a4d` | Audit VMs that do not use managed disks | `policies/azure-policy/arm/AZPOL_06a78e20-9358-41c9-923c-fb736d382a4d.json` |
+| `0725b4dd-7e76-479c-a735-68e7ee23d5ca` | [Deprecated]: Cognitive Services accounts should disable public network access | `policies/azure-policy/arm/AZPOL_0725b4dd-7e76-479c-a735-68e7ee23d5ca.json` |
+| `09aa11bb-87ec-409f-bf0b-49b7c1561a87` | Azure Cache for Redis Enterprise should use customer-managed keys for encrypting disk data | `policies/azure-policy/arm/AZPOL_09aa11bb-87ec-409f-bf0b-49b7c1561a87.json` |
+| `0a15ec92-a229-4763-bb14-0ea34a568f8d` | Azure Policy Add-on for Kubernetes service (AKS) should be installed and enabled on your clusters | `policies/azure-policy/arm/AZPOL_0a15ec92-a229-4763-bb14-0ea34a568f8d.json` |
+| `0aa61e00-0a01-4a3c-9945-e93cffedf0e6` | Azure Container Instance container group should use customer-managed key for encryption | `policies/azure-policy/arm/AZPOL_0aa61e00-0a01-4a3c-9945-e93cffedf0e6.json` |
+| `0b60c0b2-2dc2-4e1c-b5c9-abbed971de53` | Key vaults should have deletion protection enabled | `policies/azure-policy/arm/AZPOL_0b60c0b2-2dc2-4e1c-b5c9-abbed971de53.json` |
+| `0c192fe8-9cbb-4516-85b3-0ade8bd03886` | [Deprecated]: API apps should have 'Client Certificates (Incoming client certificates)' enabled | `policies/azure-policy/arm/AZPOL_0c192fe8-9cbb-4516-85b3-0ade8bd03886.json` |
+| `0c28c3fb-c244-42d5-a9bf-f35f2999577b` | Azure SQL Managed Instance should have Microsoft Entra-only authentication enabled | `policies/azure-policy/arm/AZPOL_0c28c3fb-c244-42d5-a9bf-f35f2999577b.json` |
+| `0c4bd2e8-8872-4f37-a654-03f6f38ddc76` | Application Insights components with Private Link enabled should use Bring Your Own Storage accounts for profiler and debugger. | `policies/azure-policy/arm/AZPOL_0c4bd2e8-8872-4f37-a654-03f6f38ddc76.json` |
+| `0d6d79a8-8406-4e87-814d-2dcd83b2c355` | [Preview]: Microsoft Managed DevOps Pools should be provided with valid subnet resource in order to configure with own virtual network. | `policies/azure-policy/arm/AZPOL_0d6d79a8-8406-4e87-814d-2dcd83b2c355.json` |
+| `0e3ccb86-ad68-43a5-8cd0-42035638b199` | App Service app slots should disable public network access. | `policies/azure-policy/arm/AZPOL_0e3ccb86-ad68-43a5-8cd0-42035638b199.json` |
+| `0e7849de-b939-4c50-ab48-fc6b0f5eeba2` | Azure Databricks Workspaces should disable public network access | `policies/azure-policy/arm/AZPOL_0e7849de-b939-4c50-ab48-fc6b0f5eeba2.json` |
+| `0e80e269-43a4-4ae9-b5bc-178126b8a5cb` | Container Apps should only be accessible over HTTPS | `policies/azure-policy/arm/AZPOL_0e80e269-43a4-4ae9-b5bc-178126b8a5cb.json` |
+| `0ec47710-77ff-4a3d-9181-6aa50af424d0` | Geo-redundant backup should be enabled for Azure Database for MariaDB | `policies/azure-policy/arm/AZPOL_0ec47710-77ff-4a3d-9181-6aa50af424d0.json` |
+| `0f2d8593-4667-4932-acca-6a9f187af109` | [Preview]: Audit Azure Spring Cloud instances where distributed tracing is not enabled | `policies/azure-policy/arm/AZPOL_0f2d8593-4667-4932-acca-6a9f187af109.json` |
+| `0fc92280-604b-4f23-9e04-5ef98d1a28df` | [Preview]: SQL Managed Instances should be Zone Redundant | `policies/azure-policy/arm/AZPOL_0fc92280-604b-4f23-9e04-5ef98d1a28df.json` |
+| `0fd9915e-cab3-4f24-b200-6e20e1aa276a` | Lab Services should require non-admin user for labs | `policies/azure-policy/arm/AZPOL_0fd9915e-cab3-4f24-b200-6e20e1aa276a.json` |
+| `0fdf0491-d080-4575-b627-ad0e843cba0f` | Public network access should be disabled for Container registries | `policies/azure-policy/arm/AZPOL_0fdf0491-d080-4575-b627-ad0e843cba0f.json` |
+| `0fea8f8a-4169-495d-8307-30ec335f387d` | CORS should not allow every domain to access your API for FHIR | `policies/azure-policy/arm/AZPOL_0fea8f8a-4169-495d-8307-30ec335f387d.json` |
+| `11c82d0c-db9f-4d7b-97c5-f3f9aa957da2` | Function app slots should disable public network access | `policies/azure-policy/arm/AZPOL_11c82d0c-db9f-4d7b-97c5-f3f9aa957da2.json` |
+| `12339a85-a25c-4f17-9f82-4766f13f5c4c` | Azure Cosmos DB accounts should not allow traffic from all Azure data centers | `policies/azure-policy/arm/AZPOL_12339a85-a25c-4f17-9f82-4766f13f5c4c.json` |
+| `123aed70-491a-4f07-a569-e1f3a8dd651e` | App Service app slots should enable end to end encryption | `policies/azure-policy/arm/AZPOL_123aed70-491a-4f07-a569-e1f3a8dd651e.json` |
+| `12430be1-6cc8-4527-a9a8-e3d38f250096` | Web Application Firewall (WAF) should use the specified mode for Application Gateway | `policies/azure-policy/arm/AZPOL_12430be1-6cc8-4527-a9a8-e3d38f250096.json` |
+| `12c74c95-0efd-48da-b8d9-2a7d68470c92` | PostgreSQL flexible servers should use customer-managed keys to encrypt data at rest | `policies/azure-policy/arm/AZPOL_12c74c95-0efd-48da-b8d9-2a7d68470c92.json` |
+| `12d4fa5e-1f9f-4c21-97a9-b99b3c6611b5` | Azure Key Vault should use RBAC permission model | `policies/azure-policy/arm/AZPOL_12d4fa5e-1f9f-4c21-97a9-b99b3c6611b5.json` |
+| `12e7176a-4919-47ef-922b-34eda4c7f0ce` | Azure Arc-enabled kubernetes clusters should be configured with an Azure Arc Private Link Scope | `policies/azure-policy/arm/AZPOL_12e7176a-4919-47ef-922b-34eda4c7f0ce.json` |
+| `13bcff5d-f0eb-4ce7-913e-83ad6300376b` | Function app slots should use an Azure file share for its content directory | `policies/azure-policy/arm/AZPOL_13bcff5d-f0eb-4ce7-913e-83ad6300376b.json` |
+| `14961b63-a1eb-4378-8725-7e84ca8db0e6` | DICOM Service should use a customer-managed key to encrypt data at rest | `policies/azure-policy/arm/AZPOL_14961b63-a1eb-4378-8725-7e84ca8db0e6.json` |
+| `152b15f7-8e1f-4c1f-ab71-8c010ba5dbc0` | Key Vault keys should have an expiration date | `policies/azure-policy/arm/AZPOL_152b15f7-8e1f-4c1f-ab71-8c010ba5dbc0.json` |
+| `16fabb5c-7379-4433-8009-042066fa3a16` | Exclude Usage Costs Resources | `policies/azure-policy/arm/AZPOL_16fabb5c-7379-4433-8009-042066fa3a16.json` |
+| `1760f9d4-7206-436e-a28f-d9f3a5c8a227` | Azure Batch pools should have disk encryption enabled | `policies/azure-policy/arm/AZPOL_1760f9d4-7206-436e-a28f-d9f3a5c8a227.json` |
+| `176b7c36-ac64-4f15-a296-50bd7fafab12` | Do Not Allow M365 resources | `policies/azure-policy/arm/AZPOL_176b7c36-ac64-4f15-a296-50bd7fafab12.json` |
+| `199d5677-e4d9-4264-9465-efe1839c06bd` | Application Insights components should block non-Azure Active Directory based ingestion. | `policies/azure-policy/arm/AZPOL_199d5677-e4d9-4264-9465-efe1839c06bd.json` |
+| `19ea9d63-adee-4431-a95e-1913c6c1c75f` | [Preview]: Azure Key Vault Managed HSM should disable public network access | `policies/azure-policy/arm/AZPOL_19ea9d63-adee-4431-a95e-1913c6c1c75f.json` |
+| `1adadefe-5f21-44f7-b931-a59b54ccdb45` | Azure Event Grid topics should disable public network access | `policies/azure-policy/arm/AZPOL_1adadefe-5f21-44f7-b931-a59b54ccdb45.json` |
+| `1b5ef780-c53c-4a64-87f3-bb9c8c8094ba` | App Service apps should disable public network access | `policies/azure-policy/arm/AZPOL_1b5ef780-c53c-4a64-87f3-bb9c8c8094ba.json` |
+| `1b8ca024-1d5c-4dec-8995-b1a932b41780` | Public network access on Azure SQL Database should be disabled | `policies/azure-policy/arm/AZPOL_1b8ca024-1d5c-4dec-8995-b1a932b41780.json` |
+| `1bc02227-0cb6-4e11-8f53-eb0b22eab7e8` | Application Insights components should block log ingestion and querying from public networks | `policies/azure-policy/arm/AZPOL_1bc02227-0cb6-4e11-8f53-eb0b22eab7e8.json` |
+| `1c30f9cd-b84c-49cc-aa2c-9288447cc3b3` | [Preview]: vTPM should be enabled on supported virtual machines | `policies/azure-policy/arm/AZPOL_1c30f9cd-b84c-49cc-aa2c-9288447cc3b3.json` |
+| `1cf164be-6819-4a50-b8fa-4bcaa4f98fb6` | Public network access on Azure Data Factory should be disabled | `policies/azure-policy/arm/AZPOL_1cf164be-6819-4a50-b8fa-4bcaa4f98fb6.json` |
+| `1dc2fc00-2245-4143-99f4-874c937f13ef` | Azure API Management platform version should be stv2 | `policies/azure-policy/arm/AZPOL_1dc2fc00-2245-4143-99f4-874c937f13ef.json` |
+| `1e66c121-a66a-4b1f-9b83-0fd99bf0fc2d` | Key vaults should have soft delete enabled | `policies/azure-policy/arm/AZPOL_1e66c121-a66a-4b1f-9b83-0fd99bf0fc2d.json` |
+| `1f68a601-6e6d-4e42-babf-3f643a047ea2` | Azure Monitor Logs clusters should be encrypted with customer-managed key | `policies/azure-policy/arm/AZPOL_1f68a601-6e6d-4e42-babf-3f643a047ea2.json` |
+| `1f905d99-2ab7-462c-a6b0-f709acca6c8f` | Azure Cosmos DB accounts should use customer-managed keys to encrypt data at rest | `policies/azure-policy/arm/AZPOL_1f905d99-2ab7-462c-a6b0-f709acca6c8f.json` |
+| `1fafeaf6-7927-4059-a50a-8eb2a7a6f2b5` | Logic Apps Integration Service Environment should be encrypted with customer-managed keys | `policies/azure-policy/arm/AZPOL_1fafeaf6-7927-4059-a50a-8eb2a7a6f2b5.json` |
+| `1fd32ebd-e4c3-4e13-a54a-d7422d4d95f6` | Azure HDInsight clusters should use encryption at host to encrypt data at rest | `policies/azure-policy/arm/AZPOL_1fd32ebd-e4c3-4e13-a54a-d7422d4d95f6.json` |
+| `1fec9658-933f-4b3e-bc95-913ed22d012b` | Azure Data Explorer should use a SKU that supports private link | `policies/azure-policy/arm/AZPOL_1fec9658-933f-4b3e-bc95-913ed22d012b.json` |
+| `2154edb9-244f-4741-9970-660785bccdaa` | VM Image Builder templates should use private link | `policies/azure-policy/arm/AZPOL_2154edb9-244f-4741-9970-660785bccdaa.json` |
+| `21a8cd35-125e-4d13-b82d-2e19b7208bb7` | Public network access should be disabled for Azure File Sync | `policies/azure-policy/arm/AZPOL_21a8cd35-125e-4d13-b82d-2e19b7208bb7.json` |
+| `21c2b371-0891-40a0-82e6-629defc1f36a` | App Service app slot configs should restrict default network access in Mission Platform products and services | `policies/azure-policy/arm/AZPOL_21c2b371-0891-40a0-82e6-629defc1f36a.json` |
+| `22888755-d824-4e43-8e0b-42d481836554` | [Preview]: App Service Plans should be Zone Redundant | `policies/azure-policy/arm/AZPOL_22888755-d824-4e43-8e0b-42d481836554.json` |
+| `22bee202-a82f-4305-9a2a-6d7f44d4dedb` | Only secure connections to your Azure Cache for Redis should be enabled | `policies/azure-policy/arm/AZPOL_22bee202-a82f-4305-9a2a-6d7f44d4dedb.json` |
+| `24b7a1c6-44fe-40cc-a2e6-242d2ef70e98` | App Service app slots should be injected into a virtual network | `policies/azure-policy/arm/AZPOL_24b7a1c6-44fe-40cc-a2e6-242d2ef70e98.json` |
+| `24fba194-95d6-48c0-aea7-f65bf859c598` | Infrastructure encryption should be enabled for Azure Database for PostgreSQL servers | `policies/azure-policy/arm/AZPOL_24fba194-95d6-48c0-aea7-f65bf859c598.json` |
+| `2514263b-bc0d-4b06-ac3e-f262c0979018` | Immutability must be enabled for backup vaults | `policies/azure-policy/arm/AZPOL_2514263b-bc0d-4b06-ac3e-f262c0979018.json` |
+| `25255ddf-ef4f-4283-975a-5590ad111bba` | App Service apps should disable SSH | `policies/azure-policy/arm/AZPOL_25255ddf-ef4f-4283-975a-5590ad111bba.json` |
+| `27960feb-a23c-4577-8d36-ef8b5f35e0be` | All flow log resources should be in enabled state | `policies/azure-policy/arm/AZPOL_27960feb-a23c-4577-8d36-ef8b5f35e0be.json` |
+| `295fc8b1-dc9f-4f53-9c61-3f313ceab40a` | Service Bus Premium namespaces should use a customer-managed key for encryption | `policies/azure-policy/arm/AZPOL_295fc8b1-dc9f-4f53-9c61-3f313ceab40a.json` |
+| `2bdd0062-9d75-436e-89df-487dd8e4b3c7` | [Deprecated]: Cognitive Services accounts should enable data encryption | `policies/azure-policy/arm/AZPOL_2bdd0062-9d75-436e-89df-487dd8e4b3c7.json` |
+| `2cc2c3b5-c2f8-45aa-a9e6-f90d85ae8352` | Azure Databricks workspaces should be Premium SKU that supports features like private link, customer-managed key for encryption | `policies/azure-policy/arm/AZPOL_2cc2c3b5-c2f8-45aa-a9e6-f90d85ae8352.json` |
+| `2cc2e023-0dac-4046-875b-178f683929d5` | Azure Kubernetes Service Clusters should enable workload identity | `policies/azure-policy/arm/AZPOL_2cc2e023-0dac-4046-875b-178f683929d5.json` |
+| `2d048aca-6479-4923-88f5-e2ac295d9af3` | App Service Environment apps should not be reachable over public internet | `policies/azure-policy/arm/AZPOL_2d048aca-6479-4923-88f5-e2ac295d9af3.json` |
+| `2d6830fb-07eb-48e7-8c4d-2a442b35f0fb` | Public network access on Azure IoT Hub should be disabled | `policies/azure-policy/arm/AZPOL_2d6830fb-07eb-48e7-8c4d-2a442b35f0fb.json` |
+| `2d9dbfa3-927b-4cf0-9d0f-08747f971650` | Managed workspace virtual network on Azure Synapse workspaces should be enabled | `policies/azure-policy/arm/AZPOL_2d9dbfa3-927b-4cf0-9d0f-08747f971650.json` |
+| `2e94d99a-8a36-4563-bc77-810d8893b671` | Azure Recovery Services vaults should use customer-managed keys for encrypting backup data | `policies/azure-policy/arm/AZPOL_2e94d99a-8a36-4563-bc77-810d8893b671.json` |
+| `2f080164-9f4d-497e-9db6-416dc9f7b48a` | Network Watcher flow logs should have traffic analytics enabled | `policies/azure-policy/arm/AZPOL_2f080164-9f4d-497e-9db6-416dc9f7b48a.json` |
+| `2f7c08c2-f671-4282-9fdb-597b6ef2c10d` | [Deprecated]: App Service app slots should have 'Client Certificates (Incoming client certificates)' enabled | `policies/azure-policy/arm/AZPOL_2f7c08c2-f671-4282-9fdb-597b6ef2c10d.json` |
+| `31b8092a-36b8-434b-9af7-5ec844364148` | Soft delete must be enabled for Recovery Services Vaults. | `policies/azure-policy/arm/AZPOL_31b8092a-36b8-434b-9af7-5ec844364148.json` |
+| `3235e3fc-9982-4886-9346-2dc3535b1496` | App Service app slots should enable outbound non-RFC 1918 traffic to Azure Virtual Network. | `policies/azure-policy/arm/AZPOL_3235e3fc-9982-4886-9346-2dc3535b1496.json` |
+| `324c7761-08db-4474-9661-d1039abc92ee` | [Deprecated]: API apps should use an Azure file share for its content directory | `policies/azure-policy/arm/AZPOL_324c7761-08db-4474-9661-d1039abc92ee.json` |
+| `32e6bbec-16b6-44c2-be37-c5b672d103cf` | Azure SQL Database should be running TLS version 1.2 or newer | `policies/azure-policy/arm/AZPOL_32e6bbec-16b6-44c2-be37-c5b672d103cf.json` |
+| `335d919a-dc24-4a94-b7cb-9f81b1a8156f` | Do Not Allow MCPP resources | `policies/azure-policy/arm/AZPOL_335d919a-dc24-4a94-b7cb-9f81b1a8156f.json` |
+| `3484ce98-c0c5-4c83-994b-c5ac24785218` | Azure Synapse workspaces should allow outbound data traffic only to approved targets | `policies/azure-policy/arm/AZPOL_3484ce98-c0c5-4c83-994b-c5ac24785218.json` |
+| `356da939-f20a-4bb9-86f8-5db445b0e354` | Configure Azure AI Search services to enforce customer-managed keys to encrypt data at rest | `policies/azure-policy/arm/AZPOL_356da939-f20a-4bb9-86f8-5db445b0e354.json` |
+| `3657f5a0-770e-44a3-b44e-9431ba1e9735` | Automation account variables should be encrypted | `policies/azure-policy/arm/AZPOL_3657f5a0-770e-44a3-b44e-9431ba1e9735.json` |
+| `3827af20-8f80-4b15-8300-6db0873ec901` | Azure Cache for Redis should not use access keys for authentication | `policies/azure-policy/arm/AZPOL_3827af20-8f80-4b15-8300-6db0873ec901.json` |
+| `387140f1-6da9-4741-bcee-3b5edcdfd9ec` | Function apps should enable end to end encryption | `policies/azure-policy/arm/AZPOL_387140f1-6da9-4741-bcee-3b5edcdfd9ec.json` |
+| `38d8df46-cf4e-4073-8e03-48c24b29de0d` | Azure Synapse workspaces should disable public network access | `policies/azure-policy/arm/AZPOL_38d8df46-cf4e-4073-8e03-48c24b29de0d.json` |
+| `3a58212a-c829-4f13-9872-6371df2fd0b4` | Infrastructure encryption should be enabled for Azure Database for MySQL servers | `policies/azure-policy/arm/AZPOL_3a58212a-c829-4f13-9872-6371df2fd0b4.json` |
+| `3aa03346-d8c5-4994-a5bc-7652c2a2aef1` | API Management subscriptions should not be scoped to all APIs | `policies/azure-policy/arm/AZPOL_3aa03346-d8c5-4994-a5bc-7652c2a2aef1.json` |
+| `3aa87b5a-7813-4b57-8a43-42dd9df5aaa7` | Azure Active Directory Domain Services managed domains should use TLS 1.2 only mode | `policies/azure-policy/arm/AZPOL_3aa87b5a-7813-4b57-8a43-42dd9df5aaa7.json` |
+| `3abeb944-26af-43ee-b83d-32aaf060fb94` | [Deprecated]: Pod Security Policies should be defined on Kubernetes Services | `policies/azure-policy/arm/AZPOL_3abeb944-26af-43ee-b83d-32aaf060fb94.json` |
+| `3d9f5e4c-9947-4579-9539-2a7695fbc187` | App Configuration should disable public network access | `policies/azure-policy/arm/AZPOL_3d9f5e4c-9947-4579-9539-2a7695fbc187.json` |
+| `3e13d504-9083-4912-b935-39a085db2249` | Lab Services should restrict allowed virtual machine SKU sizes | `policies/azure-policy/arm/AZPOL_3e13d504-9083-4912-b935-39a085db2249.json` |
+| `3e1f521a-d037-4709-bdd6-1f532f271a75` | Azure Firewall should be deployed to span multiple Availability Zones | `policies/azure-policy/arm/AZPOL_3e1f521a-d037-4709-bdd6-1f532f271a75.json` |
+| `3e90a76f-8f87-4753-9f18-b40f206c2653` | Function app slots should be injected into a virtual network | `policies/azure-policy/arm/AZPOL_3e90a76f-8f87-4753-9f18-b40f206c2653.json` |
+| `3f84c9b0-8b64-4208-98d4-6ada96bb49c3` | Azure Firewall Policy should have DNS Proxy Enabled | `policies/azure-policy/arm/AZPOL_3f84c9b0-8b64-4208-98d4-6ada96bb49c3.json` |
+| `413923f0-ff16-41ae-8583-90c5c5d9fa8f` | Customer managed key encryption must be used as part of CMK Encryption for Arc SQL managed instances. | `policies/azure-policy/arm/AZPOL_413923f0-ff16-41ae-8583-90c5c5d9fa8f.json` |
+| `41c4164d-a848-48d3-b10f-c3076787f0f5` | App Service app slots should enable configuration routing to Azure Virtual Network. | `policies/azure-policy/arm/AZPOL_41c4164d-a848-48d3-b10f-c3076787f0f5.json` |
+| `425bea59-a659-4cbb-8d31-34499bd030b8` | Web Application Firewall (WAF) should use the specified mode for Azure Front Door Service | `policies/azure-policy/arm/AZPOL_425bea59-a659-4cbb-8d31-34499bd030b8.json` |
+| `42781ec6-6127-4c30-bdfa-fb423a0047d3` | Container registries should have ARM audience token authentication disabled. | `policies/azure-policy/arm/AZPOL_42781ec6-6127-4c30-bdfa-fb423a0047d3.json` |
+| `42daa901-5969-47ef-92cb-b75df946195a` | [Preview]: Load Balancers should be Zone Resilient | `policies/azure-policy/arm/AZPOL_42daa901-5969-47ef-92cb-b75df946195a.json` |
+| `42daa904-5969-47ef-92fb-b75df946195a` | [Preview]: Container App should be Zone Redundant | `policies/azure-policy/arm/AZPOL_42daa904-5969-47ef-92fb-b75df946195a.json` |
+| `438c38d2-3772-465a-a9cc-7a6666a275ce` | Azure Machine Learning Workspaces should disable public network access | `policies/azure-policy/arm/AZPOL_438c38d2-3772-465a-a9cc-7a6666a275ce.json` |
+| `43bc7be6-5e69-4b0d-a2bb-e815557ca673` | Public network access on Azure Data Explorer should be disabled | `policies/azure-policy/arm/AZPOL_43bc7be6-5e69-4b0d-a2bb-e815557ca673.json` |
+| `43c323f6-0329-4f7c-a19a-6e5a5690d042` | Azure Device Update accounts should use customer-managed key to encrypt data at rest | `policies/azure-policy/arm/AZPOL_43c323f6-0329-4f7c-a19a-6e5a5690d042.json` |
+| `450d2877-ebea-41e8-b00c-e286317d21bf` | Azure Kubernetes Service Clusters should enable Microsoft Entra ID integration | `policies/azure-policy/arm/AZPOL_450d2877-ebea-41e8-b00c-e286317d21bf.json` |
+| `4598f028-de1f-4694-8751-84dceb5f86b9` | Azure Web Application Firewall on Azure Front Door should have request body inspection enabled | `policies/azure-policy/arm/AZPOL_4598f028-de1f-4694-8751-84dceb5f86b9.json` |
+| `46238e2f-3f6f-4589-9f3f-77bed4116e67` | Azure Kubernetes Clusters should use Azure CNI | `policies/azure-policy/arm/AZPOL_46238e2f-3f6f-4589-9f3f-77bed4116e67.json` |
+| `46388f67-373c-4018-98d3-2b83172dd13a` | Fluid Relay should use customer-managed keys to encrypt data at rest | `policies/azure-policy/arm/AZPOL_46388f67-373c-4018-98d3-2b83172dd13a.json` |
+| `464dbb85-3d5f-4a1d-bb09-95a9b5dd19cf` | [Deprecated]: Require SQL Server version 12.0 | `policies/azure-policy/arm/AZPOL_464dbb85-3d5f-4a1d-bb09-95a9b5dd19cf.json` |
+| `465f0161-0087-490a-9ad9-ad6217f4f43a` | Require automatic OS image patching on Virtual Machine Scale Sets | `policies/azure-policy/arm/AZPOL_465f0161-0087-490a-9ad9-ad6217f4f43a.json` |
+| `470baccb-7e51-4549-8b1a-3e5be069f663` | Azure Cache for Redis should disable public network access | `policies/azure-policy/arm/AZPOL_470baccb-7e51-4549-8b1a-3e5be069f663.json` |
+| `48af4db5-9b8b-401c-8e74-076be876a430` | Geo-redundant backup should be enabled for Azure Database for PostgreSQL | `policies/azure-policy/arm/AZPOL_48af4db5-9b8b-401c-8e74-076be876a430.json` |
+| `48c5f1cb-14ad-4797-8e3b-f78ab3f8d700` | Azure Automation account should have local authentication method disabled | `policies/azure-policy/arm/AZPOL_48c5f1cb-14ad-4797-8e3b-f78ab3f8d700.json` |
+| `4bd1f3c0-9443-49ad-b8bc-7c17a92b5924` | [Preview]: Backup Vaults should be Zone Redundant | `policies/azure-policy/arm/AZPOL_4bd1f3c0-9443-49ad-b8bc-7c17a92b5924.json` |
+| `4c660f31-eafb-408d-a2b3-6ed2260bd26c` | [Preview]: Deny Extended Security Updates (ESUs) license creation or modification. | `policies/azure-policy/arm/AZPOL_4c660f31-eafb-408d-a2b3-6ed2260bd26c.json` |
+| `4d080fa5-a6d2-4f98-ba9c-f482d0d335c0` | Azure Health Bots should use customer-managed keys to encrypt data at rest | `policies/azure-policy/arm/AZPOL_4d080fa5-a6d2-4f98-ba9c-f482d0d335c0.json` |
+| `4d0bc837-6eff-477e-9ecd-33bf8d4212a5` | Function apps should use an Azure file share for its content directory | `policies/azure-policy/arm/AZPOL_4d0bc837-6eff-477e-9ecd-33bf8d4212a5.json` |
+| `4ec52d6d-beb7-40c4-9a9e-fe753254690e` | Azure data factories should be encrypted with a customer-managed key | `policies/azure-policy/arm/AZPOL_4ec52d6d-beb7-40c4-9a9e-fe753254690e.json` |
+| `50b83b09-03da-41c1-b656-c293c914862b` | A custom IPsec/IKE policy must be applied to all Azure virtual network gateway connections | `policies/azure-policy/arm/AZPOL_50b83b09-03da-41c1-b656-c293c914862b.json` |
+| `50d7880f-06c8-402f-9236-0417e5b1559c` | App Service Environment should have Control and Data Ports bound to ILB | `policies/azure-policy/arm/AZPOL_50d7880f-06c8-402f-9236-0417e5b1559c.json` |
+| `510ec8b2-cb9e-461d-b7f3-6b8678c31182` | Public network access for Azure Device Update for IoT Hub accounts should be disabled | `policies/azure-policy/arm/AZPOL_510ec8b2-cb9e-461d-b7f3-6b8678c31182.json` |
+| `51522a96-0869-4791-82f3-981000c2c67f` | Bot Service should be encrypted with a customer-managed key | `policies/azure-policy/arm/AZPOL_51522a96-0869-4791-82f3-981000c2c67f.json` |
+| `51c1490f-3319-459c-bbbc-7f391bbed753` | Azure Databricks Clusters should disable public IP | `policies/azure-policy/arm/AZPOL_51c1490f-3319-459c-bbbc-7f391bbed753.json` |
+| `51cf6de4-d195-4172-abd3-ec28ff386558` | App Service Plans should use App Service Environment. | `policies/azure-policy/arm/AZPOL_51cf6de4-d195-4172-abd3-ec28ff386558.json` |
+| `52152f42-0dda-40d9-976e-abb1acdd611e` | Bot Service should have isolated mode enabled | `policies/azure-policy/arm/AZPOL_52152f42-0dda-40d9-976e-abb1acdd611e.json` |
+| `524b0254-c285-4903-bee6-bb8126cde579` | Container registries should have exports disabled | `policies/azure-policy/arm/AZPOL_524b0254-c285-4903-bee6-bb8126cde579.json` |
+| `5450f5bd-9c72-4390-a9c4-a7aba4edfdd2` | Cosmos DB database accounts should have local authentication methods disabled | `policies/azure-policy/arm/AZPOL_5450f5bd-9c72-4390-a9c4-a7aba4edfdd2.json` |
+| `546fe8d2-368d-4029-a418-6af48a7f61e5` | App Service apps should use a SKU that supports private link | `policies/azure-policy/arm/AZPOL_546fe8d2-368d-4029-a418-6af48a7f61e5.json` |
+| `56a5ee18-2ae6-4810-86f7-18e39ce5629b` | Azure Automation accounts should use customer-managed keys to encrypt data at rest | `policies/azure-policy/arm/AZPOL_56a5ee18-2ae6-4810-86f7-18e39ce5629b.json` |
+| `56fd377d-098c-4f02-8406-81eb055902b8` | IP firewall rules on Azure Synapse workspaces should be removed | `policies/azure-policy/arm/AZPOL_56fd377d-098c-4f02-8406-81eb055902b8.json` |
+| `587c79fe-dd04-4a5e-9d0b-f89598c7261b` | Keys should be backed by a hardware security module (HSM) | `policies/azure-policy/arm/AZPOL_587c79fe-dd04-4a5e-9d0b-f89598c7261b.json` |
+| `5b9159ae-1701-4a6f-9a7a-aa9c8ddd0580` | Container registries should be encrypted with a customer-managed key | `policies/azure-policy/arm/AZPOL_5b9159ae-1701-4a6f-9a7a-aa9c8ddd0580.json` |
+| `5bb220d9-2698-4ee4-8404-b9c30c9df609` | [Deprecated]: App Service apps should have 'Client Certificates (Incoming client certificates)' enabled | `policies/azure-policy/arm/AZPOL_5bb220d9-2698-4ee4-8404-b9c30c9df609.json` |
+| `5c345cdf-2049-47e0-b8fe-b0e96bc2df35` | Azure Kubernetes Service Clusters should enable cluster auto-upgrade | `policies/azure-policy/arm/AZPOL_5c345cdf-2049-47e0-b8fe-b0e96bc2df35.json` |
+| `5cc7aeef-357e-433d-9627-05354b40f8e2` | Azure SQL Database should restrict or disable Public network access | `policies/azure-policy/arm/AZPOL_5cc7aeef-357e-433d-9627-05354b40f8e2.json` |
+| `5d4e3c65-4873-47be-94f3-6f8b953a3598` | Azure Event Hub namespaces should have local authentication methods disabled | `policies/azure-policy/arm/AZPOL_5d4e3c65-4873-47be-94f3-6f8b953a3598.json` |
+| `5e5dbe3f-2702-4ffc-8b1e-0cae008a5c71` | Function app slots should only be accessible over HTTPS | `policies/azure-policy/arm/AZPOL_5e5dbe3f-2702-4ffc-8b1e-0cae008a5c71.json` |
+| `5e7e928c-8693-4a23-9bf3-1c77b9a8fe97` | Azure Attestation providers should disable public network access | `policies/azure-policy/arm/AZPOL_5e7e928c-8693-4a23-9bf3-1c77b9a8fe97.json` |
+| `5e8168db-69e3-4beb-9822-57cb59202a9d` | Bot Service should have public network access disabled | `policies/azure-policy/arm/AZPOL_5e8168db-69e3-4beb-9822-57cb59202a9d.json` |
+| `5ee85ce5-e7eb-44d6-b4a2-32a24be1ca54` | [Deprecated]: Allow resource creation only in India data centers | `policies/azure-policy/arm/AZPOL_5ee85ce5-e7eb-44d6-b4a2-32a24be1ca54.json` |
+| `5f0c7d88-c7de-45b8-ac49-db49e72eaa78` | Azure Machine Learning workspaces should use user-assigned managed identity | `policies/azure-policy/arm/AZPOL_5f0c7d88-c7de-45b8-ac49-db49e72eaa78.json` |
+| `5f78d1de-663e-4a6b-8dd8-791621f3b6d6` | Function apps should provide an auto-generated domain name label scope | `policies/azure-policy/arm/AZPOL_5f78d1de-663e-4a6b-8dd8-791621f3b6d6.json` |
+| `60ada4fc-e576-433c-923b-1f7bd61af33d` | Webapps should use App Service Environment. | `policies/azure-policy/arm/AZPOL_60ada4fc-e576-433c-923b-1f7bd61af33d.json` |
+| `60d21c4f-21a3-4d94-85f4-b924e6aeeda4` | Storage Accounts should use a virtual network service endpoint | `policies/azure-policy/arm/AZPOL_60d21c4f-21a3-4d94-85f4-b924e6aeeda4.json` |
+| `6164527b-e1ee-4882-8673-572f425f5e0a` | Bot Service endpoint should be a valid HTTPS URI | `policies/azure-policy/arm/AZPOL_6164527b-e1ee-4882-8673-572f425f5e0a.json` |
+| `6221cac0-bb8d-40f4-9535-5d03f713f054` | [Preview]: SQL Databases should be Zone Redundant | `policies/azure-policy/arm/AZPOL_6221cac0-bb8d-40f4-9535-5d03f713f054.json` |
+| `6300012e-e9a4-4649-b41f-a85f5c43be91` | Azure AI Search services should have local authentication methods disabled | `policies/azure-policy/arm/AZPOL_6300012e-e9a4-4649-b41f-a85f5c43be91.json` |
+| `6484db87-a62d-4327-9f07-80a2cbdf333a` | [Deprecated]: Firewall Policy Premium should enable the Intrusion Detection and Prevention System (IDPS) | `policies/azure-policy/arm/AZPOL_6484db87-a62d-4327-9f07-80a2cbdf333a.json` |
+| `64d314f6-6062-4780-a861-c23e8951bee5` | Azure HDInsight clusters should use customer-managed keys to encrypt data at rest | `policies/azure-policy/arm/AZPOL_64d314f6-6062-4780-a861-c23e8951bee5.json` |
+| `655cb504-bcee-4362-bd4c-402e6aa38759` | [Deprecated]: [Deprecated]: Audit missing blob encryption for storage accounts | `policies/azure-policy/arm/AZPOL_655cb504-bcee-4362-bd4c-402e6aa38759.json` |
+| `6599ab01-29bc-4852-a6f5-de9e2151714a` | Transparent Data Encryption must be enabled for Arc SQL managed instances. | `policies/azure-policy/arm/AZPOL_6599ab01-29bc-4852-a6f5-de9e2151714a.json` |
+| `65c4f833-1f2e-426c-8780-f6d7593bed7a` | Azure load testing resource should use customer-managed keys to encrypt data at rest | `policies/azure-policy/arm/AZPOL_65c4f833-1f2e-426c-8780-f6d7593bed7a.json` |
+| `672d56b3-23a7-4a3c-a233-b77ed7777518` | Azure IoT Hub should have local authentication methods disabled for Service Apis | `policies/azure-policy/arm/AZPOL_672d56b3-23a7-4a3c-a233-b77ed7777518.json` |
+| `679da822-78a7-4eff-8fff-a899454a9970` | Azure Front Door Standard and Premium should be running minimum TLS version of 1.2 | `policies/azure-policy/arm/AZPOL_679da822-78a7-4eff-8fff-a899454a9970.json` |
+| `67da6a60-29e0-471d-b856-4cd2dee72fc0` | Function app slots should provide an auto-generated domain name label scope | `policies/azure-policy/arm/AZPOL_67da6a60-29e0-471d-b856-4cd2dee72fc0.json` |
+| `67dcad1a-ec60-45df-8fd0-14c9d29eeaa2` | Azure Event Grid namespaces should disable public network access | `policies/azure-policy/arm/AZPOL_67dcad1a-ec60-45df-8fd0-14c9d29eeaa2.json` |
+| `6a92fe1f-0b86-44ae-843d-2db3d2b571ae` | ElasticSan should disable public network access | `policies/azure-policy/arm/AZPOL_6a92fe1f-0b86-44ae-843d-2db3d2b571ae.json` |
+| `6c53d030-cc64-46f0-906d-2bc061cd1334` | Log Analytics workspaces should block log ingestion and querying from public networks | `policies/azure-policy/arm/AZPOL_6c53d030-cc64-46f0-906d-2bc061cd1334.json` |
+| `6d02d2f7-e38b-4bdc-96f3-adc0a8726abc` | Hotpatch should be enabled for Windows Server Azure Edition VMs | `policies/azure-policy/arm/AZPOL_6d02d2f7-e38b-4bdc-96f3-adc0a8726abc.json` |
+| `6d555dd1-86f2-4f1c-8ed7-5abae7c6cbab` | Function apps should only be accessible over HTTPS | `policies/azure-policy/arm/AZPOL_6d555dd1-86f2-4f1c-8ed7-5abae7c6cbab.json` |
+| `6ddb1705-c8cf-450e-aa4b-19ad6703c440` | Azure Machine Learning and Ai Studio should use Allow Only Approved Outbound Managed Vnet mode | `policies/azure-policy/arm/AZPOL_6ddb1705-c8cf-450e-aa4b-19ad6703c440.json` |
+| `6ea81a52-5ca7-4575-9669-eaa910b7edf8` | Synapse Workspaces should have Microsoft Entra-only authentication enabled | `policies/azure-policy/arm/AZPOL_6ea81a52-5ca7-4575-9669-eaa910b7edf8.json` |
+| `6f68b69f-05fe-49cd-b361-777ee9ca7e35` | Batch accounts should have local authentication methods disabled | `policies/azure-policy/arm/AZPOL_6f68b69f-05fe-49cd-b361-777ee9ca7e35.json` |
+| `6fc8115b-2008-441f-8c61-9b722c1e537f` | Workbooks should be saved to storage accounts that you control | `policies/azure-policy/arm/AZPOL_6fc8115b-2008-441f-8c61-9b722c1e537f.json` |
+| `701a595d-38fb-4a66-ae6d-fb3735217622` | App Service app slots should disable public network access | `policies/azure-policy/arm/AZPOL_701a595d-38fb-4a66-ae6d-fb3735217622.json` |
+| `71ef260a-8f18-47b7-abcb-62d0673d94dc` | Azure AI Services resources should have key access disabled (disable local authentication) | `policies/azure-policy/arm/AZPOL_71ef260a-8f18-47b7-abcb-62d0673d94dc.json` |
+| `72923a3a-e567-46d3-b3f9-ffb2462a1c3a` | Virtual Hubs should be protected with Azure Firewall | `policies/azure-policy/arm/AZPOL_72923a3a-e567-46d3-b3f9-ffb2462a1c3a.json` |
+| `72d04c29-f87d-4575-9731-419ff16a2757` | App Service apps should be injected into a virtual network | `policies/azure-policy/arm/AZPOL_72d04c29-f87d-4575-9731-419ff16a2757.json` |
+| `73aa9838-1906-4490-aa32-f7b89a8f4554` | App Service apps should enable outbound non-RFC 1918 traffic to Azure Virtual Network. | `policies/azure-policy/arm/AZPOL_73aa9838-1906-4490-aa32-f7b89a8f4554.json` |
+| `73ef9241-5d81-4cd4-b483-8443d1730fe5` | API Management service should use a SKU that supports virtual networks | `policies/azure-policy/arm/AZPOL_73ef9241-5d81-4cd4-b483-8443d1730fe5.json` |
+| `74c5a0ae-5e48-4738-b093-65e23a060488` | Public network access should be disabled for Batch accounts | `policies/azure-policy/arm/AZPOL_74c5a0ae-5e48-4738-b093-65e23a060488.json` |
+| `75262d3e-ba4a-4f43-85f8-9f72c090e5e3` | Secrets should have content type set | `policies/azure-policy/arm/AZPOL_75262d3e-ba4a-4f43-85f8-9f72c090e5e3.json` |
+| `75c4f823-d65c-4f29-a733-01d0077fdbcb` | Keys should be the specified cryptographic type RSA or EC | `policies/azure-policy/arm/AZPOL_75c4f823-d65c-4f29-a733-01d0077fdbcb.json` |
+| `7698f4ed-80ce-4e13-b408-ee135fa400a5` | ElasticSan Volume Group should use customer-managed keys to encrypt data at rest | `policies/azure-policy/arm/AZPOL_7698f4ed-80ce-4e13-b408-ee135fa400a5.json` |
+| `77d40665-3120-4348-b539-3192ec808307` | Azure Data Factory should use a Git repository for source control | `policies/azure-policy/arm/AZPOL_77d40665-3120-4348-b539-3192ec808307.json` |
+| `78215662-041e-49ed-a9dd-5385911b3a1f` | Azure SQL Managed Instances should have Microsoft Entra-only authentication enabled during creation | `policies/azure-policy/arm/AZPOL_78215662-041e-49ed-a9dd-5385911b3a1f.json` |
+| `783ea2a8-b8fd-46be-896a-9ae79643a0b1` | Container Apps should disable external network access | `policies/azure-policy/arm/AZPOL_783ea2a8-b8fd-46be-896a-9ae79643a0b1.json` |
+| `794d77cc-fe65-4801-8514-230c0be387a8` | Azure Firewall Classic Rules should be migrated to Firewall Policy | `policies/azure-policy/arm/AZPOL_794d77cc-fe65-4801-8514-230c0be387a8.json` |
+| `797b37f7-06b8-444c-b1ad-fc62867f335a` | Azure Cosmos DB should disable public network access | `policies/azure-policy/arm/AZPOL_797b37f7-06b8-444c-b1ad-fc62867f335a.json` |
+| `7c591a93-c34c-464c-94ac-8f9f9a46e3d6` | Azure Firewall Standard - Classic Rules should enable Threat Intelligence | `policies/azure-policy/arm/AZPOL_7c591a93-c34c-464c-94ac-8f9f9a46e3d6.json` |
+| `7c5a74bf-ae94-4a74-8fcf-644d1e0e6e6f` | [Deprecated]: [Deprecated]: Require blob encryption for storage accounts | `policies/azure-policy/arm/AZPOL_7c5a74bf-ae94-4a74-8fcf-644d1e0e6e6f.json` |
+| `7d092e0a-7acd-40d2-a975-dca21cae48c4` | [Deprecated]: Azure Cache for Redis should reside within a virtual network | `policies/azure-policy/arm/AZPOL_7d092e0a-7acd-40d2-a975-dca21cae48c4.json` |
+| `7d7be79c-23ba-4033-84dd-45e2a5ccdd67` | Both operating systems and data disks in Azure Kubernetes Service clusters should be encrypted by customer-managed keys | `policies/azure-policy/arm/AZPOL_7d7be79c-23ba-4033-84dd-45e2a5ccdd67.json` |
+| `80a53f01-1f9a-49af-a5d5-0248e947dc8e` | App Service apps should provide an auto-generated domain name label scope | `policies/azure-policy/arm/AZPOL_80a53f01-1f9a-49af-a5d5-0248e947dc8e.json` |
+| `81e74cea-30fd-40d5-802f-d72103c2aaaa` | Azure Data Explorer encryption at rest should use a customer-managed key | `policies/azure-policy/arm/AZPOL_81e74cea-30fd-40d5-802f-d72103c2aaaa.json` |
+| `82339799-d096-41ae-8538-b108becf0970` | Geo-redundant backup should be enabled for Azure Database for MySQL | `policies/azure-policy/arm/AZPOL_82339799-d096-41ae-8538-b108becf0970.json` |
+| `82909236-25f3-46a6-841c-fe1020f95ae1` | Azure Web PubSub Service should use a SKU that supports private link | `policies/azure-policy/arm/AZPOL_82909236-25f3-46a6-841c-fe1020f95ae1.json` |
+| `83a86a26-fd1f-447c-b59d-e51f44264114` | Network interfaces should not have public IPs | `policies/azure-policy/arm/AZPOL_83a86a26-fd1f-447c-b59d-e51f44264114.json` |
+| `84497762-32b6-4ab3-80b6-732ea48b85a2` | Container registries should prevent cache rule creation | `policies/azure-policy/arm/AZPOL_84497762-32b6-4ab3-80b6-732ea48b85a2.json` |
+| `85b005b2-95fc-4953-b9cb-f9ee6427c754` | [Preview]: Storage Accounts should be Zone Redundant | `policies/azure-policy/arm/AZPOL_85b005b2-95fc-4953-b9cb-f9ee6427c754.json` |
+| `85bb39b5-2f66-49f8-9306-77da3ac5130f` | Azure Data Factory integration runtime should have a limit for number of cores | `policies/azure-policy/arm/AZPOL_85bb39b5-2f66-49f8-9306-77da3ac5130f.json` |
+| `8632b003-3545-4b29-85e6-b2b96773df1e` | Azure Event Grid partner namespaces should have local authentication methods disabled | `policies/azure-policy/arm/AZPOL_8632b003-3545-4b29-85e6-b2b96773df1e.json` |
+| `86b1d4f9-358c-407c-9bb0-52bf287b222f` | App Service apps should enable configuration routing to Azure Virtual Network. | `policies/azure-policy/arm/AZPOL_86b1d4f9-358c-407c-9bb0-52bf287b222f.json` |
+| `86efb160-8de7-451d-bc08-5d475b0aadae` | Azure Data Box jobs should use a customer-managed key to encrypt the device unlock password | `policies/azure-policy/arm/AZPOL_86efb160-8de7-451d-bc08-5d475b0aadae.json` |
+| `87ac3038-c07a-4b92-860d-29e270a4f3cd` | Azure Virtual Desktop workspaces should disable public network access | `policies/azure-policy/arm/AZPOL_87ac3038-c07a-4b92-860d-29e270a4f3cd.json` |
+| `87ba29ef-1ab3-4d82-b763-87fcd4f531f7` | Azure Stream Analytics jobs should use customer-managed keys to encrypt data | `policies/azure-policy/arm/AZPOL_87ba29ef-1ab3-4d82-b763-87fcd4f531f7.json` |
+| `88c0b9da-ce96-4b03-9635-f29a937e2900` | Network interfaces should disable IP forwarding | `policies/azure-policy/arm/AZPOL_88c0b9da-ce96-4b03-9635-f29a937e2900.json` |
+| `8945ba5e-918e-4a57-8117-fe615d12e3ba` | All Database Admin on Azure Data Explorer should be disabled | `policies/azure-policy/arm/AZPOL_8945ba5e-918e-4a57-8117-fe615d12e3ba.json` |
+| `898f2439-3333-4713-af25-f1d78bc50556` | Azure Arc Private Link Scopes should disable public network access | `policies/azure-policy/arm/AZPOL_898f2439-3333-4713-af25-f1d78bc50556.json` |
+| `89c8a434-18f0-402c-8147-630a8dea54e0` | App Configuration should use a SKU that supports private link | `policies/azure-policy/arm/AZPOL_89c8a434-18f0-402c-8147-630a8dea54e0.json` |
+| `89f2d532-c53c-4f8f-9afa-4927b1114a0d` | Azure Kubernetes Service Clusters should disable Command Invoke | `policies/azure-policy/arm/AZPOL_89f2d532-c53c-4f8f-9afa-4927b1114a0d.json` |
+| `8b346db6-85af-419b-8557-92cee2c0f9bb` | Container App environments should use network injection | `policies/azure-policy/arm/AZPOL_8b346db6-85af-419b-8557-92cee2c0f9bb.json` |
+| `8bfadddb-ee1c-4639-8911-a38cb8e0b3bd` | Azure Event Grid domains should have local authentication methods disabled | `policies/azure-policy/arm/AZPOL_8bfadddb-ee1c-4639-8911-a38cb8e0b3bd.json` |
+| `8bfe3603-0888-404a-87ff-5c1b6b4cc5e3` | [Deprecated]: Azure Media Services accounts should disable public network access | `policies/azure-policy/arm/AZPOL_8bfe3603-0888-404a-87ff-5c1b6b4cc5e3.json` |
+| `8c19196d-7fd7-45b2-a9b4-7288f47c769a` | Azure Firewall Standard should be upgraded to Premium for next generation protection | `policies/azure-policy/arm/AZPOL_8c19196d-7fd7-45b2-a9b4-7288f47c769a.json` |
+| `8c6a50c6-9ffd-4ae7-986f-5fa6111f9a54` | Storage accounts should prevent shared key access | `policies/azure-policy/arm/AZPOL_8c6a50c6-9ffd-4ae7-986f-5fa6111f9a54.json` |
+| `8f09fda1-91a2-4e14-96a2-67c6281158f7` | Do not allow creation of Recovery Services vaults of chosen storage redundancy. | `policies/azure-policy/arm/AZPOL_8f09fda1-91a2-4e14-96a2-67c6281158f7.json` |
+| `904152e4-4038-48ae-9f41-b67ff81eda26` | Function apps should be injected into a virtual network | `policies/azure-policy/arm/AZPOL_904152e4-4038-48ae-9f41-b67ff81eda26.json` |
+| `90bc8109-d21a-4692-88fc-51419391da3d` | [Preview]: Azure AI Search Service should be Zone Redundant | `policies/azure-policy/arm/AZPOL_90bc8109-d21a-4692-88fc-51419391da3d.json` |
+| `9285c3de-d5fd-4225-86d4-027894b0c442` | [Deprecated]: Azure Media Services should use customer-managed keys to encrypt data at rest | `policies/azure-policy/arm/AZPOL_9285c3de-d5fd-4225-86d4-027894b0c442.json` |
+| `92a89a79-6c52-4a7e-a03f-61306fc49312` | Storage accounts should prevent cross tenant object replication | `policies/azure-policy/arm/AZPOL_92a89a79-6c52-4a7e-a03f-61306fc49312.json` |
+| `92bb331d-ac71-416a-8c91-02f2cb734ce4` | API Management calls to API backends should not bypass certificate thumbprint or name validation | `policies/azure-policy/arm/AZPOL_92bb331d-ac71-416a-8c91-02f2cb734ce4.json` |
+| `94c19f19-8192-48cd-a11b-e37099d3e36b` | [Deprecated]: Allow resource creation only in European data centers | `policies/azure-policy/arm/AZPOL_94c19f19-8192-48cd-a11b-e37099d3e36b.json` |
+| `94c1f94d-33b0-4062-bd04-1cdc3e7eece2` | Azure Log Search Alerts over Log Analytics workspaces should use customer-managed keys | `policies/azure-policy/arm/AZPOL_94c1f94d-33b0-4062-bd04-1cdc3e7eece2.json` |
+| `94de2ad3-e0c1-4caf-ad78-5d47bbc83d3d` | Virtual networks should be protected by Azure DDoS Protection | `policies/azure-policy/arm/AZPOL_94de2ad3-e0c1-4caf-ad78-5d47bbc83d3d.json` |
+| `955a914f-bf86-4f0e-acd5-e0766b0efcb6` | Automation accounts should disable public network access | `policies/azure-policy/arm/AZPOL_955a914f-bf86-4f0e-acd5-e0766b0efcb6.json` |
+| `96561daf-13ae-4008-9300-638df7e6a11a` | Azure Health Bots should use Azure RBAC as their access control method | `policies/azure-policy/arm/AZPOL_96561daf-13ae-4008-9300-638df7e6a11a.json` |
+| `967a4b4b-2da9-43c1-b7d0-f98d0d74d0b1` | App Configuration should use a customer-managed key | `policies/azure-policy/arm/AZPOL_967a4b4b-2da9-43c1-b7d0-f98d0d74d0b1.json` |
+| `969ac98b-88a8-449f-883c-2e9adb123127` | Function apps should disable public network access | `policies/azure-policy/arm/AZPOL_969ac98b-88a8-449f-883c-2e9adb123127.json` |
+| `970f84d8-71b6-4091-9979-ace7e3fb6dbb` | HPC Cache accounts should use customer-managed key for encryption | `policies/azure-policy/arm/AZPOL_970f84d8-71b6-4091-9979-ace7e3fb6dbb.json` |
+| `97566dd7-78ae-4997-8b36-1c7bfe0d8121` | [Preview]: Secure Boot should be enabled on supported Windows virtual machines | `policies/azure-policy/arm/AZPOL_97566dd7-78ae-4997-8b36-1c7bfe0d8121.json` |
+| `9798d31d-6028-4dee-8643-46102185c016` | Soft delete should be enabled for Backup Vaults | `policies/azure-policy/arm/AZPOL_9798d31d-6028-4dee-8643-46102185c016.json` |
+| `983211ba-f348-4758-983b-21fa29294869` | [Deprecated]: Allow resource creation only in United States data centers | `policies/azure-policy/arm/AZPOL_983211ba-f348-4758-983b-21fa29294869.json` |
+| `98728c90-32c7-4049-8429-847dc0f4fe37` | Key Vault secrets should have an expiration date | `policies/azure-policy/arm/AZPOL_98728c90-32c7-4049-8429-847dc0f4fe37.json` |
+| `993c2fcd-2b29-49d2-9eb0-df2c3a730c32` | Azure Kubernetes Service Clusters should have local authentication methods disabled | `policies/azure-policy/arm/AZPOL_993c2fcd-2b29-49d2-9eb0-df2c3a730c32.json` |
+| `99e9ccd8-3db9-4592-b0d1-14b1715a4d8a` | Azure Batch account should use customer-managed keys to encrypt data | `policies/azure-policy/arm/AZPOL_99e9ccd8-3db9-4592-b0d1-14b1715a4d8a.json` |
+| `9ad2fd1f-b25f-47a2-aa01-1a5a779e6413` | Virtual network injection should be enabled for Azure Data Explorer | `policies/azure-policy/arm/AZPOL_9ad2fd1f-b25f-47a2-aa01-1a5a779e6413.json` |
+| `9c25c9e4-ee12-4882-afd2-11fb9d87893f` | Azure Databricks Workspaces should be in a virtual network | `policies/azure-policy/arm/AZPOL_9c25c9e4-ee12-4882-afd2-11fb9d87893f.json` |
+| `9d2b0a20-57d6-474c-9d12-44a4a20999c6` | [Preview]: Container Registry should be Zone Redundant | `policies/azure-policy/arm/AZPOL_9d2b0a20-57d6-474c-9d12-44a4a20999c6.json` |
+| `9db7917b-1607-4e7d-a689-bca978dd0633` | Application definition for Managed Application should use customer provided storage account | `policies/azure-policy/arm/AZPOL_9db7917b-1607-4e7d-a689-bca978dd0633.json` |
+| `9dfea752-dd46-4766-aed1-c355fa93fb91` | Azure SQL Managed Instances should disable public network access | `policies/azure-policy/arm/AZPOL_9dfea752-dd46-4766-aed1-c355fa93fb91.json` |
+| `9ebbbba3-4d65-4da9-bb67-b22cfaaff090` | Azure Recovery Services vaults should disable public network access | `policies/azure-policy/arm/AZPOL_9ebbbba3-4d65-4da9-bb67-b22cfaaff090.json` |
+| `9f2dea28-e834-476c-99c5-3507b4728395` | Container registries should have anonymous authentication disabled. | `policies/azure-policy/arm/AZPOL_9f2dea28-e834-476c-99c5-3507b4728395.json` |
+| `9fac9537-cba6-480a-97dc-21a93c1aa055` | Microsoft Planetary Computer Pro GeoCatalogs should use a managed identity | `policies/azure-policy/arm/AZPOL_9fac9537-cba6-480a-97dc-21a93c1aa055.json` |
+| `a049bf77-880b-470f-ba6d-9f21c530cf83` | Azure AI Search service should use a SKU that supports private link | `policies/azure-policy/arm/AZPOL_a049bf77-880b-470f-ba6d-9f21c530cf83.json` |
+| `a08f2347-fe9c-482b-a944-f6a0e05124c0` | Azure Managed Grafana workspaces should disable Grafana Enterprise upgrade | `policies/azure-policy/arm/AZPOL_a08f2347-fe9c-482b-a944-f6a0e05124c0.json` |
+| `a1181c5f-672a-477a-979a-7d58aa086233` | Security Center standard pricing tier should be selected | `policies/azure-policy/arm/AZPOL_a1181c5f-672a-477a-979a-7d58aa086233.json` |
+| `a1840de2-8088-4ea8-b153-b4c723e9cb01` | Azure Kubernetes Service clusters should have Defender profile enabled | `policies/azure-policy/arm/AZPOL_a1840de2-8088-4ea8-b153-b4c723e9cb01.json` |
+| `a22065a3-3b04-46ff-b84c-2d30e5c300d0` | Azure Virtual Desktop hostpools should disable public network access only on session hosts | `policies/azure-policy/arm/AZPOL_a22065a3-3b04-46ff-b84c-2d30e5c300d0.json` |
+| `a451c1ef-c6ca-483d-87ed-f49761e3ffb5` | Audit usage of custom RBAC roles | `policies/azure-policy/arm/AZPOL_a451c1ef-c6ca-483d-87ed-f49761e3ffb5.json` |
+| `a46d869a-f759-4404-8289-7737e7a1f79e` | Public network access should be disabled for Azure Maps Accounts. | `policies/azure-policy/arm/AZPOL_a46d869a-f759-4404-8289-7737e7a1f79e.json` |
+| `a499fed8-bcc8-4195-b154-641f14743757` | Azure Monitor Private Link Scope should block access to non private link resources | `policies/azure-policy/arm/AZPOL_a499fed8-bcc8-4195-b154-641f14743757.json` |
+| `a4af4a39-4135-47fb-b175-47fbdf85311d` | App Service apps should only be accessible over HTTPS | `policies/azure-policy/arm/AZPOL_a4af4a39-4135-47fb-b175-47fbdf85311d.json` |
+| `a6e9cf2d-7d76-440e-b795-8da246bd3aab` | Lab Services should enable all options for auto shutdown | `policies/azure-policy/arm/AZPOL_a6e9cf2d-7d76-440e-b795-8da246bd3aab.json` |
+| `a7ff3161-0087-490a-9ad9-ad6217f4f43a` | Require encryption on Data Lake Store accounts | `policies/azure-policy/arm/AZPOL_a7ff3161-0087-490a-9ad9-ad6217f4f43a.json` |
+| `a8793640-60f7-487c-b5c3-1d37215905c4` | SQL Managed Instance should have the minimal TLS version of 1.2 | `policies/azure-policy/arm/AZPOL_a8793640-60f7-487c-b5c3-1d37215905c4.json` |
+| `a88d589f-2b09-4b50-8998-9a4e71d7b746` | App Service app slots should disable SSH | `policies/azure-policy/arm/AZPOL_a88d589f-2b09-4b50-8998-9a4e71d7b746.json` |
+| `ac01ad65-10e5-46df-bdd9-6b0cad13e1d2` | SQL managed instances should use customer-managed keys to encrypt data at rest | `policies/azure-policy/arm/AZPOL_ac01ad65-10e5-46df-bdd9-6b0cad13e1d2.json` |
+| `ac4a19c2-fa67-49b4-8ae5-0b2e78c49457` | Role-Based Access Control (RBAC) should be used on Kubernetes Services | `policies/azure-policy/arm/AZPOL_ac4a19c2-fa67-49b4-8ae5-0b2e78c49457.json` |
+| `ac7e5fc0-c029-4b12-91d4-a8500ce697f9` | [Deprecated]: Allow resource creation if 'environment' tag value in allowed values | `policies/azure-policy/arm/AZPOL_ac7e5fc0-c029-4b12-91d4-a8500ce697f9.json` |
+| `ae1b9a8c-dfce-4605-bd91-69213b4a26fc` | App Service app slots should only be accessible over HTTPS | `policies/azure-policy/arm/AZPOL_ae1b9a8c-dfce-4605-bd91-69213b4a26fc.json` |
+| `ae243d87-5cf3-4dce-90bd-6d62be328de3` | [Preview]: Backup and Site Recovery should be Zone Redundant | `policies/azure-policy/arm/AZPOL_ae243d87-5cf3-4dce-90bd-6d62be328de3.json` |
+| `ae243d87-5cf3-4dce-90bd-6d62be328de9` | [Preview]: Event Hubs should be Zone Redundant | `policies/azure-policy/arm/AZPOL_ae243d87-5cf3-4dce-90bd-6d62be328de9.json` |
+| `ae9fb87f-8a17-4428-94a4-8135d431055c` | Azure Event Grid topics should have local authentication methods disabled | `policies/azure-policy/arm/AZPOL_ae9fb87f-8a17-4428-94a4-8135d431055c.json` |
+| `aec63c84-f9ea-46c7-9e66-ba567bae0f09` | [Deprecated]: Packet Core Control Plane diagnostic access should only use Microsoft EntraID authentication type | `policies/azure-policy/arm/AZPOL_aec63c84-f9ea-46c7-9e66-ba567bae0f09.json` |
+| `af1d7e88-c1c8-4ea8-be1f-87bff0df9101` | App Service apps should enable end to end encryption | `policies/azure-policy/arm/AZPOL_af1d7e88-c1c8-4ea8-be1f-87bff0df9101.json` |
+| `af35e2a4-ef96-44e7-a9ae-853dd97032c4` | Azure Spring Cloud should use network injection | `policies/azure-policy/arm/AZPOL_af35e2a4-ef96-44e7-a9ae-853dd97032c4.json` |
+| `af3c26b2-6fad-493e-9236-9c68928516ab` | Azure Kubernetes Service Clusters should enable Image Cleaner | `policies/azure-policy/arm/AZPOL_af3c26b2-6fad-493e-9236-9c68928516ab.json` |
+| `b03bb370-5249-4ea4-9fce-2552e87e45fa` | Disks and OS image should support TrustedLaunch | `policies/azure-policy/arm/AZPOL_b03bb370-5249-4ea4-9fce-2552e87e45fa.json` |
+| `b0779e02-7159-4057-bcbf-bd6132847d2b` | App Service apps should disable public network access. | `policies/azure-policy/arm/AZPOL_b0779e02-7159-4057-bcbf-bd6132847d2b.json` |
+| `b08ab3ca-1062-4db3-8803-eec9cae605d6` | App Configuration stores should have local authentication methods disabled | `policies/azure-policy/arm/AZPOL_b08ab3ca-1062-4db3-8803-eec9cae605d6.json` |
+| `b2982f36-99f2-4db5-8eff-283140c09693` | Storage accounts should disable public network access | `policies/azure-policy/arm/AZPOL_b2982f36-99f2-4db5-8eff-283140c09693.json` |
+| `b3ba835a-f47b-4b3a-8d90-f08b006f2fd0` | App Service apps should use a SKU that supports private link. | `policies/azure-policy/arm/AZPOL_b3ba835a-f47b-4b3a-8d90-f08b006f2fd0.json` |
+| `b4ac1030-89c5-4697-8e00-28b5ba6a8811` | Azure Stack Edge devices should use double-encryption | `policies/azure-policy/arm/AZPOL_b4ac1030-89c5-4697-8e00-28b5ba6a8811.json` |
+| `b52376f7-9612-48a1-81cd-1ffe4b61032c` | Public network access should be disabled for PostgreSQL servers | `policies/azure-policy/arm/AZPOL_b52376f7-9612-48a1-81cd-1ffe4b61032c.json` |
+| `b546c248-6dee-41e6-82f7-978634aeb73d` | App Service app configs should restrict default network access in Mission Platform products and services | `policies/azure-policy/arm/AZPOL_b546c248-6dee-41e6-82f7-978634aeb73d.json` |
+| `b54ed75b-3e1a-44ac-a333-05ba39b99ff0` | Service Fabric clusters should only use Azure Active Directory for client authentication | `policies/azure-policy/arm/AZPOL_b54ed75b-3e1a-44ac-a333-05ba39b99ff0.json` |
+| `b5c4b32e-8699-4bfa-8868-9ed118e9982f` | App Service Environment apps should not be reachable over public internet. | `policies/azure-policy/arm/AZPOL_b5c4b32e-8699-4bfa-8868-9ed118e9982f.json` |
+| `b5ec538c-daa0-4006-8596-35468b9148e8` | Storage account encryption scopes should use customer-managed keys to encrypt data at rest | `policies/azure-policy/arm/AZPOL_b5ec538c-daa0-4006-8596-35468b9148e8.json` |
+| `b66ab71c-582d-4330-adfd-ac162e78691e` | Azure Web PubSub Service should have local authentication methods disabled | `policies/azure-policy/arm/AZPOL_b66ab71c-582d-4330-adfd-ac162e78691e.json` |
+| `b6752a42-6fc3-46cb-8a15-33aa109407b1` | Azure Managed Grafana workspaces should disable email settings | `policies/azure-policy/arm/AZPOL_b6752a42-6fc3-46cb-8a15-33aa109407b1.json` |
+| `b741306c-968e-4b67-b916-5675e5c709f4` | API Management direct management endpoint should not be enabled | `policies/azure-policy/arm/AZPOL_b741306c-968e-4b67-b916-5675e5c709f4.json` |
+| `b7ddfbdc-1260-477d-91fd-98bd9be789a6` | [Deprecated]: API App should only be accessible over HTTPS | `policies/azure-policy/arm/AZPOL_b7ddfbdc-1260-477d-91fd-98bd9be789a6.json` |
+| `b874ab2d-72dd-47f1-8cb5-4a306478a4e7` | Managed Identity should be enabled for Container Apps | `policies/azure-policy/arm/AZPOL_b874ab2d-72dd-47f1-8cb5-4a306478a4e7.json` |
+| `ba769a63-b8cc-4b2d-abf6-ac33c7204be8` | Azure Machine Learning workspaces should be encrypted with a customer-managed key | `policies/azure-policy/arm/AZPOL_ba769a63-b8cc-4b2d-abf6-ac33c7204be8.json` |
+| `bb3c7464-033e-41ee-81dc-480fde675b20` | TLS protocol 1.2 must be used for Arc SQL managed instances. | `policies/azure-policy/arm/AZPOL_bb3c7464-033e-41ee-81dc-480fde675b20.json` |
+| `bc1b984e-ddae-40cc-801a-050a030e4fbe` | Storage accounts should have shared access signature (SAS) policies configured | `policies/azure-policy/arm/AZPOL_bc1b984e-ddae-40cc-801a-050a030e4fbe.json` |
+| `bcff6755-335b-484d-b435-d1161db39cdc` | Communication service resource should use a managed identity | `policies/azure-policy/arm/AZPOL_bcff6755-335b-484d-b435-d1161db39cdc.json` |
+| `bd560fc0-3c69-498a-ae9f-aa8eb7de0e13` | Container registries should have SKUs that support Private Links | `policies/azure-policy/arm/AZPOL_bd560fc0-3c69-498a-ae9f-aa8eb7de0e13.json` |
+| `bd58d393-162c-4134-bcd6-a6a5484a37a1` | The legacy Log Analytics extension should not be installed on Azure Arc enabled Linux servers | `policies/azure-policy/arm/AZPOL_bd58d393-162c-4134-bcd6-a6a5484a37a1.json` |
+| `bd876905-5b84-4f73-ab2d-2e7a7c4568d9` | Machines should be configured to periodically check for missing system updates | `policies/azure-policy/arm/AZPOL_bd876905-5b84-4f73-ab2d-2e7a7c4568d9.json` |
+| `bdd8bbb2-1efd-48dc-a0fd-8ddcba2e96cd` | [Preview]: Azure Managed Grafana should be Zone Redundant | `policies/azure-policy/arm/AZPOL_bdd8bbb2-1efd-48dc-a0fd-8ddcba2e96cd.json` |
+| `be7ed5c8-2660-4136-8216-e6f3412ba909` | [Deprecated]: Web Application Firewall should be enabled for Azure Front Door Service or Application Gateway | `policies/azure-policy/arm/AZPOL_be7ed5c8-2660-4136-8216-e6f3412ba909.json` |
+| `bf045164-79ba-4215-8f95-f8048dc1780b` | Geo-redundant storage should be enabled for Storage Accounts | `policies/azure-policy/arm/AZPOL_bf045164-79ba-4215-8f95-f8048dc1780b.json` |
+| `bf45113f-264e-4a87-88f9-29ac8a0aca6a` | Azure Web PubSub Service should disable public network access | `policies/azure-policy/arm/AZPOL_bf45113f-264e-4a87-88f9-29ac8a0aca6a.json` |
+| `bfecdea6-31c4-4045-ad42-71b9dc87247d` | Storage account encryption scopes should use double encryption for data at rest | `policies/azure-policy/arm/AZPOL_bfecdea6-31c4-4045-ad42-71b9dc87247d.json` |
+| `c1b9cbed-08e3-427d-b9ce-7c535b1e9b94` | [Deprecated]: Allow resource creation only in Asia data centers | `policies/azure-policy/arm/AZPOL_c1b9cbed-08e3-427d-b9ce-7c535b1e9b94.json` |
+| `c25dcf31-878f-4eba-98eb-0818fdc6a334` | Azure Virtual Desktop hostpools should disable public network access | `policies/azure-policy/arm/AZPOL_c25dcf31-878f-4eba-98eb-0818fdc6a334.json` |
+| `c349d81b-9985-44ae-a8da-ff98d108ede8` | Azure Data Box jobs should enable double encryption for data at rest on the device | `policies/azure-policy/arm/AZPOL_c349d81b-9985-44ae-a8da-ff98d108ede8.json` |
+| `c39ba22d-4428-4149-b981-70acb31fc383` | Azure Key Vault Managed HSM should have purge protection enabled | `policies/azure-policy/arm/AZPOL_c39ba22d-4428-4149-b981-70acb31fc383.json` |
+| `c42dee8c-0202-4a12-bd8e-3e171cbf64dd` | FHIR Service should use a customer-managed key to encrypt data at rest | `policies/azure-policy/arm/AZPOL_c42dee8c-0202-4a12-bd8e-3e171cbf64dd.json` |
+| `c4857be7-912a-4c75-87e6-e30292bcdf78` | [Preview]: Container Registry should use a virtual network service endpoint | `policies/azure-policy/arm/AZPOL_c4857be7-912a-4c75-87e6-e30292bcdf78.json` |
+| `c58e083e-7982-4e24-afdc-be14d312389e` | Multi-User Authorization (MUA) must be enabled for Backup Vaults. | `policies/azure-policy/arm/AZPOL_c58e083e-7982-4e24-afdc-be14d312389e.json` |
+| `c5f34731-7ab9-42ff-922d-ef4920068b74` | Azure Health Data Services de-identification service should disable public network access | `policies/azure-policy/arm/AZPOL_c5f34731-7ab9-42ff-922d-ef4920068b74.json` |
+| `c7031eab-0fc0-4cd9-acd0-4497bd66d91a` | Multi-User Authorization (MUA) must be enabled for Recovery Services Vaults. | `policies/azure-policy/arm/AZPOL_c7031eab-0fc0-4cd9-acd0-4497bd66d91a.json` |
+| `c9299215-ae47-4f50-9c54-8a392f68a052` | Public network access should be disabled for MySQL flexible servers | `policies/azure-policy/arm/AZPOL_c9299215-ae47-4f50-9c54-8a392f68a052.json` |
+| `c95b54ad-0614-4633-ab29-104b01235cbf` | Virtual Machine should have TrustedLaunch enabled | `policies/azure-policy/arm/AZPOL_c95b54ad-0614-4633-ab29-104b01235cbf.json` |
+| `c9d007d0-c057-4772-b18c-01e546713bcd` | Storage accounts should allow access from trusted Microsoft services | `policies/azure-policy/arm/AZPOL_c9d007d0-c057-4772-b18c-01e546713bcd.json` |
+| `ca85ef9a-741d-461d-8b7a-18c2da82c666` | Azure Web Application Firewall on Azure Application Gateway should have request body inspection enabled | `policies/azure-policy/arm/AZPOL_ca85ef9a-741d-461d-8b7a-18c2da82c666.json` |
+| `ca91455f-eace-4f96-be59-e6e2c35b4816` | Managed disks should be double encrypted with both platform-managed and customer-managed keys | `policies/azure-policy/arm/AZPOL_ca91455f-eace-4f96-be59-e6e2c35b4816.json` |
+| `cb3738a6-82a2-4a18-b87b-15217b9deff4` | Azure Synapse Workspace SQL Server should be running TLS version 1.2 or newer | `policies/azure-policy/arm/AZPOL_cb3738a6-82a2-4a18-b87b-15217b9deff4.json` |
+| `cbd11fd3-3002-4907-b6c8-579f0e700e13` | Service Bus Namespaces should disable public network access | `policies/azure-policy/arm/AZPOL_cbd11fd3-3002-4907-b6c8-579f0e700e13.json` |
+| `cbe0e5eb-fea9-491d-ab20-a62cf049c5ae` | Function app slots should enable end to end encryption | `policies/azure-policy/arm/AZPOL_cbe0e5eb-fea9-491d-ab20-a62cf049c5ae.json` |
+| `cd870362-211d-4cad-9ad9-11e5ea4ebbc1` | Public network access should be disabled for IoT Central | `policies/azure-policy/arm/AZPOL_cd870362-211d-4cad-9ad9-11e5ea4ebbc1.json` |
+| `cee2f9fd-3968-44be-a863-bd62c9884423` | Geo-redundant backup should be enabled for Azure Database for PostgreSQL flexible servers | `policies/azure-policy/arm/AZPOL_cee2f9fd-3968-44be-a863-bd62c9884423.json` |
+| `cf9ca02d-383e-4506-a421-258cc1a5300d` | [Deprecated]: Function app slots should have 'Client Certificates (Incoming client certificates)' enabled | `policies/azure-policy/arm/AZPOL_cf9ca02d-383e-4506-a421-258cc1a5300d.json` |
+| `cfb11c26-f069-4c14-8e36-56c394dae5af` | Azure Service Bus namespaces should have local authentication methods disabled | `policies/azure-policy/arm/AZPOL_cfb11c26-f069-4c14-8e36-56c394dae5af.json` |
+| `d074ddf8-01a5-4b5e-a2b8-964aed452c0a` | Container Apps environment should disable public network access | `policies/azure-policy/arm/AZPOL_d074ddf8-01a5-4b5e-a2b8-964aed452c0a.json` |
+| `d158790f-bfb0-486c-8631-2dc6b4e8e6af` | Enforce SSL connection should be enabled for PostgreSQL database servers | `policies/azure-policy/arm/AZPOL_d158790f-bfb0-486c-8631-2dc6b4e8e6af.json` |
+| `d1a5414f-ada4-46bf-bea7-243d5100d981` | App Service app slots should provide an auto-generated domain name label scope | `policies/azure-policy/arm/AZPOL_d1a5414f-ada4-46bf-bea7-243d5100d981.json` |
+| `d2185817-5b7e-473c-aadd-9de6ac114280` | The legacy Log Analytics extension should not be installed on virtual machines | `policies/azure-policy/arm/AZPOL_d2185817-5b7e-473c-aadd-9de6ac114280.json` |
+| `d3ee5dcf-0c6d-49ab-aee4-f250583a7bdc` | [Preview]: Service Bus should be Zone Redundant | `policies/azure-policy/arm/AZPOL_d3ee5dcf-0c6d-49ab-aee4-f250583a7bdc.json` |
+| `d4b065e2-fbda-4461-a42c-b0346aeb12a0` | The legacy Log Analytics extension should not be installed on Linux virtual machines | `policies/azure-policy/arm/AZPOL_d4b065e2-fbda-4461-a42c-b0346aeb12a0.json` |
+| `d550e854-df1a-4de9-bf44-cd894b39a95e` | Azure Monitor Logs for Application Insights should be linked to a Log Analytics workspace | `policies/azure-policy/arm/AZPOL_d550e854-df1a-4de9-bf44-cd894b39a95e.json` |
+| `d6588149-9f06-462c-a076-56aece45b5ba` | Azure Backup Vaults should use customer-managed keys for encrypting backup data. Also an option to enforce Infra Encryption. | `policies/azure-policy/arm/AZPOL_d6588149-9f06-462c-a076-56aece45b5ba.json` |
+| `d6f6f560-14b7-49a4-9fc8-d2c3a9807868` | Immutability must be enabled for Recovery Services vaults | `policies/azure-policy/arm/AZPOL_d6f6f560-14b7-49a4-9fc8-d2c3a9807868.json` |
+| `d79ab062-dffd-4318-8344-f70de714c0bc` | [Deprecated]: App Service should disable public network access | `policies/azure-policy/arm/AZPOL_d79ab062-dffd-4318-8344-f70de714c0bc.json` |
+| `d82101f3-f3ce-4fc5-8708-4c09f4009546` | IoT Hub device provisioning service instances should disable public network access | `policies/azure-policy/arm/AZPOL_d82101f3-f3ce-4fc5-8708-4c09f4009546.json` |
+| `d82527a7-91cd-409f-b96e-049600b16b9e` | Durable Task schedulers should not allow open IP allowlists | `policies/azure-policy/arm/AZPOL_d82527a7-91cd-409f-b96e-049600b16b9e.json` |
+| `d855fd7a-9be5-4d84-8b75-28d41aadc158` | [Preview]: Load tests using Azure Load Testing should be run only against private endpoints from within a virtual network. | `policies/azure-policy/arm/AZPOL_d855fd7a-9be5-4d84-8b75-28d41aadc158.json` |
+| `d9844e8a-1437-4aeb-a32c-0c992f056095` | Public network access should be disabled for MySQL servers | `policies/azure-policy/arm/AZPOL_d9844e8a-1437-4aeb-a32c-0c992f056095.json` |
+| `d9da03a1-f3c3-412a-9709-947156872263` | Azure HDInsight clusters should use encryption in transit to encrypt communication between Azure HDInsight cluster nodes | `policies/azure-policy/arm/AZPOL_d9da03a1-f3c3-412a-9709-947156872263.json` |
+| `da6e2401-19da-4532-9141-fb8fbde08431` | Azure Kubernetes Service Clusters should use managed identities | `policies/azure-policy/arm/AZPOL_da6e2401-19da-4532-9141-fb8fbde08431.json` |
+| `da79a7e2-8aa1-45ed-af81-ba050c153564` | Azure Firewall Policy should enable Threat Intelligence | `policies/azure-policy/arm/AZPOL_da79a7e2-8aa1-45ed-af81-ba050c153564.json` |
+| `da8a2248-6b4a-44a7-96bf-bf1c0dd208c3` | [Preview]: Virtual network gateways should be Zone Redundant | `policies/azure-policy/arm/AZPOL_da8a2248-6b4a-44a7-96bf-bf1c0dd208c3.json` |
+| `dbbdc317-9734-4dd8-9074-993b29c69008` | Azure Kubernetes Clusters should enable Key Management Service (KMS) | `policies/azure-policy/arm/AZPOL_dbbdc317-9734-4dd8-9074-993b29c69008.json` |
+| `dc595cb1-1cde-45f6-8faf-f88874e1c0e1` | Logic Apps should be deployed into Integration Service Environment | `policies/azure-policy/arm/AZPOL_dc595cb1-1cde-45f6-8faf-f88874e1c0e1.json` |
+| `dc921057-6b28-4fbe-9b83-f7bec05db6c2` | Container registries should have local admin account disabled. | `policies/azure-policy/arm/AZPOL_dc921057-6b28-4fbe-9b83-f7bec05db6c2.json` |
+| `dcbc65aa-59f3-4239-8978-3bb869d82604` | App Service apps should use an Azure file share for its content directory | `policies/azure-policy/arm/AZPOL_dcbc65aa-59f3-4239-8978-3bb869d82604.json` |
+| `dea83a72-443c-4292-83d5-54a2f98749c0` | Automation Account should have Managed Identity | `policies/azure-policy/arm/AZPOL_dea83a72-443c-4292-83d5-54a2f98749c0.json` |
+| `df42d971-b39b-43a4-a987-b29d1bfbffba` | App Service app slots should be injected into a virtual network. | `policies/azure-policy/arm/AZPOL_df42d971-b39b-43a4-a987-b29d1bfbffba.json` |
+| `df441472-4dae-4e4e-87b9-9205ba46be16` | The legacy Log Analytics extension should not be installed on Azure Arc enabled Windows servers | `policies/azure-policy/arm/AZPOL_df441472-4dae-4e4e-87b9-9205ba46be16.json` |
+| `dfb5ac92-ce74-4dbc-81fa-87243e62d5d3` | Azure Firewall Policy Analytics should be Enabled | `policies/azure-policy/arm/AZPOL_dfb5ac92-ce74-4dbc-81fa-87243e62d5d3.json` |
+| `dfc212af-17ea-423a-9dcb-91e2cb2caa6b` | Azure Front Door profiles should use Premium tier that supports managed WAF rules and private link | `policies/azure-policy/arm/AZPOL_dfc212af-17ea-423a-9dcb-91e2cb2caa6b.json` |
+| `e089ecb6-8b98-4b7b-b26b-9bbee7385395` | Storage accounts should use customer-managed key for encryption in Mission Platform products and services | `policies/azure-policy/arm/AZPOL_e089ecb6-8b98-4b7b-b26b-9bbee7385395.json` |
+| `e0a2b1a3-f7f9-4569-807f-2a9edebdf4d9` | Cosmos DB should use a virtual network service endpoint | `policies/azure-policy/arm/AZPOL_e0a2b1a3-f7f9-4569-807f-2a9edebdf4d9.json` |
+| `e15effd4-2278-4c65-a0da-4d6f6d1890e2` | Log Analytics Workspaces should block non-Azure Active Directory based ingestion. | `policies/azure-policy/arm/AZPOL_e15effd4-2278-4c65-a0da-4d6f6d1890e2.json` |
+| `e345b6c3-24bd-4c93-9bbb-7e5e49a17b78` | Azure VPN gateways should not use 'basic' SKU | `policies/azure-policy/arm/AZPOL_e345b6c3-24bd-4c93-9bbb-7e5e49a17b78.json` |
+| `e413671a-dd10-4cc1-a943-45b598596cb7` | Azure Machine Learning workspaces should enable V1LegacyMode to support network isolation backward compatibility | `policies/azure-policy/arm/AZPOL_e413671a-dd10-4cc1-a943-45b598596cb7.json` |
+| `e4953962-5ae4-43eb-bb92-d66fd5563487` | [Preview]: A managed identity should be enabled on your machines | `policies/azure-policy/arm/AZPOL_e4953962-5ae4-43eb-bb92-d66fd5563487.json` |
+| `e5612cea-18dc-40b0-9390-8bf77072d5df` | Encryption in transit should be required for Azure Files SMB and NFS protocols | `policies/azure-policy/arm/AZPOL_e5612cea-18dc-40b0-9390-8bf77072d5df.json` |
+| `e802a67a-daf5-4436-9ea6-f6d821dd0c5d` | Enforce SSL connection should be enabled for MySQL database servers | `policies/azure-policy/arm/AZPOL_e802a67a-daf5-4436-9ea6-f6d821dd0c5d.json` |
+| `e8775d5a-73b7-4977-a39b-833ef0114628` | Azure Managed Grafana workspaces should disable public network access | `policies/azure-policy/arm/AZPOL_e8775d5a-73b7-4977-a39b-833ef0114628.json` |
+| `e8a5a3eb-1ab6-4657-a701-7ae432cf14e1` | Lab Services should not allow template virtual machines for labs | `policies/azure-policy/arm/AZPOL_e8a5a3eb-1ab6-4657-a701-7ae432cf14e1.json` |
+| `e96a9a5f-07ca-471b-9bc5-6a0f33cbd68f` | Azure Machine Learning Computes should have local authentication methods disabled | `policies/azure-policy/arm/AZPOL_e96a9a5f-07ca-471b-9bc5-6a0f33cbd68f.json` |
+| `ea0dfaed-95fb-448c-934e-d6e713ce393d` | Azure Monitor Logs clusters should be created with infrastructure-encryption enabled (double encryption) | `policies/azure-policy/arm/AZPOL_ea0dfaed-95fb-448c-934e-d6e713ce393d.json` |
+| `ea4d6841-2173-4317-9747-ff522a45120f` | Key Vault should use a virtual network service endpoint | `policies/azure-policy/arm/AZPOL_ea4d6841-2173-4317-9747-ff522a45120f.json` |
+| `ea6df8ed-090c-4fda-8379-cb1b428e8fdb` | App Service apps should be injected into a virtual network. | `policies/azure-policy/arm/AZPOL_ea6df8ed-090c-4fda-8379-cb1b428e8fdb.json` |
+| `eaebaea7-8013-4ceb-9d14-7eb32271373c` | [Deprecated]: Function apps should have 'Client Certificates (Incoming client certificates)' enabled | `policies/azure-policy/arm/AZPOL_eaebaea7-8013-4ceb-9d14-7eb32271373c.json` |
+| `eb4d34ab-0929-491c-bbf3-61e13da19f9a` | [Deprecated]: App Service Environment should be provisioned with latest versions | `policies/azure-policy/arm/AZPOL_eb4d34ab-0929-491c-bbf3-61e13da19f9a.json` |
+| `ebaf4f25-a4e8-415f-86a8-42d9155bef0b` | Service Bus namespaces should have double encryption enabled | `policies/azure-policy/arm/AZPOL_ebaf4f25-a4e8-415f-86a8-42d9155bef0b.json` |
+| `ec068d99-e9c7-401f-8cef-5bdde4e6ccf1` | Double encryption should be enabled on Azure Data Explorer | `policies/azure-policy/arm/AZPOL_ec068d99-e9c7-401f-8cef-5bdde4e6ccf1.json` |
+| `ece3c79b-2caf-470d-a5f5-66470c4fc649` | [Preview]: Microsoft Dev Box Pools should not use Microsoft Hosted Networks. | `policies/azure-policy/arm/AZPOL_ece3c79b-2caf-470d-a5f5-66470c4fc649.json` |
+| `ee7495e7-3ba7-40b6-bfee-c29e22cc75d4` | API Management APIs should use only encrypted protocols | `policies/azure-policy/arm/AZPOL_ee7495e7-3ba7-40b6-bfee-c29e22cc75d4.json` |
+| `ee980b6d-0eca-4501-8d54-f6290fd512c3` | Azure AI Search services should disable public network access | `policies/azure-policy/arm/AZPOL_ee980b6d-0eca-4501-8d54-f6290fd512c3.json` |
+| `efa3f296-ff2b-4f38-bc0d-5ef12c965b68` | Azure Arc-enabled servers should be configured with an Azure Arc Private Link Scope | `policies/azure-policy/arm/AZPOL_efa3f296-ff2b-4f38-bc0d-5ef12c965b68.json` |
+| `f0e5abd0-2554-4736-b7c0-4ffef23475ef` | Queue Storage should use customer-managed key for encryption | `policies/azure-policy/arm/AZPOL_f0e5abd0-2554-4736-b7c0-4ffef23475ef.json` |
+| `f16a3ca9-b57a-4392-b660-4c1f8442aa8d` | [Preview]: SQL Elastic database pools should be Zone Redundant | `policies/azure-policy/arm/AZPOL_f16a3ca9-b57a-4392-b660-4c1f8442aa8d.json` |
+| `f4b53539-8df9-40e4-86c6-6b607703bd4e` | Disk encryption should be enabled on Azure Data Explorer | `policies/azure-policy/arm/AZPOL_f4b53539-8df9-40e4-86c6-6b607703bd4e.json` |
+| `f6b68e5a-7207-4638-a1fb-47d90404209e` | [Deprecated]: Web Application Firewall should be a set mode for Application Gateway and Azure Front Door Service | `policies/azure-policy/arm/AZPOL_f6b68e5a-7207-4638-a1fb-47d90404209e.json` |
+| `f7d52b2d-e161-4dfa-a82b-55e564167385` | Azure Synapse workspaces should use customer-managed keys to encrypt data at rest | `policies/azure-policy/arm/AZPOL_f7d52b2d-e161-4dfa-a82b-55e564167385.json` |
+| `f8f774be-6aee-492a-9e29-486ef81f3a68` | Azure Event Grid domains should disable public network access | `policies/azure-policy/arm/AZPOL_f8f774be-6aee-492a-9e29-486ef81f3a68.json` |
+| `fa298e57-9444-42ba-bf04-86e8470e32c7` | Saved-queries in Azure Monitor should be saved in customer storage account for logs encryption | `policies/azure-policy/arm/AZPOL_fa298e57-9444-42ba-bf04-86e8470e32c7.json` |
+| `fa498b91-8a7e-4710-9578-da944c68d1fe` | [Preview]: Azure PostgreSQL flexible server should have Microsoft Entra Only Authentication enabled | `policies/azure-policy/arm/AZPOL_fa498b91-8a7e-4710-9578-da944c68d1fe.json` |
+| `fb893a29-21bb-418c-a157-e99480ec364c` | Kubernetes Services should be upgraded to a non-vulnerable Kubernetes version | `policies/azure-policy/arm/AZPOL_fb893a29-21bb-418c-a157-e99480ec364c.json` |
+| `fb97d6e1-5c98-4743-a439-23e0977bad9e` | [Preview]: Boot Diagnostics should be enabled on virtual machines | `policies/azure-policy/arm/AZPOL_fb97d6e1-5c98-4743-a439-23e0977bad9e.json` |
+| `fc264132-db9c-4302-bb7d-3994c36461fe` | Communication services resource should have local authentication methods disabled | `policies/azure-policy/arm/AZPOL_fc264132-db9c-4302-bb7d-3994c36461fe.json` |
+| `fc4d8e41-e223-45ea-9bf5-eada37891d87` | Virtual machines and virtual machine scale sets should have encryption at host enabled | `policies/azure-policy/arm/AZPOL_fc4d8e41-e223-45ea-9bf5-eada37891d87.json` |
+| `fd34e936-069e-4fe5-bac6-f7c9824caab6` | App Service app slots should use an Azure file share for its content directory | `policies/azure-policy/arm/AZPOL_fd34e936-069e-4fe5-bac6-f7c9824caab6.json` |
+| `fdccbe47-f3e3-4213-ad5d-ea459b2fa077` | Public network access should be disabled for MariaDB servers | `policies/azure-policy/arm/AZPOL_fdccbe47-f3e3-4213-ad5d-ea459b2fa077.json` |
+| `fe3fd216-4f83-4fc1-8984-2bbec80a3418` | Cognitive Services accounts should use a managed identity | `policies/azure-policy/arm/AZPOL_fe3fd216-4f83-4fc1-8984-2bbec80a3418.json` |
+| `fe83a0eb-a853-422d-aac2-1bffd182c5d0` | Storage accounts should have the specified minimum TLS version | `policies/azure-policy/arm/AZPOL_fe83a0eb-a853-422d-aac2-1bffd182c5d0.json` |
+| `ff05e24e-195c-447e-b322-5e90c9f9f366` | Container registries should have repository scoped access token disabled. | `policies/azure-policy/arm/AZPOL_ff05e24e-195c-447e-b322-5e90c9f9f366.json` |
+| `ff1f1879-a60d-4f23-9641-41e7391ec19a` | Azure Application Gateway should be deployed with Azure WAF | `policies/azure-policy/arm/AZPOL_ff1f1879-a60d-4f23-9641-41e7391ec19a.json` |
+| `ff25f3c8-b739-4538-9d07-3d6d25cfb255` | Keys using elliptic curve cryptography should have the specified curve names | `policies/azure-policy/arm/AZPOL_ff25f3c8-b739-4538-9d07-3d6d25cfb255.json` |
+| `ffe25541-3853-4f4e-b71d-064422294b11` | API Management should have username and password authentication disabled | `policies/azure-policy/arm/AZPOL_ffe25541-3853-4f4e-b71d-064422294b11.json` |
+| `ffea632e-4e3a-4424-bf78-10e179bb2e1a` | Bot Service should have local authentication methods disabled | `policies/azure-policy/arm/AZPOL_ffea632e-4e3a-4424-bf78-10e179bb2e1a.json` |
+
+## Blocked
+
+| GUID | Display name | Codes | Reason |
+| --- | --- | --- | --- |
+| `c15dcc82-b93c-4dcb-9332-fbf121685b54` | API Management calls to API backends should be authenticated | T5, T18, T10 | Compliance for an http backend is credentials.certificate being a non-empty list OR both credentials.authorization.scheme and credentials.authorization.parameter existing - a per-resource disjunction across different att |
+| `d5448c98-e503-4fdd-bcd2-784960c00d04` | API Management policies should inherit parent scope policies using | T18, T10 | Upstream reads the policy XML out of `properties.value`, strips \r, \n, space and tab with four nested replace() calls, and then requires the stripped string to contain all four of , , |
+| `801543d1-1953-4a90-b8b0-8cf6d41473a5` | App Service apps should enable configuration routing to Azure Virtual Network | T5, T18 | The rule is a per-resource disjunction over two different attribute sets chosen by the request API version: for `[requestContext().apiVersion]` < 2024-11-01 compliance is all three of `properties.vnetImagePullEnabled`, ` |
+| `a691eacb-474d-47e4-b287-b4813ca44222` | App Service apps should enable outbound application traffic routing to Azure Virtual Network | T5, T18 | The rule is a per-resource disjunction over two different attribute sets chosen by the request API version: for `[requestContext().apiVersion]` < 2024-11-01 compliance is `properties.vnetRouteAllEnabled == "true"`; for > |
+| `0a914e76-4921-4c19-b460-a2d36003525a` | Audit resource location matches resource group location | T14, T8 | The comparison value is `[resourcegroup().location]` - the location of the resource group the deployment targets. That value is not in the ARM template at all: it belongs to the deployment scope, which tirith never sees |
+| `0e246bcf-5f6f-4f87-bc6f-775d4712c7ea` | Authorized IP ranges should be defined on Kubernetes Services | T5 | Upstream fires only when BOTH apiServerAccessProfile.authorizedIPRanges is absent AND the cluster is not private (apiServerAccessProfile.enablePrivateCluster absent or false). Compliance is therefore a per-resource disju |
+| `67121cc7-ff39-4ab8-b7e3-95b84dab487d` | Azure AI Services resources should encrypt data at rest with a customer-managed key (CMK) | T18, T5 | Upstream fires only on accounts whose sibling `kind` is outside a 16-entry excludedKinds default list that contains the two most widely deployed kinds, `CognitiveServices` and `AIServices` (also ContentSafety, LUIS, QnAM |
+| `037eea7a-bd0a-46c5-9a66-03aea78705d3` | Azure AI Services resources should restrict network access | T5, T18 | The policyRule is an anyOf over two per-type branches, and each branch is itself a per-resource disjunction over DIFFERENT attributes. A Microsoft.CognitiveServices/accounts resource is compliant when properties.publicNe |
+| `d6759c02-b87f-42b7-892e-71b3f471d782` | Azure AI Services resources should use Azure Private Link | T14, T9 | Compliance is "at least one element of `properties.privateEndpointConnections` has `privateLinkServiceConnectionState.status == "Approved"`" (upstream fires when that filtered count is < 1). Two obstacles, and the first |
+| `1ee56206-5dd1-42ab-b02d-8aae8b1634ce` | Azure API for FHIR should use private link | T14, T9 | The rule counts elements of properties.privateEndpointConnections whose privateLinkServiceConnectionState.status is "Approved" and fires when that count is below 1. privateEndpointConnections is a read-only, service-popu |
+| `7eab1da3-2bf0-4ff0-8303-1a4277c380e8` | Azure Arc Private Link Scopes should be configured with a private endpoint | T14, T9 | Compliance is "at least one element of `properties.privateEndpointConnections` has `privateLinkServiceConnectionState.status == "Approved"`" (upstream fires when that filtered count is < 1). Two obstacles, and the first |
+| `862e97cf-49fc-4a5c-9de4-40d4e2e7c8eb` | Azure Cosmos DB accounts should have firewall rules | T5, T14 | Upstream fires only when four independent clauses hold at once, so compliance is a per-resource disjunction over four *different* attributes of the same account: `properties.publicNetworkAccess` is "Disabled", OR `proper |
+| `9d83ccb1-f313-46ce-9d39-a198bfdb51a0` | Azure Cosmos DB accounts should not exceed the maximum number of days allowed since last account key regeneration. | T10, T14 | The rule compares four `properties.keysMetadata.*.generationTime` timestamps against `[addDays(utcNow(), mul(int(parameters('maxDaysSinceLastRegeneration')), -1))]`, i.e. now minus 60 days by default. That is date arithm |
+| `0473574d-2d43-4217-aefe-941fcdf7e684` | Azure Cosmos DB allowed locations | T19, T10 | The allow-list lives only in the assignment: `listOfAllowedLocations` is an Array parameter with strongType "location" and **no defaultValue**, so the definition itself names no acceptable location and card rule 10 canno |
+| `0b7ef78e-a035-4f23-b9bd-aff122a1b1cf` | Azure Cosmos DB throughput should be limited | T19, T5, T10 | Three independent blockers. (a) `throughputMax` is an Integer parameter with no defaultValue, so the definition names no threshold at all and card rule 10 cannot be satisfied - any number chosen here would be invented si |
+| `f7735886-8927-431f-b201-c953922512b8` | Azure Data Explorer cluster should use private link | T14, T9 | Compliance is "at least one element of `properties.privateEndpointConnections` (the alias spells it PrivateEndpointConnections) has `privateLinkServiceConnectionState.status == "Approved"`" (upstream fires when that filt |
+| `6809a3d0-d354-42fb-b955-783d207c62a8` | Azure Data Factory linked service resource type should be in allow list | T19 | The upstream definition carries no allow-list of its own: `allowedLinkedServiceResourceTypes` is a required Array parameter with 111 allowedValues and no defaultValue, so the set of acceptable `properties.type` values ex |
+| `127ef6d7-242f-43b3-9eef-947faf1725d0` | Azure Data Factory linked services should use Key Vault for storing secrets | T5, T18 | The rule is a 25-branch anyOf whose branches read different attribute paths of different linked-service types, so compliance is a per-resource conjunction over 25 negations that tirith's document-wide `&&`/`\|\|` cannot ex |
+| `f78ccdb4-7bf4-4106-8647-270491d2978a` | Azure Data Factory linked services should use system-assigned managed identity authentication when it is supported | T18, T5 | The upstream `if` is scoped twice by sibling attributes that tirith cannot bind to the same resource. (a) It only applies to linked services whose own `properties.type` is one of nine MSI-capable store types (AzureSqlDat |
+| `258823f2-4595-4b52-b333-cc96192710d8` | Azure Databricks Workspaces should use private link | T14, T9 | Compliance is "at least one element of `properties.privateEndpointConnections` has `privateLinkServiceConnectionState.status == "Approved"`" (upstream fires when that filtered count is < 1). Two obstacles, and the first |
+| `08a6b96f-576e-47a2-8511-119a212d344d` | Azure Edge Hardware Center devices should have double encryption support enabled | T5 | Upstream flags an orderItem only when BOTH orderItemDetails.preferences.encryptionPreferences.doubleEncryptionStatus and orderItemDetails.productDetails.productDoubleEncryptionStatus differ from "Enabled" - a per-resourc |
+| `9830b652-8523-49cc-b1b3-e17dce1127ca` | Azure Event Grid domains should use private link | T14, T9 | The rule counts elements of `properties.privateEndpointConnections` on Microsoft.EventGrid/domains whose `privateLinkServiceConnectionState.status` is "Approved" and fires when the count is below 1. privateEndpointConnec |
+| `cd8f7644-6fe8-4516-bded-0e465ead03ac` | Azure Event Grid namespace MQTT broker should use private link | T14, T9, T18 | As with the sibling namespace-topic rule, the assertion is a filtered count over `properties.privateEndpointConnections`, a read-only, service-populated property that never appears in an ARM template, under audit/disable |
+| `1301a000-bc6b-4d90-8414-7091e3abdc40` | Azure Event Grid namespace topic broker should use private link | T14, T9, T18 | Same read-only field as the other private-link rules: it counts `properties.privateEndpointConnections` elements on Microsoft.EventGrid/namespaces and fires when fewer than 1 qualifies. The property is service-populated |
+| `4b90e17e-8448-49db-875e-bd83fb6f804f` | Azure Event Grid topics should use private link | T14, T9 | The rule counts `properties.privateEndpointConnections` elements on Microsoft.EventGrid/topics whose `privateLinkServiceConnectionState.status` is "Approved" and fires below 1. That property is read-only and service-popu |
+| `04408ca5-aa10-42ce-8536-98955cdddd4c` | Azure Kubernetes Service Clusters should enable node os auto-upgrade | T5 | Upstream's `if` is allOf[ type, allOf[ anyOf[upgradeChannel absent, upgradeChannel != "node-image"], anyOf[nodeOSUpgradeChannel absent, nodeOSUpgradeChannel notin parameters('allowedNodeOsUpgradeChannels')] ] ]. Negated, |
+| `f110a506-2dcb-422e-bcea-d533fc8c35e2` | Azure Machine Learning compute instances should be recreated to get the latest software updates | T14, T18 | The whole assertion is `Microsoft.MachineLearningServices/workspaces/computes/osImageMetadata.isLatestOsImageVersion == false`. `osImageMetadata` is a service-computed, read-only property of a deployed compute instance - |
+| `9259053b-ddb8-40ab-842a-0aef19d0ade4` | Azure Purview accounts should use private link | T14, T9 | Compliance is "at least one element of `properties.privateEndpointConnections` has `privateLinkServiceConnectionState.status == "Approved"`" (upstream fires when that filtered count is < 1). Two obstacles, and the first |
+| `deeddb44-9f94-4903-9fa0-081d524406e3` | Azure Recovery Services vaults should use private link for backup | T14, T9 | The rule counts members of `Microsoft.RecoveryServices/vaults/privateEndpointConnections[*]` whose `privateLinkServiceConnectionState.status` is "Approved", whose `provisioningState` is "Succeeded" and whose `id` contain |
+| `ca950cd7-02f7-422e-8c23-91ff40f169c1` | Azure Virtual Desktop service should use private link | T14, T9 | Both branches count elements of `properties.privateEndpointConnections` whose `privateLinkServiceConnectionState.status` is "Approved" - on Microsoft.DesktopVirtualization/hostpools and on Microsoft.DesktopVirtualization |
+| `ad5621d6-a877-4407-aa93-a950b428315e` | BotService resources should use private link | T14, T9 | The rule counts members of `Microsoft.BotService/botServices/privateEndpointConnections[*]` whose `privateLinkServiceConnectionState.status` is "Approved" and fires when that count is below 1. privateEndpointConnections |
+| `f772fb64-8e40-40ad-87bc-7706e1949427` | Certificates should not expire within the specified number of days | T19, T10, T14 | Three independent blockers. (a) T0: `daysToExpire` is a required Integer parameter of the built-in with no defaultValue and no allowedValues (verified in vendor/azure-policy/built-in-policies/policyDefinitions/Key Vault/ |
+| `93c45b74-42a1-4967-b25d-82c4dc630921` | Communication service resource should use allow listed data location | T19 | The upstream definition carries no allow-list: `allowedDataLocations` is a required Array parameter with no defaultValue and no allowedValues, so the set of acceptable `properties.dataLocation` values exists only in the |
+| `d0793b48-0edc-4296-a390-4c75d1bdfd71` | Container registries should not allow unrestricted network access | T5 | Upstream flags a registry only when BOTH halves are bad: (networkRuleSet.defaultAction absent or "Allow") AND (publicNetworkAccess absent or "Enabled"). Compliance is therefore a per-resource disjunction over two differe |
+| `58440f8a-10c5-4151-bdce-dfbaad4a20b7` | CosmosDB accounts should use private link | T14, T9 | The rule counts the elements of `properties.privateEndpointConnections` whose `privateLinkServiceConnectionState.status` is "Approved" and fires when that count is less than 1. privateEndpointConnections is a read-only, |
+| `1abc5157-29f8-4dbd-b28e-ff99526cb8b7` | ElasticSan Volume Group should use private endpoints | T14, T9 | The rule counts elements of `properties.privateEndpointConnections` on Microsoft.ElasticSan/elasticSans/volumeGroups whose `privateLinkServiceConnectionState.status` is "Approved" and fires when the count is below 1. Tha |
+| `b1a9997f-2883-4f12-bdff-2280f99b5915` | Ensure cluster containers have readiness or liveness probes configured | T14 | mode `Microsoft.Kubernetes.Data`: `policyRule.if` is a bare `{"field": "type"}` resource selector with no property assertion at all, and the whole rule body is a Gatekeeper constraint template referenced by `then.details |
+| `836cd60e-87f3-4e6a-a27c-29d687f01a4c` | Event Hub namespaces should have double encryption enabled | T18 | Upstream's `if` is `type equals Microsoft.EventHub/namespaces` AND `clusterArmId exists true` AND `encryption.requireInfrastructureEncryption notEquals "true"` (confirmed in vendor/azure-policy/built-in-policies/policyDe |
+| `a1ad735a-e96f-45d2-a7b2-9a4932cab7ec` | Event Hub namespaces should use a customer-managed key for encryption | T18 | The rule is scoped by a sibling attribute of the same resource: it fires only when `Microsoft.EventHub/namespaces/clusterArmId` `exists: true`, i.e. only for namespaces that belong to an Event Hubs Dedicated cluster. The |
+| `f380edf5-de79-4862-bd57-ee407d8bd8b6` | Function app slots should enable configuration routing to Azure Virtual Network | T5, T18 | The rule is a per-resource disjunction over two different attribute sets chosen by the request API version: for `[requestContext().apiVersion]` < 2024-11-01 compliance is all three of `properties.vnetImagePullEnabled`, ` |
+| `75c42c42-dfb3-453d-8fd5-9f978e1b1106` | Function app slots should enable outbound application traffic routing to Azure Virtual Network | T5, T18 | The rule is a per-resource disjunction over two different attribute sets chosen by the request API version: for `[requestContext().apiVersion]` < 2024-11-01 compliance is `properties.vnetRouteAllEnabled == "true"`; for > |
+| `5e57f5ad-995a-485b-9f42-4748935ee5d9` | Function apps should enable configuration routing to Azure Virtual Network | T5, T18 | The rule is a per-resource disjunction over two different attribute sets chosen by the request API version: for `[requestContext().apiVersion]` < 2024-11-01 compliance is all three of `properties.vnetImagePullEnabled`, ` |
+| `32a913ec-5ed7-4b31-a5b0-198450d8fb13` | Function apps should enable outbound application traffic routing to Azure Virtual Network | T5, T18 | The rule is a per-resource disjunction over two different attribute sets chosen by the request API version: for `[requestContext().apiVersion]` < 2024-11-01 compliance is `properties.vnetRouteAllEnabled == "true"`; for > |
+| `35f9c03a-cc27-418e-9c0c-539ff999d010` | Gateway subnets should not be configured with a network security group | T18 | The rule is scoped by the resource's own `name`: it fires only on a Microsoft.Network/virtualNetworks/subnets resource whose `field name equals "GatewaySubnet"` and that also has `networkSecurityGroup.id`. As the capabil |
+| `d8cf8476-a2ec-4916-896e-992351803c44` | Keys should have a rotation policy ensuring that their rotation is scheduled within the specified number of days after creation. | T19, T10, T14 | Upstream fires when `scheduledRotationDate` is absent OR `scheduledRotationDate > addDays(attributes.createdOn, maximumDaysToRotate)`. (a) T0: `maximumDaysToRotate` is a required Integer parameter with no defaultValue (K |
+| `5ff38825-c5d8-47c5-b70e-069a21955146` | Keys should have more than the specified number of days before expiration | T19, T10 | Upstream fires when `attributes.expiresOn` exists AND `expiresOn < addDays(utcNow(), minimumDaysBeforeExpiration)`. (a) T0: `minimumDaysBeforeExpiration` is a required Integer parameter with no defaultValue (Keys_Expiry_ |
+| `49a22571-d204-4c91-a7b6-09b1a586fbc9` | Keys should have the specified maximum validity period | T19, T10, T14 | Upstream fires when `attributes.expiresOn` is absent OR `expiresOn > addDays(attributes.createdOn, maximumValidityInDays)`. (a) T0: `maximumValidityInDays` is a required Integer parameter with no defaultValue (Keys_Valid |
+| `c26e4b24-cf98-4c67-b48b-5a25c4c69eb9` | Keys should not be active for longer than the specified number of days | T19, T10, T14 | The whole condition is a value expression: `utcNow() greater addDays(if(empty(attributes.notBefore), attributes.createdOn, attributes.notBefore), maximumValidityInDays)` (azure_impurities lists `value-expression`, and va |
+| `51fb1409-3c36-43c4-a750-ce814ac35ea6` | Keys using AES cryptography should have a specified minimum key size | T19, T18 | (a) T0: `minimumOCTKeySize` is a required Integer parameter with no defaultValue (allowedValues [128, 192, 256, 512]) - Keys_OCT_MinimumKeySize.json - so the built-in carries no threshold. Picking one ourselves would inv |
+| `82067dbb-e53b-4e06-b631-546d197452d9` | Keys using RSA cryptography should have a specified minimum key size | T19, T18 | (a) T0: `minimumRSAKeySize` is a required Integer parameter with no defaultValue (allowedValues [2048, 3072, 4096]) - Keys_RSA_MinimumKeySize.json. The built-in states no bound of its own; writing GreaterThanEqualTo 2048 |
+| `a2abc456-f0ae-464b-bd3a-07a3cdbd7fb1` | Kubernetes cluster Windows containers should not overcommit cpu and memory | T14, T10 | mode `Microsoft.Kubernetes.Data`: `policyRule.if` is a bare `{"field": "type"}` resource selector with no property assertion at all, and the whole rule body is a Gatekeeper constraint template referenced by `then.details |
+| `5485eac0-7e8f-4964-998b-a44f4f0c1e75` | Kubernetes cluster Windows containers should not run as ContainerAdministrator | T14, T6 | mode `Microsoft.Kubernetes.Data`: `policyRule.if` is a bare `{"field": "type"}` resource selector with no property assertion at all, and the whole rule body is a Gatekeeper constraint template referenced by `then.details |
+| `57dde185-5c62-4063-b965-afbb201e9c1c` | Kubernetes cluster Windows containers should only run with approved user and domain user group | T14 | mode `Microsoft.Kubernetes.Data`: `policyRule.if` is a bare `{"field": "type"}` resource selector with no property assertion at all, and the whole rule body is a Gatekeeper constraint template referenced by `then.details |
+| `56d0a13f-712f-466b-8416-56fb354fb823` | Kubernetes cluster containers should not use forbidden sysctl interfaces | T14, T10 | mode `Microsoft.Kubernetes.Data`: `policyRule.if` is a bare `{"field": "type"}` resource selector with no property assertion at all, and the whole rule body is a Gatekeeper constraint template referenced by `then.details |
+| `c26596ff-4d70-4e6a-9a30-c2506bd2f80c` | Kubernetes cluster containers should only use allowed capabilities | T14, T10 | mode `Microsoft.Kubernetes.Data`: `policyRule.if` is a bare `{"field": "type"}` resource selector with no property assertion at all, and the whole rule body is a Gatekeeper constraint template referenced by `then.details |
+| `975ce327-682c-4f2e-aa46-b9598289b86c` | Kubernetes cluster containers should only use allowed seccomp profiles | T14 | mode `Microsoft.Kubernetes.Data`: `policyRule.if` is a bare `{"field": "type"}` resource selector with no property assertion at all, and the whole rule body is a Gatekeeper constraint template referenced by `then.details |
+| `82985f06-dc18-4a48-bc1c-b9f4f0098cfe` | Kubernetes cluster pods should only use approved host network and port list | T14, T10 | mode `Microsoft.Kubernetes.Data`: `policyRule.if` is a bare `{"field": "type"}` resource selector with no property assertion at all, and the whole rule body is a Gatekeeper constraint template referenced by `then.details |
+| `b0fdedee-7b9e-4a17-9f5d-5e8e912d2f01` | Kubernetes cluster services should use unique selectors | T14, T8 | mode `Microsoft.Kubernetes.Data`: `policyRule.if` is a bare `{"field": "type"}` resource selector with no property assertion at all, and the whole rule body is a Gatekeeper constraint template referenced by `then.details |
+| `95edb821-ddaf-4404-9732-666045e056b4` | Kubernetes cluster should not allow privileged containers | T14 | mode `Microsoft.Kubernetes.Data`: `policyRule.if` is a bare `{"field": "type"}` resource selector with no property assertion at all, and the whole rule body is a Gatekeeper constraint template referenced by `then.details |
+| `65280eef-c8b4-425e-9aec-af55e55bf581` | Kubernetes cluster should not use naked pods | T14 | mode `Microsoft.Kubernetes.Data`: `policyRule.if` is a bare `{"field": "type"}` resource selector with no property assertion at all, and the whole rule body is a Gatekeeper constraint template referenced by `then.details |
+| `a3dc4946-dba6-43e6-950d-f96532848c9f` | Kubernetes clusters should ensure that the cluster-admin role is only used where required | T14 | mode `Microsoft.Kubernetes.Data`: `policyRule.if` is a bare `{"field": "type"}` resource selector with no property assertion at all, and the whole rule body is a Gatekeeper constraint template referenced by `then.details |
+| `a27c700f-8a22-44ec-961c-41625264370b` | Kubernetes clusters should not use specific security capabilities | T14 | mode `Microsoft.Kubernetes.Data`: `policyRule.if` is a bare `{"field": "type"}` resource selector with no property assertion at all, and the whole rule body is a Gatekeeper constraint template referenced by `then.details |
+| `9f061a12-e40d-4183-a00e-171812443373` | Kubernetes clusters should not use the default namespace | T14 | mode `Microsoft.Kubernetes.Data`: `policyRule.if` is a bare `{"field": "type"}` resource selector with no property assertion at all, and the whole rule body is a Gatekeeper constraint template referenced by `then.details |
+| `9a5f4e39-e427-4d5d-ae73-93db00328bec` | Kubernetes resources should have required annotations | T14 | mode `Microsoft.Kubernetes.Data`: `policyRule.if` is a bare `{"field": "type"}` resource selector with no property assertion at all, and the whole rule body is a Gatekeeper constraint template referenced by `then.details |
+| `8405fdab-1faf-48aa-b702-999c9c172094` | Managed disks should disable public network access | T5, T18 | Compliance is a per-resource disjunction across two DIFFERENT attributes: the upstream `if` fires only when `properties.networkAccessPolicy` is not in [DenyAll, AllowPrivate] AND `properties.publicNetworkAccess` is not " |
+| `882e19a6-996f-400e-a30f-c090887254f4` | Migrate WAF from WAF Config to WAF Policy on Application Gateway | T18, T5 | Upstream fires only when all three hold on the SAME application gateway: `properties.sku.tier == "WAF_v2"`, `properties.webApplicationFirewallConfiguration` present, and `properties.firewallPolicy` absent. Tirith cannot |
+| `a22123bd-b9da-4c86-9424-24903e91fd55` | No AKS Specific Labels | T14, T18 | mode `Microsoft.Kubernetes.Data`: `policyRule.if` is a bare `{"field": "type"}` resource selector with no property assertion at all, and the whole rule body is a Gatekeeper constraint template referenced by `then.details |
+| `c0e996f8-39cf-4af9-9f45-83fbde810432` | Only approved VM extensions should be installed | T0 | Same unbound-parameter shape as AZPOL::cccc23c7: the rule is `field Microsoft.Compute/virtualMachines/extensions/type notIn [parameters('approvedExtensions')]` and the definition ships no default approved-extension list, |
+| `5e1de0e3-42cb-4ebc-a86d-61d0c619ca48` | Public network access should be disabled for PostgreSQL flexible servers | T5 | Upstream's `if` is a three-attribute boolean over one resource: violation when `(delegatedSubnetResourceId exists OR privateDnsZoneArmResourceId exists OR (publicNetworkAccess exists AND != "Disabled")) AND (delegatedSub |
+| `36fd7371-8eb7-4321-9c30-a7100022d048` | Requires resources to not have a specific tag. This is a versioning test built-in. | T19 | The whole rule is `exists true` on the field `[concat('tags[', parameters('tagName'), ']')]`, and `tagName` is a String parameter with no defaultValue and no allowedValues (verified in vendor/azure-policy/built-in-polici |
+| `0088bc63-6dee-4a9c-9d29-91cfdc848952` | SQL Server Integration Services integration runtimes on Azure Data Factory should be joined to a virtual network | T18, T5 | Two blockers compose. The rule is scoped by sibling attributes of the same resource - it fires only where `properties.type` equals "Managed" AND `properties.typeProperties.ssisProperties` exists - and tirith cannot scope |
+| `b0eb591a-5e70-4534-a8bf-04b9c489584a` | Secrets should have more than the specified number of days before expiration | T19, T10 | The secrets counterpart of 5ff38825: fires when `attributes.expiresOn` exists AND `expiresOn < addDays(utcNow(), minimumDaysBeforeExpiration)`. (a) T0: `minimumDaysBeforeExpiration` is a required Integer parameter with n |
+| `342e8053-e12e-4c44-be01-c3c2f318400f` | Secrets should have the specified maximum validity period | T19, T10, T14 | The secrets counterpart of 49a22571: fires when `attributes.expiresOn` is absent OR `expiresOn > addDays(attributes.createdOn, maximumValidityInDays)`. (a) T0: `maximumValidityInDays` has no defaultValue (Secrets_Validit |
+| `e8d99835-8a06-45ae-a8e0-87a91941ccfe` | Secrets should not be active for longer than the specified number of days | T19, T10, T14 | The secrets counterpart of c26e4b24, and equally a pure value expression: `utcNow() greater addDays(if(empty(attributes.notBefore), attributes.createdOn, attributes.notBefore), maximumValidityInDays)` (azure_impurities: |
+| `9c376cb6-0545-4233-a22c-074540d4d935` | Service Bus Premium namespaces should use a customer-managed key for encryption in mission platforms | T18 | The rule is scoped to Service Bus namespaces whose sku tier is Premium: `sku.tier == 'Premium'` AND `not(encryption.keySource == 'Microsoft.Keyvault')`. tirith's json provider cannot select a collection member by a sibli |
+| `617c02be-7f02-4efd-8836-3180d47b6c68` | Service Fabric clusters should have the ClusterProtectionLevel property set to EncryptAndSign | T9, T18 | Compliance requires that the cluster have a fabricSettings entry named "Security" that itself contains a parameters entry whose name is "ClusterProtectionLevel" AND whose value is "EncryptAndSign". That is a per-resource |
+| `ea6c4923-510a-4346-be26-1894919a5b97` | Stream Analytics job should use managed identity to authenticate endpoints | T18, T5, T11 | The rule is a four-way disjunction over resource types (streamingjobs, /inputs, /outputs, /functions) and, inside the inputs and outputs branches, fifteen further branches each selected by a sibling attribute: `datasourc |
+| `d416745a-506c-48b6-8ab1-83cb814bcaa3` | Virtual machines should be connected to an approved virtual network | T19 | The comparison value is the unbound assignment parameter `virtualNetworkId`: the rule is `not { field Microsoft.Network/networkInterfaces/ipconfigurations[*].subnet.id like "[concat(parameters('virtualNetworkId'),'/*')]" |
+| `564feb30-bf6a-4854-b4bb-0d2d2d1e6c66` | Web Application Firewall (WAF) should be enabled for Application Gateway | T5 | Upstream fires only when a single application gateway has NEITHER `properties.webApplicationFirewallConfiguration` NOR `properties.firewallPolicy` - a per-resource disjunction across two different attributes. Tirith's `\| |
+| `549814b6-3212-4203-bdc8-1548d342fb67` | [Deprecated]: API Management minimum API version should be set to 2019-12-01 or higher | T5, T18 | The `if` fires only when apiVersionConstraint.minApiVersion matches none of the three date patterns AND sku.name is not "Consumption". Compliance is therefore a per-resource disjunction across two different attributes - |
+| `f8d398ae-0441-4921-a341-40f3973d4647` | [Deprecated]: Azure Data Factory pipelines should only communicate with allowed domains. Versioning Test BuiltIn | T14, T19 | A `mode: Microsoft.DataFactory.Data` policy over the proxy type Microsoft.DataFactory.Data/factories/outboundTraffic, which is a data-plane object evaluated when a pipeline makes an outbound call - it is never declared i |
+| `711c24bb-7f18-4578-b192-81a6161e1f17` | [Deprecated]: Azure Firewall Premium should configure a valid intermediate certificate to enable TLS inspection | T18 | Upstream fires only when the SAME firewall policy has `properties.sku.tier == "Premium"` AND no `properties.transportSecurity.certificateAuthority`. Tirith cannot scope an evaluator by a sibling attribute, so the only av |
+| `85793e88-5a58-4555-93fa-4df63c86ae9c` | [Deprecated]: Azure Machine Learning Model Registry Deployments are restricted except for the allowed Registry. Versioning Test BuiltIn. | T14, T0 | A `mode: Microsoft.MachineLearningServices.v2.Data` policy over workspaces/deployments - a data-plane deployment object, not an ARM template resource, so the input is unreachable (T14). It is also unusable at its own def |
+| `a77d8bb4-8d22-4bc1-a884-f582a705b480` | [Deprecated]: Azure Media Services accounts should use an API that supports Private Link | T0 | The upstream definition is deprecated (metadata.deprecated true, version '1.1.0-deprecated', default effect Disabled) and its policyRule has been replaced by a placeholder that no longer implements the titled intent: the |
+| `ccf93279-9c91-4143-a841-8d1f21505455` | [Deprecated]: Azure Media Services accounts that allow access to the legacy v2 API should be blocked | T0 | The upstream definition is deprecated (metadata.deprecated true, version '1.1.0-deprecated', default effect Disabled) and its policyRule has been replaced by a placeholder that no longer implements the titled intent: the |
+| `10ee2ea2-fb4d-45b8-a7e9-a2e770044cd9` | [Deprecated]: Custom subscription owner roles should not exist | T9, T18, T10 | Three separate blockers, all on the same resource. (a) The rule applies only to role definitions whose `properties.type` is `CustomRole` - a per-resource scoping predicate tirith cannot express (T18). (b) Both remaining |
+| `98cec160-6f57-4d11-86e2-0a03290a3a8a` | [Deprecated]: Key Vault Managed HSM keys using elliptic curve cryptography should have the specified curve names. Versioning Test BuiltIn. | T0, T14 | Vacuous at its shipped defaults: `allowedECNamesV2` has defaultValue ["P-256","P-256K","P-384","P-521"] and allowedValues identical to it, and those four are the complete set of elliptic curves a Managed HSM key can carr |
+| `fa8af49a-f61d-4f56-9138-46b77d37df43` | [Deprecated]: Keys should have a rotation policy within the specified number of days after creation. Versioning Test BuiltIn. | T14, T10, T19 | A `mode: Microsoft.KeyVault.Data` policy over Microsoft.KeyVault.Data/vaults/keys - the data-plane key object, not the Microsoft.KeyVault/vaults/keys resource an ARM template declares - and `scheduledRotationDate` is a s |
+| `033f1f0f-0eff-42f0-85aa-f79b78e59a40` | [Deprecated]: Load tests should be run only against private endpoints from within a virtual network. Versioning Test BuiltIn. | T14 | A `mode: Microsoft.LoadTestService.Data` policy over Microsoft.LoadTestService.Data/loadTests/testRuns. A test run is a runtime execution object created by the Load Testing service, never a resource in an ARM template, s |
+| `83a0809a-a4e3-4ef2-8a24-2afc156607af` | [Deprecated]: No AKS Specific Labels. Versioning Test BuiltIn. | T14, T0 | A `mode: Microsoft.Kubernetes.Data` policy: it does not inspect the ARM resource at all. Its `if` asserts only `parameters('testString') == "test"` (defaultValue "test", so always true) and that the resource type is a ma |
+| `2c89a2e5-7285-40fe-afe0-ae8654b92fb2` | [Deprecated]: Unattached disks should be encrypted | T18, T14 | The upstream `if` is scoped by a sibling attribute: it fires only on a Microsoft.Compute/disks resource whose `properties.diskState` equals "Unattached", and only then checks `encryptionSettingsCollection.enabled`. tirit |
+| `752c6934-9bcc-4749-b004-655e676ae2ac` | [Deprecated]: [Deprecated]: Audit enabling of diagnostic logs in App Services | T18 | Upstream scopes the audit to one member of the `Microsoft.Web/sites/config` collection with `field name equals "web"`; the three properties it tests (detailedErrorLoggingEnabled, httpLoggingEnabled, requestTracingEnabled |
+| `3d02a511-74e5-4dab-a5fd-878704d4a61a` | [Preview]: Azure Data Factory pipelines should only communicate with allowed domains | T19 | The allow-list exists only in the assignment: `allowedDomainNames` is an Array parameter with no defaultValue and no allowedValues, so the definition names no acceptable domain and there is no concrete value that makes t |
+| `59fee2f4-d439-4f1b-9b9a-982e1474bfd8` | [Preview]: Azure Key Vault Managed HSM should use private link | T14, T9, T7 | Upstream fires when `Microsoft.KeyVault/managedHSMs/privateEndpointConnections` is absent OR the count of connections whose `privateLinkServiceConnectionState.status` equals "Approved" is 0 (azure_impurities: count). (a) |
+| `5e5a0673-649e-4d50-bf9d-5a387a4e2081` | [Preview]: Kubernetes cluster containers should use only allowed sysctl interfaces | T14 | mode `Microsoft.Kubernetes.Data`: `policyRule.if` is a bare `{"field": "type"}` resource selector with no property assertion at all, and the whole rule body is a Gatekeeper constraint template referenced by `then.details |
+| `b81f454c-eebb-4e4f-9dfe-dca060e8a8fd` | [Preview]: Kubernetes clusters should restrict creation of given resource type | T14 | mode `Microsoft.Kubernetes.Data`: `policyRule.if` is a bare `{"field": "type"}` resource selector with no property assertion at all, and the whole rule body is a Gatekeeper constraint template referenced by `then.details |
diff --git a/.claude/skills/tirith-migrate/reference/azure-policy.md b/.claude/skills/tirith-migrate/reference/azure-policy.md
new file mode 100644
index 00000000..52daa751
--- /dev/null
+++ b/.claude/skills/tirith-migrate/reference/azure-policy.md
@@ -0,0 +1,179 @@
+# Azure Policy to Tirith
+
+An Azure Policy definition is a JSON document whose `properties.policyRule` has an `if` tree of
+`{field, operator, value}` leaves under `allOf` / `anyOf` / `not`, and a `then.effect`. That is
+structurally a Tirith policy: `field` is the attribute, the operator is the condition, the logic is
+`eval_expression`. Two things make the translation more than a rename, and both are decided first.
+
+## 1. Polarity: `if` describes the violation
+
+Azure fires the effect when `if` matches. A Tirith evaluator describes compliance. So every leaf is
+translated to its **opposite**, and the logic flips with it:
+
+| `if` leaf (violation) | Tirith condition (compliance) |
+| --- | --- |
+| `equals X` | `NotEquals X` |
+| `notEquals X` | `Equals X` |
+| `in [..]` | `NotContainedIn [..]` |
+| `notIn [..]` | `ContainedIn [..]` |
+| `contains X` | `NotContains X` |
+| `notContains X` | `Contains X` |
+| `containsKey K` (on a map, e.g. tags) | `NotContains K` on the map. `Contains` on a map tests its keys, verified |
+| `notContainsKey K` | `Contains K` |
+| `exists: "false"` | `IsNotEmpty` |
+| `exists: "true"` | `IsEmpty` |
+| `less N` | `GreaterThanEqualTo N` |
+| `lessOrEquals N` | `GreaterThan N` |
+| `greater N` | `LessThanEqualTo N` |
+| `greaterOrEquals N` | `LessThan N` |
+| `like "a*"` | `RegexMatch "^a.*$"` and `!` in the expression. `*` is the only wildcard |
+| `notLike "a*"` | `RegexMatch "^a.*$"` |
+| `match`, `notMatch`, `matchInsensitively`, `notMatchInsensitively` | not expressible: `#` digit, `?` letter, `.` any is not a regex. Hand-translate the pattern to a regex and mark approximate |
+
+| `if` logic | Tirith | Fidelity |
+| --- | --- | --- |
+| `{field: type, equals: T}` inside an `allOf` | scope: `terraform_resource_type` (or the json type guard) | exact |
+| `anyOf [A, B]` (violation if either) | `notA && notB` | exact |
+| `not X` | `X` translated positively | exact |
+| `allOf [A, B]` on two attributes of the same resource (violation only if both) | `notA \|\| notB` | approximate, stricter: Tirith cannot bind both tests to one resource. Issue #316 |
+| `count { field: X[*], where: {...} } greater 0` | wildcard path over `X.*.` where the inner test is one attribute | approximate; a `where` on two attributes is not expressible |
+| `{value: "[...]", ...}` (an ARM expression, `requestContext()`, `resourceGroup()`, `field()` arithmetic) | none | not expressible |
+
+Most built-ins are `allOf [{type equals T}, {one field test}]`: scope plus one leaf, which is exact.
+
+## 2. Target: Terraform plan or ARM template
+
+| Target | Provider | When |
+| --- | --- | --- |
+| Terraform (`azurerm`) | `stackguardian/terraform_plan` | The team writes Terraform. Attribute names come from the crosswalk below; one Azure type is often two or three azurerm types |
+| ARM template | `stackguardian/json` | The team deploys ARM or Bicep-built JSON. Paths come from the alias rule below; every translation is approximate because the json provider scopes the document, not the resource |
+
+### Terraform: alias to azurerm attribute
+
+Verified: each row is an attribute a corpus policy evaluates end to end against a plan.
+
+| Azure alias | azurerm resource | attribute |
+| --- | --- | --- |
+| `location` (any type) | `*` | `location`, `error_tolerance: 2` |
+| `tags['K']`, `tags.K` | `*` | `tags` with `Contains "K"`, `error_tolerance: 2` |
+| `type` in `[..]` (Not allowed resource types) | each forbidden type | operation `count`, `Equals 0` |
+| `type` not in `[..]` (Allowed resource types) | `*` with `exclude_resource_types` = allowed | operation `count`, `Equals 0` |
+| `Microsoft.Compute/virtualMachines/sku.name` | `azurerm_linux_virtual_machine`, `azurerm_windows_virtual_machine` | `size`; legacy `azurerm_virtual_machine` uses `vm_size` |
+| `Microsoft.Compute/virtualMachines/securityProfile.encryptionAtHost` | `azurerm_linux_virtual_machine`, `azurerm_windows_virtual_machine`, both scale sets | `encryption_at_host_enabled` |
+| `Microsoft.Compute/virtualMachines/osProfile.linuxConfiguration.disablePasswordAuthentication` | `azurerm_linux_virtual_machine` | `disable_password_authentication` |
+| `Microsoft.Storage/storageAccounts/supportsHttpsTrafficOnly` | `azurerm_storage_account` | `https_traffic_only_enabled` (4.x), `enable_https_traffic_only` (3.x) |
+| `Microsoft.Storage/storageAccounts/minimumTlsVersion` | `azurerm_storage_account` | `min_tls_version`, values `TLS1_2`, `TLS1_3` |
+| `Microsoft.Storage/storageAccounts/allowBlobPublicAccess` | `azurerm_storage_account` | `allow_nested_items_to_be_public` (3.x+), `allow_blob_public_access` (2.x) |
+| `Microsoft.Storage/storageAccounts/networkAcls.defaultAction` | `azurerm_storage_account` | `network_rules.*.default_action` |
+| `Microsoft.Storage/storageAccounts/encryption.requireInfrastructureEncryption` | `azurerm_storage_account` | `infrastructure_encryption_enabled` |
+| `Microsoft.Storage/storageAccounts/blobServices/containers/publicAccess` | `azurerm_storage_container` | `container_access_type`, compliant `private` |
+| `Microsoft.KeyVault/vaults/enablePurgeProtection` | `azurerm_key_vault` | `purge_protection_enabled` |
+| `Microsoft.KeyVault/vaults/enableSoftDelete` | `azurerm_key_vault` | `soft_delete_retention_days` (soft delete is always on in 3.x+) |
+| `Microsoft.KeyVault/vaults/networkAcls.defaultAction` | `azurerm_key_vault` | `network_acls.*.default_action` |
+| `Microsoft.Web/sites/httpsOnly` | `azurerm_linux_web_app`, `azurerm_windows_web_app`, `azurerm_app_service`, slots | `https_only` |
+| `Microsoft.Web/sites/config/minTlsVersion` | same | `site_config.*.minimum_tls_version` (`min_tls_version` on `azurerm_app_service`) |
+| `Microsoft.Web/sites/publicNetworkAccess` | same | `public_network_access_enabled` |
+| `Microsoft.Sql/servers/minimalTlsVersion` | `azurerm_mssql_server` | `minimum_tls_version` |
+| `Microsoft.Sql/servers/publicNetworkAccess` | `azurerm_mssql_server` | `public_network_access_enabled` |
+| `Microsoft.DBforPostgreSQL/servers/sslEnforcement`, `minimalTlsVersion` | `azurerm_postgresql_server` | `ssl_enforcement_enabled`, `ssl_minimal_tls_version_enforced` |
+| `Microsoft.DBforMySQL/servers/minimalTlsVersion` | `azurerm_mysql_server` | `ssl_minimal_tls_version_enforced` |
+| `Microsoft.ContainerService/managedClusters/apiServerAccessProfile.enablePrivateCluster` | `azurerm_kubernetes_cluster` | `private_cluster_enabled` |
+| `Microsoft.ContainerService/managedClusters/aadProfile.enableAzureRBAC` | `azurerm_kubernetes_cluster` | `azure_active_directory_role_based_access_control.*.azure_rbac_enabled` |
+| `Microsoft.ContainerService/managedClusters/agentPoolProfiles[*].enableNodePublicIP` | `azurerm_kubernetes_cluster` | `default_node_pool.*.enable_node_public_ip` |
+| `Microsoft.ContainerRegistry/registries/publicNetworkAccess` | `azurerm_container_registry` | `public_network_access_enabled` |
+| `Microsoft.ContainerRegistry/registries/networkRuleSet.defaultAction` | `azurerm_container_registry` | `network_rule_set.*.default_action` |
+| `Microsoft.Network/networkSecurityGroups/securityRules/*` | `azurerm_network_security_rule` | `direction`, `access`, `destination_port_range`, `source_address_prefix`; inline rules are `security_rule.*.` on `azurerm_network_security_group` |
+| `Microsoft.Cache/redis/minimumTlsVersion` | `azurerm_redis_cache` | `minimum_tls_version` |
+| `Microsoft.EventHub/namespaces/minimumTlsVersion` | `azurerm_eventhub_namespace` | `minimum_tls_version` |
+| `Microsoft.ServiceBus/namespaces/minimumTlsVersion` | `azurerm_servicebus_namespace` | `minimum_tls_version` |
+| `*/publicNetworkAccess` on most PaaS types | the azurerm type | `public_network_access_enabled` |
+| `Microsoft.Network/networkInterfaces/enableIPForwarding`, publicIPs on NICs, resource-group-level anything | | not expressible: no single attribute carries it |
+
+An attribute the plan leaves unset arrives as `null` in `change.after`, not absent. `Equals` and
+`ContainedIn` fail it cleanly; `Contains` fails it as an unsupported type; `IsNotEmpty` fails it.
+A key the type does not have at all is absent and is what `error_tolerance: 2` skips.
+
+### ARM: alias to JSON path
+
+The rule, validated in the corpus against 36 independently verified paths: strip the resource type
+(the whole `Namespace/type/childType/`, not one segment), turn `[*]` into `.*`, join with `.`,
+prefix `properties.` unless the first segment is an envelope key (`sku`, `identity`, `tags`,
+`location`, `zones`, `plan`, `kind`, `managedBy`, `name`, `type`, `id`). `type`, `name`, `location`,
+`kind`, `identity.type` and `tags...` stay as they are.
+
+The json provider cannot scope by resource type (issue #317), so every ARM translation uses the
+**type guard** and is approximate:
+
+```json
+{"id": "no_such_type",
+ "provider_args": {"operation_type": "get_value", "key_path": "resources.*.type"},
+ "condition": {"type": "NotContainedIn", "value": ["Microsoft.Storage/storageAccounts", "microsoft.storage/storageaccounts"], "error_tolerance": 2}}
+```
+
+with `"eval_expression": "no_such_type || the_check"`. A template with no such type passes; once
+the type is present anywhere, the attribute path is read across **every** resource in the template,
+so a second resource type that happens to carry the same property name is swept in. ARM type strings
+are case-insensitive in Azure and compared exactly here; list the spellings you expect. Use the guard
+only when Azure fails on an absent property (`notEquals`, `exists: false`): it turns absence into
+failure at every inner tolerance.
+
+## 3. Effects
+
+| `then.effect` | Tirith | Fidelity |
+| --- | --- | --- |
+| `deny` | `meta.enforcement: "deny"`, run with `--fail-on-error` | exact |
+| `audit` | `meta.enforcement: "audit"`. The CLI has no advisory tier: keep audit policies in their own directory and do not gate the job on their exit, or treat exit 3 from that run as a warning | approximate |
+| `disabled` | do not translate | |
+| `[parameters('effect')]` | the assignment decides; record the allowed values in the notes and pick the assignment's | |
+| `auditIfNotExists`, `deployIfNotExists` | asserts a *related* resource exists, from `then.details`, not from `if`. `direct_references` with `referenced_by` covers "every X is referenced by a Y" at type level; anything on the related resource's attributes is not expressible | approximate or not expressible |
+| `modify`, `append`, `denyAction`, `manual` | remediation or process, not evaluation | not expressible |
+
+## 4. Modes and parameters
+
+- **`mode: Indexed`** means Azure evaluates only resource types that support tags and location.
+ Translate with `error_tolerance: 2` on `location` or `tags` over `terraform_resource_type: "*"`,
+ which skips the types that lack the attribute. **`mode: All`** includes everything, including
+ resource groups and subscriptions, which Terraform models as resources too.
+- **`[parameters('x')]`** becomes `{{ var.x }}` with a `variables.json` beside the policy. The
+ definition often has no `defaultValue`; the value lives in the **assignment**. Ask for it, or
+ read `properties.parameters` on the assignment. A parameter feeding `terraform_resource_type` or
+ `exclude_resource_types` cannot be a `-var`: write the types out, one evaluator per type.
+- **`[concat('tags[', parameters('tagName'), ']')]`** is the `tags` attribute with `Contains {{ var.tagName }}`.
+- **Initiatives** (policy set definitions) are a directory of policies. **Assignments**, scopes and
+ exemptions have no counterpart: a Tirith policy applies to whatever plan it is run against.
+
+## 5. What to expect
+
+Measured on HashiCorp-style public data, this time Microsoft's own `Azure/azure-policy` repository,
+by the tirith-policy-corpus project (ARM target, json provider):
+
+| | count |
+| --- | --- |
+| Definitions with an asserting effect (`audit` or `deny`) | 713 |
+| Translated to Tirith | 376, all approximate (document scope) |
+| Blocked | 110 |
+| Not started | 227 |
+
+Why blocked, in order: `mode: Microsoft.Kubernetes.Data` (Gatekeeper constraints inside a live
+cluster, not ARM); a per-resource disjunction over two attribute sets chosen by API version (T5);
+per-resource scoping on a sibling attribute (T18, issue #316); a comparison value that is an
+assignment-time parameter with no default (T19, translatable once the assignment supplies it);
+`count` with a `where` over two attributes (T9); value expressions and arithmetic (T10, issue #338).
+
+The built-ins a customer assigns first, Allowed locations, Require a tag, Allowed and Not allowed
+resource types, Allowed VM SKUs, are all parameter-driven and untyped, so the corpus skipped them.
+They translate cleanly to the Terraform target; `examples/azure-policy/` has each one verified.
+
+`reference/azure-policy-corpus.md` lists the 376 translated and 110 blocked definitions by GUID and
+display name, with the corpus path or the blocking reason. Look a built-in up there before
+translating it; if it is translated, the ARM version exists and the Terraform version is a
+crosswalk away.
+
+## 6. Verifying
+
+Azure definitions ship no test fixtures. Write a plan by hand: `terraform show -json` shape, one
+`resource_changes` entry per resource, the attribute under `change.after`. Include one resource of a
+type the policy does not cover, so the run proves the scope. For `Indexed` translations include a
+type without `location` or `tags` (a role assignment) and confirm it is skipped, not failed. Expect
+exit `3` on the violating plan and `0` on the compliant one, and for every approximate row a
+`diverges.json` where Azure and Tirith disagree.
diff --git a/documentation/docs/tirith-usage/agent-skills.md b/documentation/docs/tirith-usage/agent-skills.md
index 6e3dc781..605f8781 100644
--- a/documentation/docs/tirith-usage/agent-skills.md
+++ b/documentation/docs/tirith-usage/agent-skills.md
@@ -32,7 +32,7 @@ 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, and they are picked up in any
+for translating existing Sentinel or Azure 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.
@@ -100,12 +100,16 @@ with a small context window pays for only what the task needs.
## Migrating from Sentinel
-The second skill, `tirith-migrate`, is for teams with existing HashiCorp Sentinel policies. It is a
+The second skill, `tirith-migrate`, is for teams with existing HashiCorp Sentinel or Azure Policy
+definitions. 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.
+words with the Tirith issue that would change it. For Azure Policy the reference carries an
+alias-to-azurerm crosswalk, the corpus's classification of 713 built-ins, and eight verified
+translations of the policies a subscription is usually assigned first. Checkov and OPA/Rego are
+planned next.
## Two things decide whether the policy actually works
diff --git a/documentation/static/docs/tirith-usage/agent-skills.md b/documentation/static/docs/tirith-usage/agent-skills.md
index eb688a3c..adf11966 100644
--- a/documentation/static/docs/tirith-usage/agent-skills.md
+++ b/documentation/static/docs/tirith-usage/agent-skills.md
@@ -20,7 +20,7 @@ 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, and they are picked up in any
+for translating existing Sentinel or Azure 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.
@@ -88,12 +88,16 @@ with a small context window pays for only what the task needs.
## Migrating from Sentinel
-The second skill, `tirith-migrate`, is for teams with existing HashiCorp Sentinel policies. It is a
+The second skill, `tirith-migrate`, is for teams with existing HashiCorp Sentinel or Azure Policy
+definitions. 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.
+words with the Tirith issue that would change it. For Azure Policy the reference carries an
+alias-to-azurerm crosswalk, the corpus's classification of 713 built-ins, and eight verified
+translations of the policies a subscription is usually assigned first. Checkov and OPA/Rego are
+planned next.
## Two things decide whether the policy actually works
diff --git a/documentation/static/llms-full.txt b/documentation/static/llms-full.txt
index ac35d44f..6954a891 100644
--- a/documentation/static/llms-full.txt
+++ b/documentation/static/llms-full.txt
@@ -3475,7 +3475,7 @@ 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, and they are picked up in any
+for translating existing Sentinel or Azure 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.
@@ -3543,12 +3543,16 @@ with a small context window pays for only what the task needs.
## Migrating from Sentinel
-The second skill, `tirith-migrate`, is for teams with existing HashiCorp Sentinel policies. It is a
+The second skill, `tirith-migrate`, is for teams with existing HashiCorp Sentinel or Azure Policy
+definitions. 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.
+words with the Tirith issue that would change it. For Azure Policy the reference carries an
+alias-to-azurerm crosswalk, the corpus's classification of 713 built-ins, and eight verified
+translations of the policies a subscription is usually assigned first. Checkov and OPA/Rego are
+planned next.
## Two things decide whether the policy actually works
diff --git a/documentation/static/skill.sh b/documentation/static/skill.sh
index 261819c4..9afff9eb 100644
--- a/documentation/static/skill.sh
+++ b/documentation/static/skill.sh
@@ -37,34 +37,84 @@ 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/azure-policy-corpus.md
+reference/azure-policy.md
reference/sentinel-corpus.md
+reference/sentinel.md
+examples/azure-policy/README.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/azure-policy/allowed-locations/notes.md
+examples/azure-policy/allowed-locations/policy.json
+examples/azure-policy/allowed-locations/should-fail.json
+examples/azure-policy/allowed-locations/should-pass.json
+examples/azure-policy/allowed-locations/source.json
+examples/azure-policy/allowed-locations/variables.json
+examples/azure-policy/allowed-resource-types/notes.md
+examples/azure-policy/allowed-resource-types/policy.json
+examples/azure-policy/allowed-resource-types/should-fail.json
+examples/azure-policy/allowed-resource-types/should-pass.json
+examples/azure-policy/allowed-resource-types/source.json
+examples/azure-policy/allowed-vm-skus/notes.md
+examples/azure-policy/allowed-vm-skus/policy.json
+examples/azure-policy/allowed-vm-skus/should-fail.json
+examples/azure-policy/allowed-vm-skus/should-pass.json
+examples/azure-policy/allowed-vm-skus/source.json
+examples/azure-policy/allowed-vm-skus/variables.json
+examples/azure-policy/inherit-tag-from-resource-group/notes.md
+examples/azure-policy/inherit-tag-from-resource-group/source.json
+examples/azure-policy/not-allowed-resource-types/notes.md
+examples/azure-policy/not-allowed-resource-types/policy.json
+examples/azure-policy/not-allowed-resource-types/should-fail.json
+examples/azure-policy/not-allowed-resource-types/should-pass.json
+examples/azure-policy/not-allowed-resource-types/source.json
+examples/azure-policy/nsg-rdp-from-internet/diverges.json
+examples/azure-policy/nsg-rdp-from-internet/notes.md
+examples/azure-policy/nsg-rdp-from-internet/policy.json
+examples/azure-policy/nsg-rdp-from-internet/should-fail.json
+examples/azure-policy/nsg-rdp-from-internet/should-pass.json
+examples/azure-policy/nsg-rdp-from-internet/source.json
+examples/azure-policy/require-tag/notes.md
+examples/azure-policy/require-tag/policy.json
+examples/azure-policy/require-tag/should-fail-null-tags.json
+examples/azure-policy/require-tag/should-fail.json
+examples/azure-policy/require-tag/should-pass.json
+examples/azure-policy/require-tag/source.json
+examples/azure-policy/require-tag/variables.json
+examples/azure-policy/storage-secure-transfer/notes.md
+examples/azure-policy/storage-secure-transfer/policy.json
+examples/azure-policy/storage-secure-transfer/should-fail-azurerm3.json
+examples/azure-policy/storage-secure-transfer/should-fail.json
+examples/azure-policy/storage-secure-transfer/should-pass.json
+examples/azure-policy/storage-secure-transfer/source.json
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/mandatory-tags/source.sentinel
examples/sentinel/prevent-database-destroy/notes.md
examples/sentinel/prevent-database-destroy/policy.json
+examples/sentinel/prevent-database-destroy/should-fail-replacement.json
examples/sentinel/prevent-database-destroy/should-fail.json
examples/sentinel/prevent-database-destroy/should-pass.json
-examples/sentinel/prevent-database-destroy/should-fail-replacement.json
-examples/sentinel/restrict-ssh-ingress/source.sentinel
+examples/sentinel/prevent-database-destroy/source.sentinel
+examples/sentinel/require-private-registry-modules/notes.md
+examples/sentinel/require-private-registry-modules/source.sentinel
+examples/sentinel/restrict-instance-type/notes.md
+examples/sentinel/restrict-instance-type/policy.json
+examples/sentinel/restrict-instance-type/should-fail.json
+examples/sentinel/restrict-instance-type/should-pass.json
+examples/sentinel/restrict-instance-type/source.sentinel
+examples/sentinel/restrict-instance-type/variables.json
+examples/sentinel/restrict-ssh-ingress/diverges.json
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"
+examples/sentinel/restrict-ssh-ingress/source.sentinel
+examples/azure-policy/storage-secure-transfer/arm/policy.json
+examples/azure-policy/storage-secure-transfer/arm/should-fail.json
+examples/azure-policy/storage-secure-transfer/arm/should-pass-no-storage.json
+examples/azure-policy/storage-secure-transfer/arm/should-pass.json"
while [ $# -gt 0 ]; do
case "$1" in
From 8768f52db706ded02ae889e38adcccf102e5cc8e Mon Sep 17 00:00:00 2001
From: Akash S
Date: Tue, 8 Sep 2026 15:57:13 +0530
Subject: [PATCH 13/13] skill.sh: one archive download instead of a hundred raw
fetches
Installing over a slow link took more than ten minutes and looked hung: the script fetched each
of ~100 files from raw.githubusercontent.com in sequence, printing nothing until all had
arrived, and each fetch was taking four to five seconds. A single codeload tarball of the branch
(4.9 MB) took 1.4 seconds on the same link.
The installer now downloads that one archive, extracts it to a temporary directory, and copies
every directory under .claude/skills/ that has a SKILL.md into place. Consequences: the three
hand-maintained file lists are gone, so a new example no longer has to be registered anywhere;
a new skill directory installs without touching the script; each pack is replaced wholesale on
re-run, so a fixture renamed upstream no longer lingers; and a tag works as a ref as well as a
branch, since refs/tags is tried when refs/heads is not found. Same flags, same output shape,
same all-or-nothing behaviour. TIRITH_SKILL_ARCHIVE overrides the URL so the script can be tested
against a local `git archive` tarball, which has no top-level directory; both layouts are handled.
Verified: local tarball with --cursor, identical to the checkout; a stale file in an installed
pack removed on re-run; unknown flag exits 2; the real network install of this branch in 2.2 s,
identical to the checkout.
---
.../docs/tirith-usage/agent-skills.md | 14 +-
.../static/docs/tirith-usage/agent-skills.md | 14 +-
documentation/static/llms-full.txt | 14 +-
documentation/static/skill.sh | 205 ++++--------------
4 files changed, 71 insertions(+), 176 deletions(-)
diff --git a/documentation/docs/tirith-usage/agent-skills.md b/documentation/docs/tirith-usage/agent-skills.md
index a4f29a0f..58fb9c0c 100644
--- a/documentation/docs/tirith-usage/agent-skills.md
+++ b/documentation/docs/tirith-usage/agent-skills.md
@@ -44,11 +44,12 @@ skill until it is restarted; a new session sees it immediately.
| `--ref REF` | Install from a branch or tag instead of `main` |
| `--help` | The same summary, from the script itself |
-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
-and works from a partial vocabulary without saying so.
+The script downloads one archive of the repository, copies the skills out of it, 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 extracts to a temporary directory and copies each
+skill into place only once the whole archive has 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 needs `curl` and `tar`.
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
@@ -155,7 +156,8 @@ version:
curl -fsSL https://stackguardian.github.io/tirith/skill.sh | sh
```
-Re-running is safe: it overwrites the files it owns, in both skills, and leaves everything else alone.
+Re-running is safe: it replaces each skill directory it owns wholesale, so a file removed upstream
+does not linger, and it 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 2c9e75f4..4913e464 100644
--- a/documentation/static/docs/tirith-usage/agent-skills.md
+++ b/documentation/static/docs/tirith-usage/agent-skills.md
@@ -32,11 +32,12 @@ skill until it is restarted; a new session sees it immediately.
| `--ref REF` | Install from a branch or tag instead of `main` |
| `--help` | The same summary, from the script itself |
-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
-and works from a partial vocabulary without saying so.
+The script downloads one archive of the repository, copies the skills out of it, 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 extracts to a temporary directory and copies each
+skill into place only once the whole archive has 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 needs `curl` and `tar`.
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
@@ -143,7 +144,8 @@ version:
curl -fsSL https://stackguardian.github.io/tirith/skill.sh | sh
```
-Re-running is safe: it overwrites the files it owns, in both skills, and leaves everything else alone.
+Re-running is safe: it replaces each skill directory it owns wholesale, so a file removed upstream
+does not linger, and it 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 60523104..a9b0886e 100644
--- a/documentation/static/llms-full.txt
+++ b/documentation/static/llms-full.txt
@@ -3729,11 +3729,12 @@ skill until it is restarted; a new session sees it immediately.
| `--ref REF` | Install from a branch or tag instead of `main` |
| `--help` | The same summary, from the script itself |
-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
-and works from a partial vocabulary without saying so.
+The script downloads one archive of the repository, copies the skills out of it, 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 extracts to a temporary directory and copies each
+skill into place only once the whole archive has 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 needs `curl` and `tar`.
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
@@ -3840,7 +3841,8 @@ version:
curl -fsSL https://stackguardian.github.io/tirith/skill.sh | sh
```
-Re-running is safe: it overwrites the files it owns, in both skills, and leaves everything else alone.
+Re-running is safe: it replaces each skill directory it owns wholesale, so a file removed upstream
+does not linger, and it 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 4c937120..1d8b6614 100644
--- a/documentation/static/skill.sh
+++ b/documentation/static/skill.sh
@@ -1,6 +1,6 @@
#!/usr/bin/env sh
#
-# Install the Tirith policy skill for a coding agent.
+# Install the Tirith skills for a coding agent.
#
# curl -fsSL https://stackguardian.github.io/tirith/skill.sh | sh
#
@@ -8,12 +8,13 @@
# 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 three skills into .claude/skills/ -- tirith-policies (eleven markdown
-# files plus a worked example), tirith-standards (generating a policy set from an existing
-# Terraform or OpenTofu repository) 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.
+# What it does: downloads one archive of the repository at REF, copies every skill under
+# .claude/skills/ out of it into ./.claude/skills/ (tirith-policies, tirith-standards,
+# tirith-migrate today) 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 the download, and it never touches a file it did not
+# create. One request rather than one per file: the first version fetched each file from
+# raw.githubusercontent.com and took ten minutes on a slow link.
#
# Flags:
# --global install into ~/.claude/skills/ instead of ./.claude/skills/
@@ -21,111 +22,14 @@
# --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.
+# gives you. Needs curl and tar.
set -eu
REPO="StackGuardian/tirith"
REF="main"
-PACK=".claude/skills/tirith-policies"
DEST="."
CURSOR=0
-MIGRATE_PACK=".claude/skills/tirith-migrate"
-STANDARDS_PACK=".claude/skills/tirith-standards"
-
-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-standards, relative to its own pack root. One path per line so the list stays diffable.
-STANDARDS_FILES="SKILL.md
-reference/standards.md
-examples/org-standards/README.md
-examples/org-standards/naming.json
-examples/org-standards/should-fail.json
-examples/org-standards/should-pass.json
-examples/org-standards/no-bucket.json"
-
-# tirith-migrate, relative to its own pack root. One path per line so the list stays diffable.
-MIGRATE_FILES="SKILL.md
-reference/azure-policy-corpus.md
-reference/azure-policy.md
-reference/sentinel-corpus.md
-reference/sentinel.md
-examples/azure-policy/README.md
-examples/sentinel/README.md
-examples/azure-policy/allowed-locations/notes.md
-examples/azure-policy/allowed-locations/policy.json
-examples/azure-policy/allowed-locations/should-fail.json
-examples/azure-policy/allowed-locations/should-pass.json
-examples/azure-policy/allowed-locations/source.json
-examples/azure-policy/allowed-locations/variables.json
-examples/azure-policy/allowed-resource-types/notes.md
-examples/azure-policy/allowed-resource-types/policy.json
-examples/azure-policy/allowed-resource-types/should-fail.json
-examples/azure-policy/allowed-resource-types/should-pass.json
-examples/azure-policy/allowed-resource-types/source.json
-examples/azure-policy/allowed-vm-skus/notes.md
-examples/azure-policy/allowed-vm-skus/policy.json
-examples/azure-policy/allowed-vm-skus/should-fail.json
-examples/azure-policy/allowed-vm-skus/should-pass.json
-examples/azure-policy/allowed-vm-skus/source.json
-examples/azure-policy/allowed-vm-skus/variables.json
-examples/azure-policy/inherit-tag-from-resource-group/notes.md
-examples/azure-policy/inherit-tag-from-resource-group/source.json
-examples/azure-policy/not-allowed-resource-types/notes.md
-examples/azure-policy/not-allowed-resource-types/policy.json
-examples/azure-policy/not-allowed-resource-types/should-fail.json
-examples/azure-policy/not-allowed-resource-types/should-pass.json
-examples/azure-policy/not-allowed-resource-types/source.json
-examples/azure-policy/nsg-rdp-from-internet/diverges.json
-examples/azure-policy/nsg-rdp-from-internet/notes.md
-examples/azure-policy/nsg-rdp-from-internet/policy.json
-examples/azure-policy/nsg-rdp-from-internet/should-fail.json
-examples/azure-policy/nsg-rdp-from-internet/should-pass.json
-examples/azure-policy/nsg-rdp-from-internet/source.json
-examples/azure-policy/require-tag/notes.md
-examples/azure-policy/require-tag/policy.json
-examples/azure-policy/require-tag/should-fail-null-tags.json
-examples/azure-policy/require-tag/should-fail.json
-examples/azure-policy/require-tag/should-pass.json
-examples/azure-policy/require-tag/source.json
-examples/azure-policy/require-tag/variables.json
-examples/azure-policy/storage-secure-transfer/notes.md
-examples/azure-policy/storage-secure-transfer/policy.json
-examples/azure-policy/storage-secure-transfer/should-fail-azurerm3.json
-examples/azure-policy/storage-secure-transfer/should-fail.json
-examples/azure-policy/storage-secure-transfer/should-pass.json
-examples/azure-policy/storage-secure-transfer/source.json
-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/mandatory-tags/source.sentinel
-examples/sentinel/prevent-database-destroy/notes.md
-examples/sentinel/prevent-database-destroy/policy.json
-examples/sentinel/prevent-database-destroy/should-fail-replacement.json
-examples/sentinel/prevent-database-destroy/should-fail.json
-examples/sentinel/prevent-database-destroy/should-pass.json
-examples/sentinel/prevent-database-destroy/source.sentinel
-examples/sentinel/require-private-registry-modules/notes.md
-examples/sentinel/require-private-registry-modules/source.sentinel
-examples/sentinel/restrict-instance-type/notes.md
-examples/sentinel/restrict-instance-type/policy.json
-examples/sentinel/restrict-instance-type/should-fail.json
-examples/sentinel/restrict-instance-type/should-pass.json
-examples/sentinel/restrict-instance-type/source.sentinel
-examples/sentinel/restrict-instance-type/variables.json
-examples/sentinel/restrict-ssh-ingress/diverges.json
-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/source.sentinel
-examples/azure-policy/storage-secure-transfer/arm/policy.json
-examples/azure-policy/storage-secure-transfer/arm/should-fail.json
-examples/azure-policy/storage-secure-transfer/arm/should-pass-no-storage.json
-examples/azure-policy/storage-secure-transfer/arm/should-pass.json"
while [ $# -gt 0 ]; do
case "$1" in
@@ -133,7 +37,7 @@ while [ $# -gt 0 ]; do
--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\}//'
+ sed -n '3,26p' "$0" 2>/dev/null | sed 's/^# \{0,1\}//'
exit 0
;;
*) printf 'skill.sh: unknown option %s\n' "$1" >&2; exit 2 ;;
@@ -142,69 +46,54 @@ while [ $# -gt 0 ]; do
done
command -v curl >/dev/null 2>&1 || { echo "skill.sh: curl is required" >&2; exit 1; }
+command -v tar >/dev/null 2>&1 || { echo "skill.sh: tar is required" >&2; exit 1; }
-BASE="https://raw.githubusercontent.com/$REPO/$REF/$PACK"
-TARGET="$DEST/$PACK"
+# The archive URL can be overridden for testing against a local `git archive` tarball.
+ARCHIVE="${TIRITH_SKILL_ARCHIVE:-https://codeload.github.com/$REPO/tar.gz/refs/heads/$REF}"
-# 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.
+# Everything lands in a temporary directory first and is copied into place only once the
+# archive has been read in full. 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" "$TMP/$EXAMPLE"
-
-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
-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
-SBASE="https://raw.githubusercontent.com/$REPO/$REF/$STANDARDS_PACK"
-for f in $STANDARDS_FILES; do
- mkdir -p "$TMP/standards/$(dirname "$f")"
- fetch "$SBASE/$f" "$TMP/standards/$f"
-done
-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
+if ! curl -fsSL "$ARCHIVE" -o "$TMP/repo.tar.gz"; then
+ # A tag lives under refs/tags, a branch under refs/heads; try the other before giving up.
+ ALT="https://codeload.github.com/$REPO/tar.gz/refs/tags/$REF"
+ [ -n "${TIRITH_SKILL_ARCHIVE:-}" ] || curl -fsSL "$ALT" -o "$TMP/repo.tar.gz" || {
+ printf 'skill.sh: could not download %s (ref %s)\n' "$REPO" "$REF" >&2; exit 1; }
+fi
-MTARGET="$DEST/$MIGRATE_PACK"
-for f in $MIGRATE_FILES; do
- mkdir -p "$MTARGET/$(dirname "$f")"
- cp "$TMP/migrate/$f" "$MTARGET/$f"
+mkdir -p "$TMP/x"
+tar -xzf "$TMP/repo.tar.gz" -C "$TMP/x"
+# GitHub archives have one top-level directory named after the ref; a `git archive` tarball
+# has none. Find the skills directory wherever it landed, at most one level down.
+SRC=""
+for candidate in "$TMP/x/.claude/skills" "$TMP"/x/*/.claude/skills; do
+ [ -d "$candidate" ] && { SRC="$candidate"; break; }
done
-
-STARGET="$DEST/$STANDARDS_PACK"
-for f in $STANDARDS_FILES; do
- mkdir -p "$STARGET/$(dirname "$f")"
- cp "$TMP/standards/$f" "$STARGET/$f"
+[ -n "$SRC" ] || { printf 'skill.sh: the archive for ref %s has no .claude/skills directory\n' "$REF" >&2; exit 1; }
+ROOT="$(dirname "$(dirname "$SRC")")"
+
+TARGET="$DEST/.claude/skills"
+mkdir -p "$TARGET"
+installed=0
+for pack in "$SRC"/*/; do
+ name="$(basename "$pack")"
+ [ -f "$pack/SKILL.md" ] || continue
+ # Replace the pack wholesale so a file dropped upstream does not linger here.
+ rm -rf "$TARGET/$name"
+ cp -R "$pack" "$TARGET/$name"
+ printf 'Installed %s: %s\n' "$name" "$TARGET/$name"
+ installed=$((installed + 1))
done
-
-printf 'Installed the Tirith skill: %s\n' "$TARGET"
-printf 'Installed the standards skill: %s\n' "$STARGET"
-printf 'Installed the migration skill: %s\n' "$MTARGET"
+[ "$installed" -gt 0 ] || { echo "skill.sh: no skills found in the archive" >&2; exit 1; }
if [ "$CURSOR" -eq 1 ]; then
+ RULE="$ROOT/.cursor/rules/tirith-policies.mdc"
+ [ -f "$RULE" ] || { echo "skill.sh: the archive has no .cursor/rules/tirith-policies.mdc" >&2; exit 1; }
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"
+ cp "$RULE" "$DEST/.cursor/rules/tirith-policies.mdc"
printf 'Installed the Cursor rule: %s\n' "$DEST/.cursor/rules/tirith-policies.mdc"
fi