diff --git a/.claude/skills/tirith-policies/SKILL.md b/.claude/skills/tirith-policies/SKILL.md
new file mode 100644
index 00000000..b427587f
--- /dev/null
+++ b/.claude/skills/tirith-policies/SKILL.md
@@ -0,0 +1,133 @@
+---
+name: tirith-policies
+description: Author, debug and review Tirith IaC governance policies. Use when writing or editing files under .tirith/policies, when a Tirith check fails in CI, when asked to add a guardrail to a Terraform or OpenTofu pipeline, or when reading a Tirith result document or exit code.
+---
+
+# Writing Tirith policies
+
+A Tirith policy is **JSON data, not a program**. It names a provider, the value to inspect and
+the condition that value must satisfy. Tirith does the traversal and returns resource-level
+evidence.
+
+Policies live under `.tirith/policies` and are evaluated against the plan a pipeline already
+produces (`terraform show -json tfplan > plan.json`).
+
+## Do not guess the vocabulary
+
+The two mistakes that cost the most time are inventing a `condition.type` and inventing an
+`operation_type`. Both are enumerated below. An unknown condition type is especially expensive:
+**the engine reports it as an ordinary failed check with no error attached**, so it is
+indistinguishable from a real violation and will send someone to debug infrastructure that is
+fine.
+
+If the `mcp` extra is installed, prefer the tools over this file — they read the live registries:
+
+```
+tirith mcp # describe_provider, lint_policy, evaluate, explain_result
+```
+
+## Shape
+
+```json
+{
+ "meta": {
+ "version": "v1",
+ "required_provider": "stackguardian/terraform_plan",
+ "name": "Every resource carries a costcenter tag"
+ },
+ "evaluators": [{
+ "id": "costcenter_tag_present",
+ "description": "Every taggable resource declares a costcenter tag",
+ "provider_args": {
+ "operation_type": "attribute",
+ "terraform_resource_type": "*",
+ "terraform_resource_attribute": "tags.costcenter"
+ },
+ "condition": {"type": "IsNotEmpty"}
+ }],
+ "eval_expression": "costcenter_tag_present"
+}
+```
+
+- `eval_expression` references evaluators **by id** and combines them with `and` / `or`. An
+ evaluator the expression never names cannot affect the verdict.
+- `terraform_resource_type: "*"` means every resource type is in scope.
+
+## Condition types
+
+`ContainedIn`, `Contains`, `Equals`, `GreaterThan`, `GreaterThanEqualTo`, `IsEmpty`,
+`IsNotEmpty`, `LessThan`, `LessThanEqualTo`, `NotContainedIn`, `NotContains`, `NotEquals`,
+`RegexMatch`
+
+There is no `Exists`, no `Matches`, no `In`. Use `IsNotEmpty` for presence.
+
+## Providers and their `operation_type` values
+
+| `required_provider` | Reads | `operation_type` |
+|---|---|---|
+| `stackguardian/terraform_plan` | a `terraform show -json` plan | `action`, `attribute`, `count`, `direct_dependencies`, `direct_references`, `provider_config`, `terraform_version` |
+| `stackguardian/infracost` | an Infracost breakdown | `total_monthly_cost`, `total_hourly_cost` |
+| `stackguardian/kubernetes` | Kubernetes manifests | `attribute` |
+| `stackguardian/json` | any JSON document | `get_value` |
+| `stackguardian/terraform_state` | a state document | as for plans |
+
+## Gotchas the schema does not tell you
+
+Each of these produces a policy that looks correct and behaves wrongly. They cost real time to
+discover.
+
+- **The key naming the value to read differs per provider.** `terraform_plan` and
+ `terraform_state` use `terraform_resource_attribute`; `kubernetes` uses `attribute_path` (and
+ requires `kubernetes_kind`); `json` uses `key_path`. Using another provider's key is not an
+ error — it is *ignored*, so the evaluator reads `None` and tests the condition against nothing.
+- **`error_tolerance` lives inside `condition`**, and its severities are specific:
+ `0` = the resource is being deleted (`change.after` is null), `1` = the type is absent from the
+ plan, `2` = the attribute is absent. Choose the tolerance from which of those you mean to
+ forgive, not by feel.
+- **There is no `NotRegexMatch`**, and no inverse conditions generally. Write the positive
+ detector and invert it in `eval_expression` with `!`. That is the only negation mechanism.
+- **`operation_type: attribute` reads `change.after` only.** Nothing about a resource being
+ *destroyed* is visible through it — use `action` for that.
+- **`count` has no action filter**, and Terraform reports unchanged resources as `no-op`, so
+ `count(*)` measures root-module size, not the size of the change. Blast radius is not
+ expressible today.
+- **`jmespath` and `jq_query` do not ship.** Some test fixtures in the repository reference them,
+ which makes them look supported. The `json` provider supports `get_value`.
+
+## Reading the verdict
+
+Exit codes are a contract. Never collapse them:
+
+| Exit | Meaning |
+|---|---|
+| `0` | Policies passed, or nothing was in scope |
+| `3` | A policy ran and said no — the change violates a rule |
+| `1` | Tirith could not tell you either way: bad input, an unevaluable policy, or **every check skipped** |
+
+`final_result: null` means every check was skipped. **That is not a pass.** It almost always
+means `provider_args` matched nothing — check `terraform_resource_type` and the attribute path
+before touching the condition.
+
+One asymmetry worth knowing: when a check fails because an attribute is *absent*, there is no
+value to attach a resource to, so that failure arrives **without a resource address**. Find the
+culprit by looking in the plan for the resource lacking the attribute.
+
+## Before you hand a policy back
+
+1. Does `eval_expression` reference every evaluator you wrote?
+2. Is every `condition.type` in the list above?
+3. Did you **run it**? A policy that matches nothing looks identical to one that works.
+
+```bash
+tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error
+```
+
+Never claim a policy works without evaluating it against a document that should fail it. A
+guardrail only ever seen passing is a guardrail nobody has tested.
+
+## Reference
+
+- Policy reference — https://stackguardian.github.io/tirith/docs/tirith-policies/tirith-policy-reference/
+- Conditions — https://stackguardian.github.io/tirith/docs/tirith-policies/tirith-policy-conditions/
+- Exit codes — https://stackguardian.github.io/tirith/docs/tirith-usage/exit-codes/
+- Worked examples — `src/tirith/tui/examples/`
diff --git a/.cursor/rules/tirith-policies.mdc b/.cursor/rules/tirith-policies.mdc
new file mode 100644
index 00000000..b9563d0f
--- /dev/null
+++ b/.cursor/rules/tirith-policies.mdc
@@ -0,0 +1,47 @@
+---
+description: Authoring and debugging Tirith IaC governance policies
+globs: ["**/.tirith/policies/**", "**/*.tirith.json"]
+alwaysApply: false
+---
+
+Tirith policies are JSON data, not programs. Do not invent vocabulary — both registries are
+closed, and an unknown `condition.type` reaches the engine as an ordinary failed check with no
+error attached, so it reads as a real infrastructure violation and sends someone to debug
+working infrastructure.
+
+**Condition types** — the complete list:
+`ContainedIn`, `Contains`, `Equals`, `GreaterThan`, `GreaterThanEqualTo`, `IsEmpty`,
+`IsNotEmpty`, `LessThan`, `LessThanEqualTo`, `NotContainedIn`, `NotContains`, `NotEquals`,
+`RegexMatch`. There is no `Exists` — use `IsNotEmpty` for presence.
+
+**`operation_type` by provider:**
+- `stackguardian/terraform_plan` — `action`, `attribute`, `count`, `direct_dependencies`,
+ `direct_references`, `provider_config`, `terraform_version`
+- `stackguardian/infracost` — `total_monthly_cost`, `total_hourly_cost`
+- `stackguardian/kubernetes` — `attribute`
+- `stackguardian/json` — `get_value`
+
+**Structure:** `meta` (`version`, `required_provider`, `name`) · `evaluators[]` (each with `id`,
+`provider_args`, `condition`) · `eval_expression` combining evaluator ids with `and` / `or`. An
+evaluator the expression never references cannot affect the verdict.
+
+**Verdicts:** exit `0` passed · `3` a policy failed · `1` no verdict was reached. `final_result:
+null` means every check was skipped — that is not a pass, and it usually means `provider_args`
+matched nothing.
+
+**Gotchas:** the key naming the value differs per provider — `terraform_resource_attribute`
+for terraform_plan/state, `attribute_path` plus `kubernetes_kind` for kubernetes, `key_path` for
+json. Another provider's key is ignored rather than rejected, so the evaluator reads nothing. `error_tolerance` goes inside
+`condition` — severity `0` = resource being deleted, `1` = type absent, `2` = attribute absent.
+`attribute` reads `change.after` only, so use `action` for destroys. `count(*)` is root-module
+size, not change size. `jmespath` and `jq_query` do not ship despite appearing in test fixtures.
+
+**Always evaluate before claiming a policy works.** A policy that matches nothing looks identical
+to one that works:
+
+```bash
+tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error
+```
+
+Worked examples: `src/tirith/tui/examples/`. Reference:
+https://stackguardian.github.io/tirith/docs/tirith-policies/tirith-policy-reference/
diff --git a/.github/ISSUE_TEMPLATE/first-pipeline-help.md b/.github/ISSUE_TEMPLATE/first-pipeline-help.md
new file mode 100644
index 00000000..3f0b9360
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/first-pipeline-help.md
@@ -0,0 +1,34 @@
+---
+name: Help me govern my first IaC pipeline
+about: Get help adding a local, credential-free first check to your pipeline
+title: 'Help: first pipeline — '
+labels: ['help wanted', 'first-pipeline']
+assignees: ''
+---
+
+A Tirith maintainer or community member can help you add a local,
+credential-free first check.
+
+**Do not include secrets, plan files or private source code in this issue.**
+Redact anything you paste, or reduce it to a minimal public example.
+
+### CI system
+
+
+
+### IaC tool and version
+
+
+
+### How the plan is produced, and where the plan JSON ends up
+
+
+
+### The first guardrail you want to enforce
+
+
+
+### A public example repository, or a redacted workflow snippet
+
+
diff --git a/.github/ISSUE_TEMPLATE/policy-request.md b/.github/ISSUE_TEMPLATE/policy-request.md
new file mode 100644
index 00000000..36e93b27
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/policy-request.md
@@ -0,0 +1,26 @@
+---
+name: Policy request
+about: A rule you cannot express in a Tirith policy today
+title: 'Policy: '
+labels: ['policy', 'enhancement']
+assignees: ''
+---
+
+### The rule, in plain words
+
+
+
+### What you tried
+
+
+
+### What happened instead
+
+
+
+### The input document
+
+
diff --git a/.github/ISSUE_TEMPLATE/proposal.md b/.github/ISSUE_TEMPLATE/proposal.md
new file mode 100644
index 00000000..1c086163
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/proposal.md
@@ -0,0 +1,36 @@
+---
+name: Proposal / RFC
+about: Propose a design change before writing it
+title: 'RFC: '
+labels: ['rfc']
+assignees: ''
+---
+
+Substantial changes should start here rather than as a pull request — it is
+cheaper to disagree about an approach in a paragraph than in a diff. See
+[GOVERNANCE.md](../../GOVERNANCE.md) for how these get decided.
+
+### The problem
+
+
+
+### Proposed change
+
+
+
+### Contracts this touches
+
+
+
+### Alternatives considered
+
+
+
+### Who this affects, and how they migrate
+
+
diff --git a/.github/repository-metadata.md b/.github/repository-metadata.md
new file mode 100644
index 00000000..363c99b7
--- /dev/null
+++ b/.github/repository-metadata.md
@@ -0,0 +1,77 @@
+# Repository metadata
+
+GitHub's About panel, topics and social preview cannot be set from a file in
+the repository — a maintainer has to apply them in **Settings** and in the
+sidebar's **About** gear. This file is the source of truth for what they should
+say, so the values are reviewable in a pull request rather than living only in
+one person's browser.
+
+Keep it in step with the landing page (`documentation/src/pages/index.js`) and
+the README opening.
+
+## About
+
+**Description**
+
+> Open-source IaC governance for any Terraform or OpenTofu pipeline. Evaluate
+> plans locally, explain failures and stop unsafe changes before apply.
+
+**Website**
+
+> https://stackguardian.github.io/tirith/
+
+**Topics**
+
+```
+terraform
+opentofu
+infrastructure-as-code
+policy-as-code
+devops
+platform-engineering
+ci-cd
+compliance
+cloud-security
+github-actions
+gitlab-ci
+```
+
+## Social preview
+
+Settings → General → Social preview. 1280×640.
+
+**Title**
+
+> Tirith — open-source IaC governance
+
+**Subtitle**
+
+> Put governance in front of every Terraform or OpenTofu plan.
+
+> [!NOTE]
+> **Open item:** the image itself does not exist yet. Until it is uploaded,
+> GitHub falls back to the repository owner's avatar, which reads as a
+> StackGuardian link rather than a project link.
+
+## Settings to confirm before launch
+
+- **Private vulnerability reporting** enabled (Settings → Security). Without
+ it, the reporting link in [SECURITY.md](../SECURITY.md) does not work.
+- **Discussions** enabled, if the "tell us how the first plan went" link on the
+ landing page is to resolve; otherwise repoint it at Issues.
+- A **`good first issue`** label with 8–12 genuinely scoped issues behind it.
+ Both the README and the landing page link to that label.
+- **`POSTHOG_KEY`** repository secret and optional **`POSTHOG_HOST`** variable,
+ read by `.github/workflows/deploy_docs.yml`. Unset means the published site
+ ships no analytics, which is a safe default rather than a failure.
+- **`HUBSPOT_PORTAL_ID`** and **`HUBSPOT_FORM_GUID`** repository variables, for
+ the Fleet enquiry form. The HubSpot form needs properties named `email`,
+ `company`, `repository_band`, `ci_systems`, `primary_problem` and `context`.
+ Unset, the form disables itself and points at GitHub Issues instead.
+
+## Naming
+
+Search results and social cards use **Tirith IaC Governance** (the Docusaurus
+site title) rather than bare "Tirith", to distinguish the project from the
+unrelated Tirith terminal-security tool and the unrelated `tirith` package on
+PyPI. Keep that qualifier in any new SEO-facing copy.
diff --git a/.github/workflows/deploy_docs.yml b/.github/workflows/deploy_docs.yml
index c18f02dc..c25ddfa0 100644
--- a/.github/workflows/deploy_docs.yml
+++ b/.github/workflows/deploy_docs.yml
@@ -29,7 +29,20 @@ jobs:
cd documentation
npm ci
+ # POSTHOG_KEY is optional. Unset -- on a fork, or in a pull-request build --
+ # the site ships no analytics script at all, which is the behaviour we want
+ # everywhere except the published site.
- name: Build documentation site
+ env:
+ POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }}
+ POSTHOG_HOST: ${{ vars.POSTHOG_HOST }}
+ # The Fleet enquiry form. Neither is a secret -- a HubSpot portal id
+ # and form guid are public by design, since the browser posts to them
+ # directly -- but they are supplied here rather than committed so a
+ # fork's build does not point at StackGuardian's CRM. Unset, the form
+ # disables itself and explains why.
+ HUBSPOT_PORTAL_ID: ${{ vars.HUBSPOT_PORTAL_ID }}
+ HUBSPOT_FORM_GUID: ${{ vars.HUBSPOT_FORM_GUID }}
run: |
cd documentation
npm run build
diff --git a/ADOPTERS.md b/ADOPTERS.md
new file mode 100644
index 00000000..59af588a
--- /dev/null
+++ b/ADOPTERS.md
@@ -0,0 +1,29 @@
+# Adopters
+
+Organisations and teams using Tirith, listed voluntarily.
+
+This file is empty on purpose. Nobody has been added without asking, and nobody
+will be. A logo on a page that its owner did not agree to is worth less than an
+empty list.
+
+## Adding yourself
+
+Open a pull request adding a row. Anything you would rather not state, leave
+out — the name alone is a perfectly good entry.
+
+| Organisation | Since | Scale | How it is used |
+|---|---|---|---|
+| _(your team here)_ | | | |
+
+- **Scale** is deliberately vague: "a handful of repositories", "~200
+ pipelines". No precise counts are wanted.
+- **How it is used** is a sentence, not a case study. "Tag and cost policies on
+ every AWS plan in CI" is ideal.
+- Adding yourself here is not an endorsement of StackGuardian, and does not
+ create any commercial relationship.
+
+## Quotes
+
+If you are happy to be quoted on the project's landing page or README, say so
+in the same pull request and include the exact wording and the attribution you
+want. Nothing is used beyond what you write, and it is removed on request.
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 00000000..46901dad
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,75 @@
+# Working on Tirith with a coding agent
+
+Tirith is an open-source IaC governance tool: it evaluates the Terraform or OpenTofu plan a
+pipeline already produces against declarative JSON policies and returns a pass, warning, failure
+or unevaluated verdict.
+
+This file is for an agent working **on this repository**. If you are writing policies in someone
+else's repository, the skill at `.claude/skills/tirith-policies/SKILL.md` is the one you want —
+it is self-contained and can be copied into any project.
+
+## Layout
+
+| Path | What lives there |
+|---|---|
+| `src/tirith/core/` | The evaluation engine and the condition registry (`EVALUATORS_DICT`) |
+| `src/tirith/providers/` | One directory per provider; each turns an input document into values |
+| `src/tirith/platform/` | `tirith platform check` — the only surface that talks to a network |
+| `src/tirith/tui/` | `tirith ui`, optional `[tui]` extra |
+| `src/tirith/mcp/` | `tirith mcp`, optional `[mcp]` extra |
+| `src/tirith/tui/examples/` | Worked policy/input pairs, used by the UI, the docs site and the tests |
+| `documentation/` | The Docusaurus site |
+| `tests/` | pytest |
+
+## Running things
+
+```bash
+pip install -e . # the CLI
+pip install -e '.[tui]' # plus the interactive interface (needs Python 3.9+)
+pip install -e '.[mcp]' # plus the MCP server (needs Python 3.10+)
+
+pytest # the suite
+tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error
+```
+
+The documentation site needs Node 18 or newer:
+
+```bash
+cd documentation && npm ci && npm run build
+```
+
+## Things that will bite you
+
+**Exit codes are a contract, not a convention.** `0` passed, `3` a policy failed, `1` Tirith
+could not reach a verdict, `2` platform timeout. `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. Both surfaces fail
+closed. `tests/core/test_output_compatibility.py` asserts the `--json` output is byte-identical
+to a golden file — if you change the result shape, that test is the conversation.
+
+**`final_result: null` is not a pass.** It means every check was skipped, so the policy evaluated
+nothing. It exits `1`, not `0` and not `3`.
+
+**Optional extras must degrade, not crash.** `tui` needs Python 3.9 and `mcp` needs 3.10, while
+Tirith itself supports 3.8. Both are dispatched before the flat argument parser and both report a
+missing extra as an actionable message rather than an ImportError traceback. Keep the SDK imports
+inside the subcommand's `cli.py`, never at package import time — the tests rely on importing the
+package without the extra installed.
+
+**Local mode makes no network call.** That is a published governance commitment, not just current
+behaviour. Nothing outside `src/tirith/platform/` may open a connection.
+
+**Adding a condition type** means adding it to `EVALUATORS_DICT`; the MCP server, the docs page
+and the skill's list all read from or mirror that registry. Adding a provider `operation_type` to
+`terraform_plan` means updating `_TERRAFORM_PLAN_OPS` in `src/tirith/mcp/tools.py` — there is a
+drift test that will tell you.
+
+## Style
+
+- Comments explain **why**, not what. The existing code is comment-rich in that specific way;
+ match it rather than stripping it.
+- British spelling in prose; American in code identifiers where the ecosystem uses it.
+- Commit messages: imperative present tense (`Add …`, not `Added …`), with a body saying why.
+- Every behavioural change needs a test that fails without it.
+
+See [CONTRIBUTING.md](CONTRIBUTING.md) for the pull-request process and
+[GOVERNANCE.md](GOVERNANCE.md) for which changes need two approvals.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 23c3b402..2161ba73 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,91 +1,115 @@
-# Contributing to Tirith (StackGuardian Policy Framework)
+# Contributing to Tirith
-Thank you for taking the time to contribute! 🎉
-Contributions are welcome, and they are greatly appreciated! Every
-little bit helps, and credit will always be given.
+Thank you for taking the time to contribute. Every bit helps, and credit is always given.
-The following is a set of guidelines for contributing to Tirith on GitHub. These are mostly guidelines, not rules. Use your best judgment, and feel free to propose changes to this document in a pull request.
+These are guidelines rather than rules. Use your judgement, and propose changes to this document in
+a pull request if something here is wrong or unhelpful.
-## Join the StackGuardian Community
-We'd love for you to join our community! Join our [Slack](https://join.slack.com/t/stackguardian-ol78820/shared_invite/zt-2ksag36j9-OjmXqQmyXudgYrV6FmesIQ) to ask questions, share ideas, and connect with other contributors. Follow us on [LinkedIn](https://www.linkedin.com/company/stackguardian/posts/?feedView=all) to stay updated on the latest news and announcements.
+## Where the project lives
-## Contribution types
+**Everything happens on GitHub, in public.** Bugs, feature requests, policy questions, design
+disagreements and help getting a first pipeline gated all go through
+[Issues](https://github.com/StackGuardian/tirith/issues/new/choose). There is no private channel
+you need to be in to participate, and no account anywhere but GitHub.
-### Report Bugs
+Public by default is deliberate: a question answered in an issue is findable by the next person
+with the same problem.
-We use GitHub issues to track bugs at [https://github.com/stackguardian/tirith/issues](https://github.com/stackguardian/tirith/issues). Please use Bug report issue template.
+- **How decisions are made** — [GOVERNANCE.md](GOVERNANCE.md)
+- **Who maintains what** — [MAINTAINERS.md](MAINTAINERS.md)
+- **What is planned** — [ROADMAP.md](ROADMAP.md)
+- **Where to ask for help** — [SUPPORT.md](SUPPORT.md)
+- **Reporting a vulnerability** — [SECURITY.md](SECURITY.md), which is a private route. Not an
+ issue.
-### Fix Bugs and implement features
+The [Code of Conduct](CODE_OF_CONDUCT.md) applies everywhere in the project.
-All contributions to solve GitHub issues tagged with "bug", "enhancement" and "help wanted" are most welcome and greatly appreciated.
+## Ways to contribute
-### Documentation
+### Report a bug
-Trith could always use more documentation, whether as part of the
-official Tirith docs, in docstrings, or even on the web in blog posts,
-articles, and such.
+[Open a bug report](https://github.com/StackGuardian/tirith/issues/new/choose). A reproduction —
+even a rough one — is the difference between a fix this week and a fix eventually.
-### Submit Feedback
+**Do not include secrets, plan files or private source code.** Redact them, or reduce the case to
+a minimal public example. This applies to every issue and every pull request.
-Please use GitHub Discussions to submit feedback and engage with community [https://github.com/StackGuardian/feedback/discussions/8](https://github.com/StackGuardian/feedback/discussions/8).
+### Request a policy you cannot express
-## Basic guidelines
+Use the policy request template. Say the rule in plain words, show what you tried, and show what
+happened instead. A rule that is awkward to write is usually a missing provider operation rather
+than a missing language feature, and that is worth knowing.
-### Commits
+### Fix bugs and implement features
-- Use the imperative, present tense («change», not «changed» or «changes») to be consistent with generated messages from commands like git merge.
-- Describe the changes you have made
+Issues labelled `bug`, `enhancement` and `help wanted` are all fair game. Issues labelled
+[good first issue](https://github.com/StackGuardian/tirith/labels/good%20first%20issue) carry
+enough maintainer context and acceptance criteria to start on without asking.
-#### Examples
-**Good example**:
- - Commit Message:\
- `Add feature to calculate total monthly cost for AWS resources`
+### Improve the documentation
-- Description:\
-`Implement a function that calculates the total monthly cost for AWS resources. Update the documentation to reflect this new feature.`
+Docs count as contributions, and are frequently the highest-leverage ones. That includes
+docstrings, the documentation site under `documentation/`, and worked policy examples.
- Why it’s Good:
- > **Clarity**: Clearly states the action and the feature.\
- >**Specificity**: Specifies what the feature is and what it affects (AWS resources).\
- >**Consistency**: Uses imperative, present tense, aligning with best practices.
+### Propose a design change
+Substantial changes should start as an RFC issue describing the problem, before a pull request
+describing the solution — it is cheaper to disagree about an approach in a paragraph than in a
+diff. Changes to the policy schema, the CLI contract, the action's behaviour, or what leaves the
+machine need two maintainer approvals; see [GOVERNANCE.md](GOVERNANCE.md).
-**Bad Example**:
-- Commit Message:\
- `Fixed some stuff`
-- Description:\
-`Made changes to the code to fix issues. Updated a few things here and there.`
- Why it’s Bad:
- > **Vague**: Does not explain what was fixed.\
- > **Lacks Detail**: Provides no insight into what "stuff" refers to or how it was changed.\
- > Does not use imperitve present tense.
+## Getting an issue assigned
-### Pull Requests
+Ask on the issue before you start, and a maintainer will assign it. Then:
-- **Stay Updated**: Make sure your PR is based on the latest code from the `main` branch.
-- **Clear Title and Description**: Try to include a clear, descriptive title and a detailed explanation of your changes.
-- **Reference Issues**: If applicable, link to related issues and PRs.
-- **Pass Tests**: Ensure all tests pass before submitting your PR.
-- **Be Open to Feedback**: We're all here to help each other improve, so please be open to feedback and ready to make adjustments.
+- Work on one issue at a time.
+- Limit yourself to four `good first issue` items in total; after that, please move on to other
+ kinds of issue so the easy ones stay available to newcomers.
+- If an issue assigned to you goes quiet for about a week, the assignee is removed so somebody else
+ can pick it up. Say so on the issue if you are still on it — that is enough.
-### Creating Issues
+## Opening a pull request
-- **Search First**: It helps to check if your problem or feature request has already been discussed before opening a new issue.
-- **Be Detailed**: When you open a new issue, providing as much detail as possible really helps. Feel free to use our templates for bugs and feature requests.
-- **Be Respectful**: Let's all be kind and considerate in our communication.
+1. Fork the repository and clone your fork.
+2. Create a branch named for the change: `git switch -c fix-equals-evaluator`.
+3. Make the change, and **add a test that fails without it**.
+4. Run the test suite and the linters.
+5. Push, and open a pull request against `main`.
-### Solving Issues
+What makes a pull request easy to merge:
-- Limit yourself to solving a maximum of four `good first issues`. Once you've reached this limit, consider tackling other types of issues.
-- Please work on only one issue at a time.
-- Please ask for assignee before working, and if there's no update for about a week on a particular issue, we'll remove the assignee.
+- **Based on current `main`.** Rebase rather than merging `main` into your branch.
+- **A clear title and description.** Say what changed and why; link the issue it closes.
+- **Green CI.**
+- **Openness to review.** Review comments are about the change, not about you.
-Thank you for taking the time to help improve our project!
+### Commit messages
+Use the imperative, present tense — «change», not «changed» or «changes» — so your messages read
+the same way as the ones git generates itself. Describe what the change does, and why if it is not
+obvious.
+**Good:**
-### If you have commit access:
+> `Add support for calculating total monthly cost of AWS resources`
+>
+> Implement a function that sums the monthly cost of the resources an Infracost breakdown would
+> create, so a policy can gate on the total. Update the provider documentation.
-- Do NOT use git push --force.
-- Do NOT commit to other contributor's branches without their consent.
-- Use Pull Requests if you are unsure and to suggest changes to other maintainers.
+Clear about the action, specific about what it affects, and consistent with the rest of the log.
+
+**Bad:**
+
+> `Fixed some stuff`
+>
+> Made changes to the code to fix issues. Updated a few things here and there.
+
+Vague, no detail about what "stuff" was, and the wrong tense.
+
+## If you have commit access
+
+- Do **not** use `git push --force` on shared branches.
+- Do **not** commit to another contributor's branch without their consent.
+- Use a pull request when you are unsure, or when suggesting changes to another maintainer's work.
+
+Thank you for taking the time to help improve Tirith.
diff --git a/GOVERNANCE.md b/GOVERNANCE.md
new file mode 100644
index 00000000..5705de8b
--- /dev/null
+++ b/GOVERNANCE.md
@@ -0,0 +1,111 @@
+# Tirith governance
+
+Tirith is an Apache-2.0 open-source project governed by its maintainers.
+StackGuardian contributes engineering time, infrastructure and production
+experience, but using, forking or contributing to Tirith does not require a
+StackGuardian account or a commercial relationship. Project decisions are made
+in public, through GitHub Issues and pull requests, under the process below.
+
+## What this project promises
+
+These commitments hold for every release. If one of them has to change, it
+changes in public, in a pull request against this file, with the reasoning
+stated.
+
+- **Local policy evaluation stays usable without a StackGuardian account.**
+ Running `tirith` against a plan on your own machine or runner will not start
+ requiring credentials.
+- **The open parts stay open.** The policy schema, the providers, the CLI
+ contract, the action's local mode and the example policy library are
+ Apache-2.0 and remain so.
+- **Local mode sends nothing.** No telemetry, no plans, no source, no results —
+ unless a future opt-in is added, and then only as an opt-in that is off by
+ default and documented before it ships.
+- **Commercial capabilities are labelled.** Anything that needs a StackGuardian
+ organisation is called out as such in the docs, on the landing page, in demos
+ and in release notes. `tirith platform check` is the only such surface today.
+- **Breaking changes are versioned and documented**, and CI examples pin a
+ released tag rather than a moving branch.
+- **Community policies get a real home.** Accepted policy contributions live in
+ a public, tested policy library with clear ownership and licensing.
+
+## Roles
+
+**Contributor** — anyone who opens an issue or a pull request. No prior
+involvement is expected, and no account anywhere but GitHub is needed.
+
+**Maintainer** — has commit access and review authority over some area of the
+project. Maintainers are listed in [MAINTAINERS.md](MAINTAINERS.md), which also
+records what each one looks after.
+
+There is no third tier. If the project grows enough to need one, it will be
+added here first.
+
+## How decisions get made
+
+Most changes need one maintainer approval and green CI. That covers bug fixes,
+documentation, new policy examples, new conditions and new provider operations.
+
+Changes that alter a contract need **two** maintainer approvals, from
+maintainers who did not author the change:
+
+- the policy schema, or the meaning of an existing field;
+- the CLI's flags, output shape or exit codes;
+- the action's inputs, outputs or default behaviour;
+- what leaves the machine in either mode;
+- anything in this file.
+
+Where a change is contested, the maintainers seek consensus in the pull request
+or issue thread. Consensus means no maintainer sustains an objection — not that
+everybody is enthusiastic. If consensus is not reached within two weeks, a
+simple majority of maintainers decides, and the reasoning is recorded in the
+thread. A maintainer may block a change on the grounds that it breaks one of
+the promises above; that objection is resolved by changing the promise first,
+in its own pull request, or not at all.
+
+Substantial changes should start as an issue describing the problem before a
+pull request describing the solution. This is a courtesy to the contributor as
+much as to the project: it is cheaper to disagree about an approach in a
+paragraph than in a diff.
+
+## Releases
+
+Any maintainer may cut a release. Releases are tagged, and the tag is what CI
+examples and installation instructions point at. A release that changes a
+documented contract says so in [CHANGELOG.md](CHANGELOG.md) and in the release
+notes, in the plain terms a reader upgrading their pipeline needs.
+
+## Adding and removing maintainers
+
+A contributor with a sustained record of good judgement in the project — review
+comments as much as commits — may be nominated by any maintainer. The
+nomination is an issue; it carries if a majority of maintainers agree and none
+sustain an objection.
+
+Maintainers who have been inactive for six months move to emeritus in
+[MAINTAINERS.md](MAINTAINERS.md) and lose commit access, with no implication of
+fault; the door back is another nomination. A maintainer may step down at any
+time by opening a pull request against that file. Removal for cause — repeated
+violation of the [Code of Conduct](CODE_OF_CONDUCT.md), or acting against the
+promises above — requires a majority of the remaining maintainers.
+
+## Relationship to StackGuardian
+
+StackGuardian employs several of the maintainers, funds the project's
+development and operates the optional platform mode that `tirith platform
+check` talks to. That relationship is why the project exists and is worth being
+plain about.
+
+What it does not confer: StackGuardian has no reserved seats, no casting vote
+and no veto. A StackGuardian-employed maintainer's approval counts the same as
+anyone else's, and the two-approval rule above deliberately makes it awkward
+for a single employer's engineers to change a contract quietly. Where a change
+would benefit the platform at the expense of the local, accountless path, the
+promises at the top of this file are the tiebreaker.
+
+## Conduct
+
+The [Code of Conduct](CODE_OF_CONDUCT.md) applies to every project space.
+Reports go to the maintainers listed in [MAINTAINERS.md](MAINTAINERS.md);
+security reports follow [SECURITY.md](SECURITY.md) instead, which is a private
+route.
diff --git a/MAINTAINERS.md b/MAINTAINERS.md
new file mode 100644
index 00000000..c8971bcd
--- /dev/null
+++ b/MAINTAINERS.md
@@ -0,0 +1,46 @@
+# Maintainers
+
+Who looks after what, and how that list changes. The process is in
+[GOVERNANCE.md](GOVERNANCE.md).
+
+> [!IMPORTANT]
+> **This roster was derived from `.github/CODEOWNERS` and the commit history,
+> and has not yet been confirmed by the people named.** Every maintainer should
+> check their own row — handle, email, and especially the areas of
+> responsibility — and correct it in a pull request. Remove this notice once
+> the whole table has been confirmed.
+
+## Current maintainers
+
+| Name | GitHub | Looks after |
+|---|---|---|
+| Akshat Tandon | [@Akshat0694](https://github.com/Akshat0694) | Project direction, releases, code owner for the whole tree |
+| Arunim Chaudhary | [@arunim2405](https://github.com/arunim2405) | CI, supply-chain and security hardening |
+| Rafid Aslam | [@refeed](https://github.com/refeed) | Policy engine, providers, `tirith platform check`, docs |
+
+## Proposed
+
+Derived from sustained recent contribution, and listed here rather than above
+because the nomination in [GOVERNANCE.md](GOVERNANCE.md) has not been run.
+
+| Name | GitHub | Contribution area |
+|---|---|---|
+| Akash S | [@AkashS0510](https://github.com/AkashS0510) | `terraform_plan` provider, `tirith ui`, documentation CI |
+
+## Emeritus
+
+Nobody yet. Maintainers who step down or go inactive move here, with no
+implication of fault, and are welcome back through the same nomination process.
+
+## Getting hold of a maintainer
+
+For anything about the project — a bug, a policy that will not match, a
+question about whether Tirith fits your pipeline — open an
+[issue](https://github.com/StackGuardian/tirith/issues/new/choose). Public by
+default is the point: the answer helps the next person too.
+
+For a suspected vulnerability, do **not** open an issue. Follow
+[SECURITY.md](SECURITY.md), which is a private route.
+
+Code of Conduct reports go to the maintainers above; if the report concerns a
+maintainer, send it to any of the others.
diff --git a/README.md b/README.md
index 49ad8c96..93c89ef0 100644
--- a/README.md
+++ b/README.md
@@ -3,10 +3,25 @@
[](https://github.com/psf/black)
[](https://sonarcloud.io/summary/new_code?id=StackGuardian_policy-framework)
[](https://sonarcloud.io/summary/new_code?id=StackGuardian_policy-framework)
-[](https://join.slack.com/t/stackguardian-ol78820/shared_invite/zt-2ksag36j9-OjmXqQmyXudgYrV6FmesIQ)
[](https://codecov.io/gh/StackGuardian/tirith)
-# Tirith — IaC Governance plugin
+# Tirith — open-source IaC governance
+
+Put governance in front of the Terraform or OpenTofu plan your pipeline already produces. Tirith
+evaluates readable JSON policies on your own runner, reports the rule, resource and value behind
+every verdict, and can stop a non-compliant change before apply.
+
+**Apache-2.0 · no account · no network in local mode · works with any CI**
+
+```yaml
+- run: terraform show -json tfplan > plan.json
+- uses: StackGuardian/tirith-iac-governance-action@v2
+ with: {fail-on-error: true}
+```
+
+[Quick start](#credential-free-quick-start) · [Example policies](#example-tirith-policies) ·
+[Run it in CI](#run-it-in-ci) · [The interactive interface](#the-interactive-interface) ·
+[Star the project](https://github.com/StackGuardian/tirith)
> [!NOTE]
> **New — `tirith ui`, an interactive interface. In beta, and we want your input.**
@@ -14,34 +29,55 @@
> Explore a failing evaluation down to the resource that caused it, assemble policies from a
> form, and experiment in a playground with worked examples. Try it with
> `pip install 'py-tirith[tui] @ git+https://github.com/StackGuardian/tirith.git'`, then
-> `tirith ui` — see
-> [The interactive interface](#the-interactive-interface).
+> `tirith ui` — see [The interactive interface](#the-interactive-interface).
>
> It is new, so the rough edges are still being found. Tell us what is confusing, what is
> missing, or what you would rather it did:
-> [open an issue](https://github.com/StackGuardian/tirith/issues) or say so in
-> [Slack](https://join.slack.com/t/stackguardian-ol78820/shared_invite/zt-2ksag36j9-OjmXqQmyXudgYrV6FmesIQ).
+> [open an issue](https://github.com/StackGuardian/tirith/issues/new/choose).
> Nothing about the existing CLI changes: same flags, same `--json` output, same exit codes.
-**Plugin IaC Governance for any pipeline, running anywhere.** Evaluate plans with Tirith, protect
-sensitive values, enforce centralised governance, and surface actionable results before
-infrastructure changes are applied.
+## What you get from the first run
+
+- **A verdict on the plan you already generate.** No new job, no change to Terraform, no policy
+ language to program. Tirith reads the output of `terraform show -json tfplan`.
+- **The rule, the resource, the action and the value** behind every pass and every failure — not a
+ job log that says a job failed.
+- **An exit code your pipeline can act on.** `3` means a policy said no; `1` means Tirith could not
+ tell you either way. A job that treats every non-zero code alike cannot tell a working gate from
+ a broken one.
+- **Nothing leaving your machine.** Policies are JSON files in your repository and evaluation
+ happens on your runner. There is no account, and local mode makes no network call.
+
+Policies also cover Terraform state, Kubernetes manifests, Infracost breakdowns and arbitrary
+JSON — the same schema and the same verdict for each.
-Tirith reads the plan your pipeline already produces — the output of `terraform show -json tfplan` —
-checks it against your policies, and exits non-zero so a violating change never reaches `apply`. The
-reason it is a plugin rather than an integration is that one policy set then covers every pipeline
-you run it from: the same policy files gate a GitHub Actions job, a GitLab job and a laptop, and in
-platform mode Tirith rules and Checkov findings come back in one verdict instead of two tools you
-have to reconcile by hand.
+## Credential-free quick start
-It is Apache-2.0 and needs no account. Policies are JSON files in your repository, evaluation happens
-on your own runner, and nothing is sent anywhere. If you would rather keep policy in one place across
-many repositories, `tirith platform check` evaluates against the policies a
-[StackGuardian](https://www.stackguardian.io/) organization enforces instead — same document, same
-verdict, same exit codes. That mode is optional and is the only part that talks to a network.
+Two lines on GitHub Actions, with policies committed under `.tirith/policies`:
+
+```yaml
+permissions:
+ contents: read
+ pull-requests: write # sticky comment
+ checks: write # check run
+
+steps:
+ - run: |
+ terraform plan -out=tfplan -input=false
+ terraform show -json tfplan > plan.json
+
+ - uses: StackGuardian/tirith-iac-governance-action@v2
+ with: {fail-on-error: true}
+```
+
+No credentials anywhere: without them the action evaluates your repository's policy files on the
+runner and uploads nothing. GitLab CI and any other container-based CI invoke the CLI directly —
+see [Run it in CI](#run-it-in-ci).
## Content
+- [What you get from the first run](#what-you-get-from-the-first-run)
+- [Credential-free quick start](#credential-free-quick-start)
- [What is Tirith?](#what-is-tirith)
- [Features](#features)
- [Installation](#installation)
@@ -51,8 +87,10 @@ verdict, same exit codes. That mode is optional and is the only part that talks
- [Builder](#builder)
- [Playground](#playground)
- [Serving it on a port](#serving-it-on-a-port)
+- [Use it with a coding agent](#use-it-with-a-coding-agent)
- [Run it in CI](#run-it-in-ci)
- [Exit codes](#exit-codes)
+- [How Tirith differs from a scanner](#how-tirith-differs-from-a-scanner)
- [Evaluating against your StackGuardian organization](#evaluating-against-your-stackguardian-organization)
- [Example Tirith policies](#example-tirith-policies)
- [error_tolerance](#error_tolerance-and-the-third-outcome)
@@ -63,11 +101,8 @@ verdict, same exit codes. That mode is optional and is the only part that talks
- [Kubernetes](#kubernetes)
- [Getting Started](#getting-started)
- [Want to contribute?](#want-to-contribute)
- - [Getting an issue assigned](#getting-an-issue-assigned)
- - [A bug report](#a-bug-report)
- - [Opening a Pull Request and getting it merged](#opening-a-pull-request-and-getting-it-merged)
-- [Submitting a feedback](#submitting-a-feedback)
- [Support](#support)
+- [Project and governance](#project-and-governance)
- [License](#license)
## What is Tirith?
@@ -110,10 +145,10 @@ pip install git+https://github.com/StackGuardian/tirith.git
Pin a tag rather than tracking the default branch, so a CI job cannot change behaviour underneath you:
```
-pip install "git+https://github.com/StackGuardian/tirith.git@1.0.5"
+pip install "git+https://github.com/StackGuardian/tirith.git@1.2.0"
```
-`1.0.5` is the newest tag; `git ls-remote --tags https://github.com/StackGuardian/tirith.git` lists
+`1.2.0` is the newest tag; `git ls-remote --tags https://github.com/StackGuardian/tirith.git` lists
them. Tirith is not on PyPI — `pip install tirith` installs an unrelated project of the same name, so
install from git. Python 3.8 or newer.
@@ -225,8 +260,7 @@ About Tirith:
> [!NOTE]
> **Beta.** Everything below works and is covered by tests, but the interface is new and the
> shape of it is still open. Feedback is genuinely wanted — especially on what is missing.
-> [Open an issue](https://github.com/StackGuardian/tirith/issues) or find us in
-> [Slack](https://join.slack.com/t/stackguardian-ol78820/shared_invite/zt-2ksag36j9-OjmXqQmyXudgYrV6FmesIQ).
+> [Open an issue](https://github.com/StackGuardian/tirith/issues/new/choose).
`tirith ui` opens a terminal interface with three tabs: an **Explorer** for reading results, a
**Builder** for assembling policies, and a **Playground** for experimenting.
@@ -347,6 +381,43 @@ behaves identically and there is nothing extra to keep in sync.
Bind address and port are yours to choose, but note the served interface can read any file path
the serving process can. Keep it on `localhost` unless you have a reason not to.
+## Use it with a coding agent
+
+Ask any agent for "a policy requiring an Owner tag" and it will write plausible JSON against a
+schema it is guessing at — usually inventing a `condition.type` that does not exist. That mistake
+is expensive because the engine reports an unknown condition type as an ordinary **failed check**
+with no error attached: it reads as a real violation, and somebody debugs infrastructure that was
+fine.
+
+Tirith ships an MCP server so the agent reads the real registries and gets a real verdict:
+
+```bash
+pip install 'py-tirith[mcp] @ git+https://github.com/StackGuardian/tirith.git'
+
+claude mcp add tirith -- tirith mcp
+```
+
+Four tools, all local — no network call, nothing written to disk:
+
+| Tool | What it does |
+|---|---|
+| `evaluate` | Runs a policy against a document and returns the real verdict and exit code |
+| `lint_policy` | Catches unknown condition types, a missing `eval_expression`, unreferenced evaluators |
+| `describe_provider` | The providers, their `operation_type` values and every condition type, read from the engine's registries |
+| `explain_result` | Turns a result document into which rule failed, on which resource, and why |
+
+Needs Python 3.10 or newer; it is an optional extra so a CI gate stays dependency-light.
+
+Prefer not to run a server? The vocabulary is just a file:
+[`.claude/skills/tirith-policies/SKILL.md`](.claude/skills/tirith-policies/SKILL.md) is
+self-contained and can be copied into any repository,
+[`AGENTS.md`](AGENTS.md) covers working on Tirith itself, and
+[`.cursor/rules/tirith-policies.mdc`](.cursor/rules/tirith-policies.mdc) attaches automatically
+in Cursor when a policy file is open.
+
+One-click install for Cursor and VS Code, and configuration for Claude Desktop and Codex, are on
+the [AI page](https://stackguardian.github.io/tirith/ai).
+
## Run it in CI
### GitHub Actions
@@ -374,7 +445,7 @@ policy:
image: python:3.12
needs: [plan]
script:
- - pip install "git+https://github.com/StackGuardian/tirith.git@1.0.5"
+ - pip install "git+https://github.com/StackGuardian/tirith.git@1.2.0"
- tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error
```
@@ -412,6 +483,30 @@ One limit worth stating plainly: a *misconfigured* policy — an unsupported `co
it is indistinguishable from a real violation and exits `3`. It fails closed, which is the safe
direction, but it will point at your infrastructure when the fault is in the policy.
+## How Tirith differs from a scanner
+
+Tirith is a policy engine, but its job is not to replace every scanner or policy language. It turns
+the plan your pipeline already produces and the policies you choose into one enforceable decision
+before apply. The comparison below is meant to be fair rather than flattering — pick whichever of
+these fits the job.
+
+| | Primary job | Authoring | Runtime | Where it is strong |
+|---|---|---|---|---|
+| **Tirith** | The governance gate between plan and apply | JSON declarative policy plus providers | Local runner; optional central platform | Adoption from one repository outward, verdict semantics, an optional path to centrally governed execution |
+| **Checkov** | Broad IaC scanning | Large built-in library; Python/YAML custom policies | CLI/CI; optional platform | Breadth, graph checks, many IaC formats, established checks |
+| **OPA / Rego** | General-purpose policy decision engine | Rego | Embedded, CLI, service or platform integration | Expressiveness, portability, a mature policy ecosystem |
+| **Sentinel** | Policy as code for HashiCorp integrations | Sentinel language | Sentinel-enabled products and CLI | Terraform/HCP integration, enforcement levels, testing |
+
+Two things worth saying plainly. Checkov already scans Terraform plan JSON and has far broader
+built-in coverage than Tirith; OPA and Sentinel are mature and more expressive than a JSON schema
+can be. Tirith did not invent plan-time policy.
+
+Where it earns its place is the shape of the result and the cost of adopting it: policies are data
+rather than programs, the same policy and exit-code contract works on a laptop and in every CI
+system, and a check that could not run is reported as `1` rather than quietly passing. In platform
+mode, Tirith rules and Checkov findings come back as one verdict instead of two tools to reconcile
+by hand.
+
## Evaluating against your StackGuardian organization
`tirith platform check` evaluates against the policies your StackGuardian organization enforces,
@@ -1480,50 +1575,61 @@ Final expression used:
## Want to contribute?
-We are calling for contributors to help build out new features, review pull requests, fix bugs, and
-maintain overall code quality. Email us at team[at]stackguardian.io, or get started by reading
-[contributing.md](./CONTRIBUTING.md).
-
-### Getting an issue assigned
-
-Go to the Tirith Repository and in the issues tab describe any bug or feature you want to add. If found relevant, the maintainers will assign the issue to you and you may start working on it as mentioned in the next section.
-
-
The kinds of issues a contributor can open:
-
-
Report Bugs
-
Feature Enhancement
-
If any "help" is needed with using Tirith
-
+Contributions are welcome, and the project is run in public: bugs, feature proposals and
+disagreements about design all go through GitHub. Start with
+[CONTRIBUTING.md](./CONTRIBUTING.md).
-### A bug report
+- **Report a bug or request a policy** —
+ [open an issue](https://github.com/StackGuardian/tirith/issues/new/choose) and pick the template
+ that fits. No secrets, plan files or private source in the issue, please.
+- **Pick something up** — issues labelled
+ [good first issue](https://github.com/StackGuardian/tirith/labels/good%20first%20issue) carry
+ enough context to start on. Ask to be assigned before you begin, and take one at a time.
+- **Propose a design change** — open an RFC issue before writing the pull request. It is cheaper to
+ disagree about an approach in a paragraph than in a diff, and changes to the policy schema, the
+ CLI contract or what leaves the machine need two maintainer approvals either way.
-Head over to the Tirith repository and in the issues tab describe the bug you encountered and we will be happy to take a look into it.
+### Opening a pull request
-### Opening a Pull Request and getting it merged?
+1. Fork the repository and create a branch named for the change
+ (`git switch -c fix-equals-evaluator`).
+2. Make the change, and add a test that fails without it.
+3. Run the test suite and the linters.
+4. Push and open a pull request against `main`, linking the issue it closes.
-1. Go to the repository and fork it.
-2. Clone the repository in your local machine.
-3. Open your terminal and `cd tirith`
-4. Create your own branch to work on the changes you intend to perform. For e.g. if you want some changes or bug fix to any function in the evaluators, name your branch with something relevant like, `git branch bug-fix-equals-evaluator`
-5. After necessary changes, `git push --set-upstream origin bug-fix-equals-evaluator`, `git checkout main` and `git merge bug-fix-equals-evaluator` or use the GUI to create a "Pull Request" after pushing it in the respective branch.
-6. A review request will be sent to the repository maintainers and your changes will be merged if found relevant.
+A maintainer will review it. Approval rules and how contested changes are decided are in
+[GOVERNANCE.md](./GOVERNANCE.md).
-## Submitting a Feedback
+## Support
-Wanna submit a feedback? It's as simple as writing and posting it in the feedback section.
+**[GitHub Issues](https://github.com/StackGuardian/tirith/issues/new/choose) is the support
+channel** — for bugs, policy authoring questions, and help getting a first pipeline gated. Public
+by default is deliberate: a question answered in an issue is findable by the next person with the
+same problem. [SUPPORT.md](./SUPPORT.md) says which template to use.
-
Your feedback will help us improve
+Suspected vulnerabilities go through the private route in [SECURITY.md](./SECURITY.md), not through
+issues.
-## Maintainers
+For anything specific to a StackGuardian organization — enforcement scope, a run that errored, an
+API key — contact StackGuardian support instead, since that needs account context this repository
+has no access to. You never need it for local mode.
-This project is maintained by [StackGuardian](https://www.linkedin.com/company/stackguardian/).
+## Project and governance
-## Support
+Tirith is an Apache-2.0 project governed by its maintainers. StackGuardian contributes engineering
+time, infrastructure and production experience; using, forking or contributing to Tirith requires
+no StackGuardian account and no commercial relationship.
-Open an [issue](https://github.com/StackGuardian/tirith/issues) for a bug or a question about policy
-authoring. For anything specific to a StackGuardian organization — enforcement scope, a run that
-errored, an API key — contact StackGuardian support instead, since that needs account context this
-repository has no access to.
+| | |
+|---|---|
+| [GOVERNANCE.md](./GOVERNANCE.md) | How decisions are made, what the project commits to, and the relationship to StackGuardian |
+| [MAINTAINERS.md](./MAINTAINERS.md) | Who maintains what, and how that changes |
+| [ROADMAP.md](./ROADMAP.md) | Now / next / later, and what is deliberately not planned |
+| [SECURITY.md](./SECURITY.md) | Supported versions and the private vulnerability route |
+| [SUPPORT.md](./SUPPORT.md) | Where to ask, and the community/commercial boundary |
+| [CONTRIBUTING.md](./CONTRIBUTING.md) | How to contribute |
+| [CODE_OF_CONDUCT.md](./CODE_OF_CONDUCT.md) | Expected conduct, and how to report |
+| [ADOPTERS.md](./ADOPTERS.md) | Add your team, if you would like to |
## License
diff --git a/ROADMAP.md b/ROADMAP.md
new file mode 100644
index 00000000..694aefc8
--- /dev/null
+++ b/ROADMAP.md
@@ -0,0 +1,76 @@
+# Roadmap
+
+Themes, not dates. This file says what the maintainers intend to work on and in
+roughly what order; it is not a commitment, and anything here can be argued
+with in an [issue](https://github.com/StackGuardian/tirith/issues).
+
+Everything under **Now** and **Next** is open source and works without an
+account. Where an item involves the optional StackGuardian platform, it is
+labelled — and labelled *planned*, not shipped.
+
+> [!NOTE]
+> **Draft.** These themes were assembled from the launch messaging brief and
+> the current state of the repository. Maintainers should confirm, reorder and
+> cut before this is treated as the project's position.
+
+## Now
+
+Work in progress or next up.
+
+- **Remove first-run friction.** An unambiguous installation story is the
+ biggest one: Tirith is not on PyPI, `pip install tirith` installs an
+ unrelated project, and the git-URL install is a stumbling block for anyone
+ evaluating quickly. Publish and own a package name, or make the git route
+ impossible to get wrong.
+- **A credential-free demo path.** The existing four-PR demo requires a
+ StackGuardian organisation and token, so it demonstrates platform mode. Add a
+ local-mode quick start that reaches the same first verdict with nothing but a
+ repository.
+- **`tirith ui` out of beta.** The explorer, builder and playground shipped
+ recently and the rough edges are still being found. Feedback is wanted.
+- **Community foundations.** Governance, maintainers, support and security
+ documented; issue templates for the paths people actually arrive on; a set of
+ genuinely scoped good-first issues.
+
+## Next
+
+Intended, not started.
+
+- **A policy test harness.** Today a policy that silently matches nothing is
+ hard to distinguish from a policy that passed. Authors need a way to assert
+ that a rule matched what they meant it to match — this is the gap most likely
+ to cost someone real trust in a green check.
+- **GitLab CI support that is as good as the GitHub Action.** Right now GitLab
+ users invoke the CLI directly and get no native reporting. Either a catalog
+ component or an honest statement that the CLI is the supported route.
+- **A published, tested policy library.** Community-contributed policies with
+ clear ownership, licensing and tests, rather than examples pasted from docs.
+- **Better provider coverage and clearer provider errors**, driven by what
+ people actually report.
+- **A fair, reproducible comparison** with Checkov, OPA/Rego and Sentinel —
+ identical policies in a benchmark repository, published including the cases
+ where another tool is the better fit.
+
+## Later
+
+Directional. No design work has been done, and any of it may be dropped.
+
+- **Richer remediation output** — showing the smallest compliant change, not
+ just the failing value.
+- **Policy versioning and deprecation** as policy sets grow past what one
+ person holds in their head.
+- **Governed execution via the StackGuardian workflow API** *(planned; platform
+ mode)* — moving from a policy decision into controlled execution. This does
+ not exist today and should not be described as if it does.
+
+## Not planned
+
+Stated so nobody spends effort proposing them:
+
+- **Replacing scanners.** Tirith is the governance step between plan and apply,
+ not a catalogue of built-in security checks. Checkov's breadth is real and we
+ are not trying to reproduce it.
+- **A second policy language.** Policies are JSON data. If a rule needs a
+ program, that is a signal the provider is missing an operation, not that
+ Tirith needs an expression language.
+- **Network calls in local mode.** See [GOVERNANCE.md](GOVERNANCE.md).
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 00000000..8cdf883f
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,91 @@
+# Security policy
+
+## Supported versions
+
+Tirith is released as git tags; there is no long-term support branch. Security
+fixes land on `main` and are released as a new tag.
+
+| Version | Supported |
+|---|---|
+| Latest tag | Yes |
+| Anything older | No — upgrade to the latest tag |
+
+CI examples in this repository pin a tag rather than tracking `main`, so
+upgrading is a one-line change and a deliberate one. Check what you are pinned
+to with `git ls-remote --tags https://github.com/StackGuardian/tirith.git`.
+
+## Reporting a vulnerability
+
+**Do not open a public issue for a suspected vulnerability.**
+
+Use GitHub's private reporting:
+[Report a vulnerability](https://github.com/StackGuardian/tirith/security/advisories/new).
+It creates a private advisory visible only to you and the maintainers, and it
+is the fastest route.
+
+> [!NOTE]
+> **Confirm before launch:** private vulnerability reporting must be enabled in
+> the repository's Settings → Security before the link above works, and a
+> monitored security contact address should be added here as a fallback for
+> reporters who cannot use GitHub. Both are open items.
+
+A useful report includes what you did, what happened, what you expected, the
+Tirith version or commit, and — if you have one — a minimal reproduction.
+Please do not include real secrets, plan files or private source in a report;
+redact them, the same way the issue templates ask.
+
+### What to expect
+
+- **Acknowledgement within three working days.** If you have not heard back,
+ assume the message was lost rather than ignored, and ping any maintainer in
+ [MAINTAINERS.md](MAINTAINERS.md) — without disclosing details publicly.
+- **An assessment within ten working days**, saying whether we consider it a
+ vulnerability and, if so, roughly when a fix will land.
+- **Credit in the advisory and release notes**, unless you would rather not be
+ named.
+
+We will not take legal action against anyone reporting in good faith, and we
+ask the same courtesy in return: give us a reasonable window to ship a fix
+before disclosing publicly.
+
+## What counts
+
+Things we want to hear about:
+
+- Anything that causes Tirith to report a **pass for a policy that should have
+ failed**, or to exit `0` when the verdict was never established. A gate that
+ silently stops gating is the worst failure this project has.
+- **A sensitive value surviving masking** in `tirith platform check` — a
+ terraform-sensitive value, or a credential-shaped literal, reaching the
+ network unredacted.
+- Code execution, path traversal or file disclosure triggered by a crafted
+ policy file, plan document or provider input.
+- Credential leakage into logs, PR comments, check-run output or the result
+ document.
+- Dependency vulnerabilities that Tirith actually reaches.
+
+## Known limits, already documented
+
+These are stated behaviour rather than vulnerabilities. Reporting them is
+welcome as a documentation or design issue, but they are not secret:
+
+- **`json` and `kubernetes` documents are not masked.** There is no schema that
+ says which fields are secret.
+- **Committed source ships as written** in platform mode. Masking applies to
+ the documents, not to your repository — a secret hardcoded in a `.tf` file
+ reaches the platform even though the plan was masked. `--no-source` is the
+ opt-out.
+- **Terraform's sensitivity markers are not exhaustive.** A value that flows
+ through `locals`, or comes from a provider that did not mark its schema, is
+ not caught by marker-driven masking.
+- **A misconfigured policy fails closed but reads as a violation** — an
+ unsupported `condition.type` or unknown `required_provider` comes back as an
+ ordinary failed check and exits `3`, pointing at your infrastructure when the
+ fault is in the policy.
+
+The full detail is in the
+[platform-check documentation](https://stackguardian.github.io/tirith/docs/tirith-usage/platform-check/)
+and the [exit-code contract](https://stackguardian.github.io/tirith/docs/tirith-usage/exit-codes/).
+
+Local mode makes no network call at all, which bounds a great deal of this: if
+you never pass credentials, nothing leaves your runner.
diff --git a/SUPPORT.md b/SUPPORT.md
new file mode 100644
index 00000000..9c4682fc
--- /dev/null
+++ b/SUPPORT.md
@@ -0,0 +1,58 @@
+# Getting help
+
+**GitHub Issues is the support channel for Tirith.**
+[Open one](https://github.com/StackGuardian/tirith/issues/new/choose).
+
+Public by default is deliberate. A question answered in an issue is findable by
+the next person with the same problem; the same question answered in a private
+channel is answered once.
+
+## Before you open one
+
+- Check the [documentation](https://stackguardian.github.io/tirith/) — the
+ [exit-code contract](https://stackguardian.github.io/tirith/docs/tirith-usage/exit-codes/)
+ in particular, if a job is red and you are not sure why.
+- Search [existing issues](https://github.com/StackGuardian/tirith/issues?q=is%3Aissue).
+- Try `tirith ui` if you are debugging a policy: the explorer walks a failing
+ evaluation down to the resource that caused it, which is usually faster than
+ reading raw JSON.
+
+## Which template
+
+| You have | Use |
+|---|---|
+| Something is broken | Bug report |
+| A rule you cannot express | Policy request |
+| Tirith will not slot into your CI | Integration help |
+| A first pipeline to set up | Help me govern my first IaC pipeline |
+| A design change to propose | Proposal / RFC |
+
+Whatever you open: **no secrets, no plan files, no private source code.**
+Redact, or reduce it to a minimal public example.
+
+## What you can expect
+
+Maintainers are volunteers and StackGuardian engineers, not a support rota.
+Issues are usually acknowledged within a few working days. An issue with a
+reproduction gets looked at sooner than one without, which is not a rule so
+much as an inevitability.
+
+## Community versus commercial support
+
+Everything in this repository — the CLI, the providers, the policy schema, the
+GitHub Action's local mode, the example policies — is Apache-2.0 and supported
+here, by the maintainers, for free.
+
+[StackGuardian](https://www.stackguardian.io/) sells a platform that Tirith can
+optionally talk to (`tirith platform check`), and sells commercial support for
+it. That is a separate relationship with a separate contract. You never need it
+to use Tirith: local mode needs no account, and questions about local mode
+belong here, in issues, regardless of whether you are a StackGuardian customer.
+
+If your question is specifically about a StackGuardian organisation, its
+policies, or a platform-mode run, StackGuardian's own support is the faster
+route.
+
+## Security
+
+Not here — see [SECURITY.md](SECURITY.md), which is a private route.
diff --git a/documentation/docs/getting-started-with-tirith.md b/documentation/docs/getting-started-with-tirith.md
index f57a5de9..ea5c59c7 100644
--- a/documentation/docs/getting-started-with-tirith.md
+++ b/documentation/docs/getting-started-with-tirith.md
@@ -21,9 +21,8 @@ experiment in a playground with worked examples. Install it with
[the interactive interface](tirith-usage/interactive-interface.md).
It is new, so the rough edges are still being found. Tell us what is confusing, what is missing, or
-what you would rather it did: [open an issue](https://github.com/StackGuardian/tirith/issues) or say
-so in
-[Slack](https://join.slack.com/t/stackguardian-ol78820/shared_invite/zt-2ksag36j9-OjmXqQmyXudgYrV6FmesIQ).
+what you would rather it did:
+[open an issue](https://github.com/StackGuardian/tirith/issues/new/choose).
Nothing about the existing CLI changes.
:::
diff --git a/documentation/docs/tirith-usage/ci-integration.md b/documentation/docs/tirith-usage/ci-integration.md
index 68cab83c..b57a36dd 100644
--- a/documentation/docs/tirith-usage/ci-integration.md
+++ b/documentation/docs/tirith-usage/ci-integration.md
@@ -107,13 +107,13 @@ policy:
image: python:3.12
needs: [plan]
script:
- - pip install "git+https://github.com/StackGuardian/tirith.git@1.0.5"
+ - pip install "git+https://github.com/StackGuardian/tirith.git@1.2.0"
- tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error
```
Tirith is **not on PyPI** — `pip install tirith` installs an unrelated project of the same name.
Install from git, and pin a tag rather than tracking the default branch so a CI job cannot change
-behaviour underneath you. `1.0.5` is the newest tag;
+behaviour underneath you. `1.2.0` is the newest tag;
`git ls-remote --tags https://github.com/StackGuardian/tirith.git` lists them. Python 3.8 or newer.
To evaluate your organization's policies instead of the committed files, swap the last line for
@@ -126,7 +126,7 @@ policy:
variables:
SG_ORG: my-org # SG_API_TOKEN comes from a masked CI/CD variable
script:
- - pip install "git+https://github.com/StackGuardian/tirith.git@1.0.5"
+ - pip install "git+https://github.com/StackGuardian/tirith.git@1.2.0"
- tirith platform check --workflow-id my-repo --input-path plan.json --fail-on-error
```
@@ -138,7 +138,7 @@ Nothing above is GitLab-specific: any runner that can execute a container and pr
the same way. The recipe is always the same three steps —
1. produce the input document (`terraform show -json tfplan > plan.json`);
-2. `pip install "git+https://github.com/StackGuardian/tirith.git@1.0.5"`;
+2. `pip install "git+https://github.com/StackGuardian/tirith.git@1.2.0"`;
3. `tirith -policy-path -input-path plan.json --fail-on-error`
— and gate the job on the exit code, which every CI system does by default for a non-zero exit.
diff --git a/documentation/docs/tirith-usage/interactive-interface.md b/documentation/docs/tirith-usage/interactive-interface.md
index df9425b7..af0cd04b 100644
--- a/documentation/docs/tirith-usage/interactive-interface.md
+++ b/documentation/docs/tirith-usage/interactive-interface.md
@@ -17,8 +17,7 @@ slug: interactive-interface/
interface is still open, and the rough edges are still being found.
Tell us what is confusing, what is missing, or what you would rather it did:
-[open an issue](https://github.com/StackGuardian/tirith/issues) or say so in
-[Slack](https://join.slack.com/t/stackguardian-ol78820/shared_invite/zt-2ksag36j9-OjmXqQmyXudgYrV6FmesIQ).
+[open an issue](https://github.com/StackGuardian/tirith/issues/new/choose).
Nothing about the existing CLI changes: same flags, same `--json` output, same exit codes.
:::
diff --git a/documentation/docusaurus.config.js b/documentation/docusaurus.config.js
index 161a0d67..20a1556e 100644
--- a/documentation/docusaurus.config.js
+++ b/documentation/docusaurus.config.js
@@ -1,8 +1,33 @@
import {themes as prismThemes} from 'prism-react-renderer';
+/*
+ * PostHog is configured entirely from the environment, and nothing is
+ * committed. An unset key means the client module never loads the script and
+ * src/lib/analytics.js no-ops -- which is the correct behaviour for a local
+ * `docusaurus start`, a fork's build and a contributor's checkout.
+ */
+const posthogKey = process.env.POSTHOG_KEY || '';
+const posthogHost = process.env.POSTHOG_HOST || 'https://eu.i.posthog.com';
+
+/*
+ * The Fleet enquiry form posts straight to HubSpot from the browser, which is
+ * what that endpoint is for -- a static site needs no backend. Both values are
+ * environment-supplied: unset, the form disables itself and says why, rather
+ * than posting into the void.
+ */
+const hubspotPortalId = process.env.HUBSPOT_PORTAL_ID || '';
+const hubspotFormGuid = process.env.HUBSPOT_FORM_GUID || '';
+
+const repo = 'https://github.com/StackGuardian/tirith';
+
/** @type {import('@docusaurus/types').Config} */
const config = {
- title: 'Tirith',
+ // Named `Tirith IaC Governance` rather than `Tirith` so that search results
+ // and social cards distinguish this project from the unrelated Tirith
+ // terminal-security tool and the unrelated `tirith` package on PyPI. The
+ // navbar still reads `Tirith`, which is what the project is called.
+ title: 'Tirith IaC Governance',
+ tagline: 'Put governance in front of every Terraform or OpenTofu plan.',
favicon: 'img/tirith.png',
// Set the production url of your site here
url: 'https://stackguardian.github.io',
@@ -22,6 +47,15 @@ const config = {
locales: ['en'],
},
+ customFields: {
+ posthogKey,
+ posthogHost,
+ hubspotPortalId,
+ hubspotFormGuid,
+ },
+
+ clientModules: ['./src/clientModules/posthog.js'],
+
presets: [
[
'classic',
@@ -40,17 +74,21 @@ const config = {
themeConfig:
/** @type {import('@docusaurus/preset-classic').ThemeConfig} */
({
- // Replace with your project's social card
- image: 'img/docusaurus-social-card.jpg',
+ image: 'img/tirith.png',
navbar: {
title: 'Tirith',
- hideOnScroll: true,
+ hideOnScroll: true,
// No href: the logo and title link to the site home, which is what a
- // reader clicking a site's own logo expects. It used to open the policy
- // builder in a new tab, which left no way back to the docs home.
+ // reader clicking a site's own logo expects.
+ //
+ // The mark is Tirith's own, not StackGuardian's. This is an Apache-2.0
+ // project that works with no account and no vendor relationship, and
+ // flying the sponsor's logo as the page logo argues the opposite
+ // before a word is read. StackGuardian is credited in the footer, and
+ // its blue survives as the single accent colour.
logo: {
- alt: 'StackGuardian logo',
- src: 'img/sg-icon.png',
+ alt: 'Tirith',
+ src: 'img/tirith.png',
},
items: [
{
@@ -59,17 +97,75 @@ const config = {
position: 'left',
label: 'Docs',
},
+ {to: '/learn', label: 'Learn', position: 'left'},
+ {to: '/playground', label: 'Playground', position: 'left'},
+ {to: '/policies', label: 'Policies', position: 'left'},
+ {to: '/ai', label: 'AI', position: 'left'},
+ {to: '/traction', label: 'Traction', position: 'left'},
+ // Last in the left group, after Docs: the commercial route is
+ // reachable but sits at the end of the OSS surfaces rather than
+ // beside the primary action. `Star on GitHub` stays alone on the
+ // right, so nothing competes with it.
+ {to: '/fleet', label: 'Fleet governance', position: 'left'},
{
- href: 'https://tirith-policy-builder.vercel.app/',
- label: 'Policy Builder',
+ href: repo,
+ label: 'Star on GitHub',
position: 'right',
},
+ ],
+ },
+ footer: {
+ style: 'light',
+ links: [
{
- href: 'https://github.com/StackGuardian/tirith',
- label: 'GitHub',
- position: 'right',
+ title: 'Use it',
+ items: [
+ {label: 'Documentation', to: '/docs/getting-started-with-tirith/'},
+ {label: 'CI integration', to: '/docs/tirith-usage/ci-integration/'},
+ {label: 'Exit codes', to: '/docs/tirith-usage/exit-codes/'},
+ {label: 'Platform mode (optional)', to: '/docs/tirith-usage/platform-check/'},
+ ],
+ },
+ {
+ title: 'Explore',
+ items: [
+ {label: 'Learn', to: '/learn'},
+ {label: 'Playground', to: '/playground'},
+ {label: 'Policies', to: '/policies'},
+ {label: 'AI and MCP', to: '/ai'},
+ {label: 'Traction', to: '/traction'},
+ {label: 'Fleet governance', to: '/fleet'},
+ ],
+ },
+ {
+ title: 'Policies',
+ items: [
+ {label: 'Policy reference', to: '/docs/tirith-policies/tirith-policy-reference/'},
+ {label: 'Worked examples', to: '/docs/tirith-policies/tirith-policy-examples/'},
+ {label: 'Providers', to: '/docs/tirith-providers/providers-overview/'},
+ // The builder is now a tab inside the Playground rather than a
+ // separate destination, so this points there instead of leaving
+ // the site. People search the footer for the word "builder".
+ {label: 'Policy builder', to: '/playground'},
+ ],
+ },
+ {
+ title: 'Project',
+ items: [
+ {label: 'GitHub', href: repo},
+ {label: 'Issues', href: `${repo}/issues`},
+ {label: 'Contributing', href: `${repo}/blob/main/CONTRIBUTING.md`},
+ {label: 'Governance', href: `${repo}/blob/main/GOVERNANCE.md`},
+ {label: 'Maintainers', href: `${repo}/blob/main/MAINTAINERS.md`},
+ {label: 'Security', href: `${repo}/blob/main/SECURITY.md`},
+ {label: 'Roadmap', href: `${repo}/blob/main/ROADMAP.md`},
+ {label: 'License', href: `${repo}/blob/main/LICENSE`},
+ ],
},
],
+ copyright:
+ 'Tirith is Apache-2.0, maintained by community contributors with engineering support ' +
+ 'from StackGuardian.',
},
prism: {
theme: prismThemes.github,
@@ -79,7 +175,3 @@ const config = {
};
export default config;
-
-
-
-
diff --git a/documentation/scripts/find-dashes.py b/documentation/scripts/find-dashes.py
new file mode 100644
index 00000000..223bf1d7
--- /dev/null
+++ b/documentation/scripts/find-dashes.py
@@ -0,0 +1,105 @@
+#!/usr/bin/env python3
+"""
+Find em dashes in anything that reaches a reader.
+
+Written because two hand audits both missed real occurrences. The first looked only at
+src/pages/*.js and missed the shared components; the second missed that a single dash inside a
+component is multiplied by every item it renders -- two in the verdict renderer became 56 on the
+policies page. Counting sources is not the same as counting what ships.
+
+So this checks both: the authored files, and the built HTML. Run it after a build.
+
+ python3 documentation/scripts/find-dashes.py # source + build
+ python3 documentation/scripts/find-dashes.py --built # only what ships
+
+Exits non-zero when the built site contains any, which makes it usable as a CI gate.
+"""
+
+import os
+import re
+import sys
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+DOCS = os.path.dirname(HERE)
+
+# En dashes are deliberately not flagged. In `1-10` or `lessons 1-3` an en dash is correct
+# typography for a numeric range, not a stylistic tic, and replacing it with a hyphen would be
+# a downgrade.
+EM = "—"
+
+SOURCE_DIRS = [("src", (".js", ".json", ".css")), ("docs", (".md",))]
+SOURCE_FILES = ["docusaurus.config.js"]
+
+
+def scan_source():
+ rows = []
+ for rel, exts in SOURCE_DIRS:
+ root = os.path.join(DOCS, rel)
+ for dirpath, _, names in os.walk(root):
+ for name in sorted(names):
+ if not name.endswith(exts):
+ continue
+ path = os.path.join(dirpath, name)
+ with open(path, encoding="utf-8") as handle:
+ for number, line in enumerate(handle, 1):
+ if EM in line:
+ rows.append((os.path.relpath(path, DOCS), number, line.strip()))
+ for name in SOURCE_FILES:
+ path = os.path.join(DOCS, name)
+ if not os.path.exists(path):
+ continue
+ with open(path, encoding="utf-8") as handle:
+ for number, line in enumerate(handle, 1):
+ if EM in line:
+ rows.append((name, number, line.strip()))
+ return rows
+
+
+def scan_built():
+ """
+ What a visitor receives. Counts per page, because one dash in a shared component shows up
+ once per rendered item and that multiplication is the thing worth seeing.
+ """
+ build = os.path.join(DOCS, "build")
+ if not os.path.isdir(build):
+ return None
+ counts = {}
+ for dirpath, _, names in os.walk(build):
+ for name in names:
+ if name != "index.html":
+ continue
+ path = os.path.join(dirpath, name)
+ with open(path, encoding="utf-8") as handle:
+ found = handle.read().count(EM)
+ if found:
+ counts[os.path.relpath(path, build)] = found
+ return counts
+
+
+def main():
+ only_built = "--built" in sys.argv
+
+ if not only_built:
+ rows = scan_source()
+ by_file = {}
+ for path, number, line in rows:
+ by_file.setdefault(path, []).append((number, line))
+ print(f"SOURCE: {len(rows)} em dash(es) in {len(by_file)} file(s)")
+ for path in sorted(by_file):
+ print(f"\n {path} ({len(by_file[path])})")
+ for number, line in by_file[path][:40]:
+ print(f" {number:>5} {line[:110]}")
+
+ counts = scan_built()
+ if counts is None:
+ print("\nBUILT: no build/ directory; run `npm run build` first.")
+ return 0
+ total = sum(counts.values())
+ print(f"\nBUILT: {total} em dash(es) reaching readers, across {len(counts)} page(s)")
+ for path in sorted(counts, key=lambda p: -counts[p]):
+ print(f" {counts[path]:>5} /{os.path.dirname(path) or ''}")
+ return 1 if total else 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/documentation/scripts/generate-fixtures.py b/documentation/scripts/generate-fixtures.py
new file mode 100644
index 00000000..a5622890
--- /dev/null
+++ b/documentation/scripts/generate-fixtures.py
@@ -0,0 +1,190 @@
+#!/usr/bin/env python3
+"""
+Generate the fixture data the Learn and Playground pages render.
+
+Both pages show real Tirith verdicts rather than hand-written ones. This script
+runs the actual engine over the worked examples that ship with `tirith ui`
+(`src/tirith/tui/examples/`) and writes the results to
+`documentation/src/data/fixtures.json`, which the site imports at build time.
+
+Doing it here rather than in the browser is a deliberate trade. Tirith is
+Python; evaluating in the browser means shipping a Python runtime to every
+visitor. Precomputing keeps the pages fast and keeps the promise that nothing
+a visitor brings is uploaded -- there is no evaluation endpoint to upload to.
+The cost is that an edited policy cannot be re-evaluated on the page, and the
+Playground says so plainly rather than pretending otherwise.
+
+Regenerate after changing any example, or after a change to the engine that
+alters result shape:
+
+ python3 documentation/scripts/generate-fixtures.py
+
+The output is committed, so a docs build never needs Python.
+"""
+
+import json
+import os
+import sys
+
+REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+sys.path.insert(0, os.path.join(REPO_ROOT, "src"))
+
+from tirith.core.core import start_policy_evaluation_from_dict # noqa: E402
+from tirith.tui.examples import load_examples # noqa: E402
+
+OUT_PATH = os.path.join(REPO_ROOT, "documentation", "src", "data", "fixtures.json")
+
+
+def exit_code_for(result):
+ """
+ Mirror the CLI's --fail-on-error contract, from docs/tirith-usage/exit-codes.md.
+
+ Kept as a small local function rather than imported because the CLI decides
+ this while also handling flags and IO; what the page needs is only the
+ mapping from a tri-state final_result to the code a pipeline would see.
+ """
+ if "final_result" not in result:
+ return 1, "The policy could not be evaluated at all."
+ final = result.get("final_result")
+ if final is True:
+ return 0, "Every check that ran passed."
+ if final is False:
+ return 3, "A check ran and failed. With --fail-on-error this stops the job."
+ return 1, "Every check was skipped, so the policy evaluated nothing. This is not a pass."
+
+
+def main():
+ payload = {
+ "_comment": (
+ "Generated by documentation/scripts/generate-fixtures.py. Do not edit by hand. "
+ "Every result is the real output of the Tirith engine."
+ ),
+ "examples": _tui_examples(),
+ "pack": _starter_pack(),
+ }
+
+ with open(OUT_PATH, "w") as handle:
+ json.dump(payload, handle, indent=2, sort_keys=False)
+ handle.write("\n")
+
+ print(f"wrote {OUT_PATH}")
+ print(f" examples (bundled with `tirith ui`): {len(payload['examples'])}")
+ for entry in payload["examples"]:
+ print(f" {entry['key']:<26} exit={entry['exitCode']}")
+ print(f" pack (examples/ starter policies): {len(payload['pack'])}")
+ for entry in payload["pack"]:
+ clean = entry["clean"]["exitCode"] if entry.get("clean") else "-"
+ print(f" {entry['key']:<34} fails={entry['exitCode']} clean={clean}")
+
+
+def _tui_examples():
+ """
+ The worked examples bundled with `tirith ui`. Editable in the Playground, and the source the
+ Learn lesson is written against.
+ """
+ examples = load_examples()
+ if not examples:
+ raise SystemExit("no examples found -- has src/tirith/tui/examples/ moved?")
+
+ out = []
+ for example in examples:
+ result = start_policy_evaluation_from_dict(example.policy, example.input_document)
+ code, meaning = exit_code_for(result)
+ out.append(
+ {
+ "key": example.key,
+ "title": example.title,
+ "summary": example.summary,
+ "about": example.about,
+ "policy": example.policy,
+ "input": example.input_document,
+ "result": result,
+ "exitCode": code,
+ "exitMeaning": meaning,
+ }
+ )
+ return out
+
+
+def _starter_pack():
+ """
+ The starter policy pack in examples/ -- policies meant to be copied into a real repository.
+
+ Each one is evaluated twice, which is the whole point of the pack: against the fixture built
+ to trip it, and against a clean plan. A policy only ever seen failing might be firing on
+ everything; one only ever seen passing might be matching nothing. Both runs are recorded so
+ the site can show either, and so a false positive on the clean plan cannot pass unnoticed.
+ """
+ policies_dir = os.path.join(REPO_ROOT, "examples", "policies")
+ inputs_dir = os.path.join(REPO_ROOT, "examples", "inputs")
+ if not os.path.isdir(policies_dir):
+ print(f"note: {policies_dir} is absent; the starter pack will be empty")
+ return []
+
+ clean_path = os.path.join(inputs_dir, "00-clean-plan.passes.json")
+ clean_plan = None
+ if os.path.exists(clean_path):
+ with open(clean_path) as handle:
+ clean_plan = json.load(handle)
+
+ out = []
+ for name in sorted(os.listdir(policies_dir)):
+ if not name.endswith(".json"):
+ continue
+ key = name[: -len(".json")]
+ with open(os.path.join(policies_dir, name)) as handle:
+ policy = json.load(handle)
+
+ fails_path = os.path.join(inputs_dir, f"{key}.fails.json")
+ if not os.path.exists(fails_path):
+ print(f"note: {key} has no .fails.json fixture; skipping")
+ continue
+ with open(fails_path) as handle:
+ document = json.load(handle)
+
+ meta = policy.get("meta", {})
+ provider = meta.get("required_provider", "")
+ evaluators = policy.get("evaluators", []) or []
+ operations = sorted(
+ {
+ (e.get("provider_args") or {}).get("operation_type")
+ for e in evaluators
+ if (e.get("provider_args") or {}).get("operation_type")
+ }
+ )
+
+ result = start_policy_evaluation_from_dict(policy, document)
+ code, meaning = exit_code_for(result)
+
+ entry = {
+ "key": key,
+ "title": meta.get("name") or key,
+ "severity": meta.get("severity"),
+ "provider": provider,
+ "operations": operations,
+ "evaluatorCount": len(evaluators),
+ "policy": policy,
+ "input": document,
+ "result": result,
+ "exitCode": code,
+ "exitMeaning": meaning,
+ }
+
+ # Only plan-reading policies can be run against the shared clean plan; a cost or JSON
+ # policy would be evaluated against the wrong document shape and report a meaningless
+ # verdict, which is worse than reporting nothing.
+ if clean_plan is not None and provider.endswith("terraform_plan"):
+ clean_result = start_policy_evaluation_from_dict(policy, clean_plan)
+ clean_code, clean_meaning = exit_code_for(clean_result)
+ entry["clean"] = {
+ "exitCode": clean_code,
+ "outcome": "passed" if clean_code == 0 else "not clean",
+ "meaning": clean_meaning,
+ }
+
+ out.append(entry)
+ return out
+
+
+if __name__ == "__main__":
+ main()
diff --git a/documentation/src/analytics.js b/documentation/src/analytics.js
new file mode 100644
index 00000000..b32865c3
--- /dev/null
+++ b/documentation/src/analytics.js
@@ -0,0 +1,108 @@
+import {useEffect} from 'react';
+
+/**
+ * The one place the landing page talks to analytics.
+ *
+ * Every call goes through capture() rather than touching window.posthog
+ * directly, so that:
+ *
+ * - a page with no key configured is silent rather than broken. PostHog is
+ * only loaded when POSTHOG_KEY is set at build time (see
+ * src/clientModules/posthog.js), which it is not for a local `docusaurus
+ * start`, a fork's build, or anyone running the docs offline;
+ * - the event vocabulary is auditable from a single file. The properties
+ * below are deliberately coarse -- a CI system name, a mode, a stage.
+ * Nothing here may carry a repository name, a plan, policy text, source
+ * code or a token, because this is a public page and none of that is ours
+ * to collect.
+ *
+ * Tirith's own local mode is unrelated to any of this and stays networkless.
+ */
+
+export const EVENTS = {
+ // Landing
+ heroStar: 'hero_star_click',
+ quickstart: 'quickstart_click',
+ installCopy: 'install_copy',
+ demoPr: 'demo_pr_open',
+ policyExample: 'policy_example_open',
+ firstPlan: 'first_plan_self_report',
+ helpIssue: 'help_issue_open',
+ platformInterest: 'platform_interest',
+
+ // Learn
+ learnStart: 'learn_start',
+ lessonStart: 'lesson_start',
+ lessonComplete: 'lesson_complete',
+ courseComplete: 'course_complete',
+ learnToPlayground: 'learn_to_playground',
+ learnToQuickstart: 'learn_to_quickstart',
+
+ // Playground
+ playgroundOpen: 'playground_open',
+ fixtureSelect: 'fixture_select',
+ templateSelect: 'template_select',
+ evaluationRun: 'evaluation_run',
+ evaluationOutcome: 'evaluation_outcome',
+ policyExport: 'policy_export',
+ snippetCopy: 'snippet_copy',
+ playgroundToRepo: 'playground_to_repo',
+ playgroundToFleet: 'playground_to_fleet',
+ // Not in the brief's list: the builder became a tab on this page, and a tab
+ // nobody opens is worth knowing about.
+ builderOpen: 'builder_open',
+
+ // Fleet
+ fleetView: 'fleet_view',
+ offerCta: 'offer_cta_click',
+ fleetCapabilityExpand: 'fleet_capability_expand',
+ fleetFormStart: 'fleet_form_start',
+ fleetFormSubmit: 'fleet_form_submit',
+ fleetToOss: 'fleet_to_oss',
+
+ // AI
+ aiView: 'ai_view',
+ mcpInstall: 'mcp_install_click',
+ skillCopy: 'skill_file_copy',
+ sgMcpInterest: 'sg_mcp_interest',
+
+ // Traction
+ tractionView: 'traction_view',
+ metricSourceOpen: 'metric_source_open',
+ starFromTraction: 'star_from_traction',
+ adopterIssueOpen: 'adopter_issue_open',
+ contributionCta: 'contribution_cta_click',
+};
+
+export function capture(event, properties = {}) {
+ if (typeof window === 'undefined') return;
+ const posthog = window.posthog;
+ if (!posthog || typeof posthog.capture !== 'function') return;
+ try {
+ posthog.capture(event, properties);
+ } catch {
+ // Analytics must never take the page down with it.
+ }
+}
+
+/**
+ * Returns an onClick handler. Written as a factory because nearly every call
+ * site is a link whose only job is to fire one event and then behave like a
+ * link -- no preventDefault, no navigation of its own.
+ */
+export function track(event, properties) {
+ return () => capture(event, properties);
+}
+
+/**
+ * Fire once when a page mounts. Written as a hook so a page can say what it is
+ * in one line, and so the effect's empty dependency list lives in exactly one
+ * place rather than being re-derived (and occasionally got wrong) per page.
+ */
+export function usePageView(event, properties) {
+ useEffect(() => {
+ capture(event, properties);
+ // Mount only: a page view is not re-fired when props change.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+}
diff --git a/documentation/src/clientModules/posthog.js b/documentation/src/clientModules/posthog.js
new file mode 100644
index 00000000..a8d66104
--- /dev/null
+++ b/documentation/src/clientModules/posthog.js
@@ -0,0 +1,34 @@
+/**
+ * Loads PostHog, and only if a key was supplied at build time.
+ *
+ * The key is read from siteConfig.customFields, which docusaurus.config.js
+ * fills from the POSTHOG_KEY environment variable. Nothing is committed: an
+ * unconfigured build -- a local `docusaurus start`, a fork, a contributor's
+ * checkout -- ships no tracking script at all, and src/lib/analytics.js
+ * degrades to a no-op on its own.
+ */
+
+import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment';
+import siteConfig from '@generated/docusaurus.config';
+
+if (ExecutionEnvironment.canUseDOM) {
+ const {posthogKey, posthogHost} = siteConfig.customFields || {};
+
+ if (posthogKey) {
+ // The official snippet, transcribed rather than depended on, so the docs
+ // build does not grow a package for eight events.
+ /* eslint-disable */
+ !function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.async=!0,p.src=s.api_host+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e},u.people.toString=function(){return u.toString(1)+".people (stub)"},o="capture identify alias people.set people.set_once set_config register register_once unregister opt_out_capturing has_opted_out_capturing opt_in_capturing reset isFeatureEnabled onFeatureFlags getFeatureFlag getFeatureFlagPayload reloadFeatureFlags group updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures getActiveMatchingSurveys getSurveys onSessionId".split(" "),n=0;n
+ TODO
+ {children}
+
+ );
+}
+
+/**
+ * A reserved space for an asset that does not exist yet.
+ *
+ * Renders the well at the aspect ratio the real thing will occupy, so the page
+ * does not reflow when a GIF or screenshot lands, and so the amount of missing
+ * material is obvious to anyone reviewing the page.
+ *
+ * `compact` is the in-card variant used by the demo pull-request cards, where
+ * a full-width 16:9 frame would dwarf the copy beside it.
+ */
+export function VisualSlot({children, compact, label = 'ASSET'}) {
+ return (
+
+
+ {label}
+
+ {children}
+
+ );
+}
+
+/** Several asset wells shown as a set. */
+export function AssetGrid({children}) {
+ return
{children}
;
+}
+
+// Uses Docusaurus's own button classes rather than hand-rolled ones: they carry
+// a readable foreground in both light and dark mode. The primary button
+// additionally takes a CSS-module class that recolours it to the accent blue by
+// overriding the --ifm-button-* custom properties (see site.module.css).
+export function Action({label, to, href, primary, onClick}) {
+ const className = primary
+ ? `button button--lg button--primary ${styles.heroPrimary}`
+ : 'button button--lg button--secondary';
+ return to ? (
+
+ {label}
+
+ ) : (
+
+ {label}
+
+ );
+}
+
+/*
+ * The id lands on the heading rather than on the . Docusaurus's
+ * broken-anchor check only registers heading anchors, and the navbar's
+ * cross-page links have to resolve against something it knows about -- an id
+ * on the wrapper builds clean but is reported broken on every page.
+ */
+export function Section({id, heading, kicker, children, tone}) {
+ return (
+
+ {kicker ?
+ );
+}
+
+/**
+ * A full-width invitation to a companion page.
+ *
+ * `subdued` is for the commercial route. The brief requires the commercial CTA
+ * never to outrank `Use Tirith OSS`, so that variant loses its fill and its
+ * button and becomes a plain link.
+ */
+export function Doorway({title, body, cta, subdued, onClick}) {
+ return (
+
+ );
+}
+
+/*
+ * CodeBlock owns its copy button and exposes no callback, so the click is
+ * caught on the way up instead. Coarse by design: any button inside a code
+ * block is the copy button.
+ */
+export function TrackedCode({ciSystem, mode = 'local', ...props}) {
+ const onClick = (event) => {
+ if (event.target.closest('button')) {
+ capture(EVENTS.installCopy, {ci_system: ciSystem, mode});
+ }
+ };
+ return (
+
+
+
+ );
+}
+
+/* ------------------------------------------------------------ verdict --- */
+
+/**
+ * The four outcomes Tirith can return, and what each one means.
+ *
+ * `unevaluated` is the one that matters most and the one a scanner UI usually
+ * gets wrong. A policy whose every check was skipped evaluated nothing, and
+ * showing that as green is exactly the failure the exit-code contract exists
+ * to prevent -- so it gets its own badge, its own glyph and its own wording,
+ * never a quiet pass.
+ */
+export function outcomeOf(result) {
+ if (!result || !('final_result' in result)) {
+ return {
+ key: 'error',
+ label: 'Tool error',
+ glyph: '!',
+ className: styles.badgeUnknown,
+ sr: 'Tool error: the evaluation did not complete',
+ note: 'The evaluation did not complete. Your policy did not fail; fix the execution error and run it again.',
+ };
+ }
+ if (result.final_result === true) {
+ return {
+ key: 'passed',
+ label: 'Passed',
+ glyph: '✓',
+ className: styles.badgePass,
+ sr: 'Passed: every check that ran passed',
+ note: 'This plan satisfies the policy. Inspect the resources evaluated before exporting the rule.',
+ };
+ }
+ if (result.final_result === false) {
+ return {
+ key: 'failed',
+ label: 'Failed',
+ glyph: '✕',
+ className: styles.badgeFail,
+ sr: 'Failed: a check ran and failed',
+ note: 'This change would be blocked.',
+ };
+ }
+ return {
+ key: 'unevaluated',
+ label: 'Unevaluated',
+ glyph: '?',
+ className: styles.badgeUnknown,
+ sr: 'Unevaluated: no policy answer was reached, which is not a pass',
+ note: 'Tirith could not reach a policy answer. This is not a pass. Review the provider input, match count and policy diagnostics.',
+ };
+}
+
+function Evidence({item}) {
+ const passed = item.passed === true;
+ const address = item.meta?.address;
+ const actions = item.meta?.change?.actions;
+ return (
+
+ );
+}
+
+/**
+ * Renders one real engine result.
+ *
+ * Every field read here comes out of
+ * documentation/scripts/generate-fixtures.py, which runs the actual engine --
+ * so this follows the engine's shape rather than an idealised version of it,
+ * including the fact that a skipped check carries a message but no resource
+ * metadata.
+ */
+export function Verdict({example, showExit = true}) {
+ const {result, exitCode, exitMeaning} = example;
+ const outcome = outcomeOf(result);
+ const failing = (result.evaluators || []).reduce(
+ (total, evaluator) => total + (evaluator.result || []).filter((r) => r.passed === false).length,
+ 0,
+ );
+
+ return (
+
+ Ask an agent for “a policy requiring an Owner tag on every resource” and you will
+ usually get something like {'"condition": {"type": "Exists"}'}. It reads
+ correctly. It is not a condition type Tirith has.
+
+
+ The expensive part is what happens next. An unknown condition type does not raise an
+ error. The engine returns it as an ordinary failed check with no error attached, so it
+ is indistinguishable from a genuine violation. The build goes red, and somebody spends
+ an afternoon looking at infrastructure that was fine all along.
+
+
+ The fix is not a better prompt. It is giving the agent the closed list, and making it
+ run the policy before it claims the policy works.
+
+
+
+
+
+ tirith mcp speaks the Model Context Protocol over stdio. Your editor starts
+ it; it makes no network calls and writes nothing to disk, so pointing an agent at it
+ cannot change anyone’s infrastructure.
+
+
+
+ {TOOLS.map(([name, what, why]) => (
+
+
+ {name}
+
+
{what}
+
+ {why}
+
+
+ ))}
+
+
+
+
+
+ The server ships as an optional extra, on the same terms as the interactive interface:
+ a CI gate should not pay install time for a server it never starts.
+
+ The skill file is picked up automatically from{' '}
+ .claude/skills/ when you work in a repository that has it. See{' '}
+ skill files below.
+
+
+
+
+
+
+
+
+ One click, because the link carries the whole configuration. Cursor also reads{' '}
+ .cursor/rules/tirith-policies.mdc automatically when a policy file is
+ open.
+
+
+
+
+
+
+
+
+ Opens VS Code’s MCP install prompt with the server pre-filled. On Insiders, swap the
+ scheme for vscode-insiders:.
+
+ The MCP server is the better experience because the agent can evaluate a
+ policy. But most of the value is knowing the vocabulary, and that is just a file: no
+ install, no extra, no Python version floor. All three live in this repository and can be
+ copied into yours.
+
+ An agent with the Tirith server can write and prove a policy in the repository in front
+ of it. What it cannot do is tell you which of your two hundred repositories have no gate
+ at all, which teams pinned four different provider versions, or which pipeline applies
+ without a reviewed plan, because none of that is in the repository it is looking at.
+
+
+ The StackGuardian MCP server puts that estate-wide view in the same conversation: ask
+ which repositories are ungoverned, ask what a finding means, and open the installation
+ pull requests from where you already are.
+
+
+ {SG_MCP_PACKAGE ? (
+
+ {`claude mcp add stackguardian -- npx -y ${SG_MCP_PACKAGE}`}
+
+ ) : (
+
+ The StackGuardian MCP server is a local package over stdio, authenticated with the same{' '}
+ SG_API_TOKEN and SG_ORG that platform mode already uses, but
+ the package name has not been supplied, so no install command is shown and no
+ one-click link is generated. Set SG_MCP_PACKAGE in this file and the
+ command, the Cursor deeplink and the VS Code deeplink all follow from it. Until then
+ this section describes something a reader cannot install.
+
+ )}
+
+
+
+
+
+
+
+
+
+ Worth being explicit, because “AI” on a governance page usually means something vaguer
+ than this.
+
+
+
+ Nothing here remediates your infrastructure. The tools read documents
+ and return verdicts. An agent may propose a code change; a human reviews and merges it,
+ as before.
+
+
+ A drafted policy is a draft. Generated JSON is worth no more than the
+ evaluation that follows it, which is why evaluate exists and why the
+ skill file says never to hand back a policy you have not run against a document that
+ should fail it.
+
+
+ The engine is the arbiter, not the model. Every verdict on this site,
+ and every verdict these tools return, comes from the same evaluator your pipeline runs.
+
+
+ Nothing leaves your machine. The Tirith server makes no network call.
+ Your agent may be a hosted model, which is between you and your agent, but the
+ evaluation is local.
+
+
+
+
+
+
+ The quickest way to see whether this is worth installing: open the Playground, take a
+ policy that catches a violation, and ask your agent to explain the verdict. If it can do
+ that from the result document alone, you do not need the server. If it cannot, that is
+ what explain_result is for.
+
+
+
+
+
+
+
+
+ Report an issue with the server
+
+
+
+ Working on Tirith itself
+
+
+
+
+ );
+}
diff --git a/documentation/src/pages/fleet.js b/documentation/src/pages/fleet.js
new file mode 100644
index 00000000..d4f9f4cc
--- /dev/null
+++ b/documentation/src/pages/fleet.js
@@ -0,0 +1,450 @@
+import {useState} from 'react';
+import Link from '@docusaurus/Link';
+import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
+
+import {EVENTS, capture, track, usePageView} from '../analytics';
+import {
+ Action,
+ AssetGrid,
+ DataTable,
+ Hero,
+ NEW_ISSUE,
+ PageShell,
+ issueUrl,
+ REPO,
+ Section,
+ Todo,
+ VisualSlot,
+ styles,
+} from '../components/site';
+
+/*
+ * ---------------------------------------------------------------------------
+ * FLEET
+ *
+ * Job: explain the commercial progression when a team needs to discover,
+ * standardise, approve and evidence Tirith governance across many IaC
+ * repositories -- without making StackGuardian a condition of OSS use.
+ *
+ * Two rules shape everything below, and both come from the brief:
+ *
+ * 1. The first viewport must state that Tirith OSS is free, independent and
+ * needs no StackGuardian account. It does, in the hero body and again in
+ * the trust line.
+ *
+ * 2. The commercial CTA never outranks `Use Tirith OSS`. On this page --
+ * the one page where a commercial CTA is legitimately primary -- the OSS
+ * route still appears beside it every time, never buried.
+ *
+ * Capabilities are tagged available or planned. Direct execution initiated
+ * from Tirith by calling the StackGuardian workflow API is planned, not
+ * shipped, and is labelled as such rather than described in the present tense.
+ * ---------------------------------------------------------------------------
+ */
+
+const STAGES = [
+ ['Discover', 'Find Terraform and OpenTofu repositories and pipelines with read access; show coverage confidence and gaps.', 'available'],
+ ['Prioritise', 'Rank which repositories need governance first by verified severity and exposure signals.', 'available'],
+ ['Install', 'Open minimal Tirith pull requests for repository owners to review, and it does not write directly to protected branches.', 'available'],
+ ['Standardise', 'Manage central policy and evaluate it continuously across GitHub Actions, GitLab CI and other pipelines.', 'available'],
+ ['Approve', 'Add policy-aware approvals, credential brokering and private user-owned runners without rewriting the developer workflow.', 'available'],
+ ['Remediate', 'Stage an explainable code change for human approval, with file and line evidence and an audit trail.', 'available'],
+ ['Execute', 'Keep your current apply jobs, or move execution into StackGuardian for revisions, snapshots, recovery and notifications.', 'available'],
+ ['Execute from Tirith', 'Tirith calling the StackGuardian workflow API directly to move from policy decision into controlled execution.', 'planned'],
+];
+
+const COMPARISON = [
+ ['Evaluate a Terraform or OpenTofu plan', 'Included', 'Included'],
+ ['Run with no account and no network', 'Included', 'Not applicable; connection is explicit'],
+ ['Policies and results in each repository', 'Included', 'Included'],
+ ['Discover IaC repositories and open rollout PRs', 'Manual', 'Included'],
+ ['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'],
+ ['Drift, snapshots, recovery and notifications', 'None', 'Where execution or state is connected'],
+ ['Private user-owned runtime', 'Your own CI runner', 'Included'],
+];
+
+const FAQ = [
+ [
+ 'Do I need StackGuardian to use Tirith?',
+ 'No. Local Tirith evaluation is Apache-2.0, runs wherever your pipeline runs, and requires no account and no network connection. That is a governance commitment rather than current behaviour. See GOVERNANCE.md.',
+ ],
+ [
+ 'What changes when I connect?',
+ 'You explicitly supply a StackGuardian organisation and token; there is no other switch. Tirith masks Terraform-sensitive values locally, then can send the masked plan, results, metadata and, unless you disable it with --no-source, the related source.',
+ ],
+ [
+ 'Can StackGuardian replace or control my repositories?',
+ 'Installation and remediation arrive as pull requests for repository owners to approve. It does not write directly to protected branches, and your existing CI and apply jobs can stay exactly as they are.',
+ ],
+ [
+ 'Can the runtime remain ours?',
+ 'Yes. A private runtime fully owned and operated by you is supported; its network and control-plane requirements are covered in the technical follow-up.',
+ ],
+ [
+ 'What stays open source?',
+ 'The policy schema, the providers, the CLI and local action contract, and the example policy library are all usable without any commercial relationship, subject to the published governance commitments.',
+ ],
+];
+
+const REPO_BANDS = ['1–10', '11–50', '51–250', '250+'];
+const CI_SYSTEMS = ['GitHub Actions', 'GitLab CI', 'Azure DevOps', 'Jenkins', 'Other'];
+const PROBLEMS = ['Visibility', 'Policy consistency', 'Approvals', 'Remediation', 'Audit', 'Governed execution'];
+
+/**
+ * The enquiry form.
+ *
+ * Submits to HubSpot's forms API directly from the browser, which is what that
+ * endpoint is designed for, so a static site needs no backend. The portal id
+ * and form guid come from build-time configuration rather than being committed
+ * here -- and when they are absent the form disables itself and says why,
+ * instead of silently posting into the void.
+ *
+ * Analytics never sees free text. Only the enumerated fields -- repository
+ * band and primary problem -- are captured; email, organisation and the
+ * context box are not.
+ */
+function FleetForm() {
+ const {siteConfig} = useDocusaurusContext();
+ const {hubspotPortalId, hubspotFormGuid} = siteConfig.customFields || {};
+ const configured = Boolean(hubspotPortalId && hubspotFormGuid);
+
+ const [state, setState] = useState('idle');
+ const [started, setStarted] = useState(false);
+
+ const onFirstInput = () => {
+ if (!started) {
+ setStarted(true);
+ capture(EVENTS.fleetFormStart);
+ }
+ };
+
+ const onSubmit = async (event) => {
+ event.preventDefault();
+ if (!configured) return;
+
+ const data = new FormData(event.target);
+ const band = data.get('repository_band');
+ const problem = data.get('primary_problem');
+ const ci = data.getAll('ci_systems');
+
+ // Enumerated values only. Never the email, organisation or context box.
+ capture(EVENTS.fleetFormSubmit, {
+ repo_count_band: band,
+ primary_problem: problem,
+ ci_systems: ci.join(','),
+ });
+
+ setState('sending');
+ try {
+ const response = await fetch(
+ `https://api.hsforms.com/submissions/v3/integration/submit/${hubspotPortalId}/${hubspotFormGuid}`,
+ {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({
+ fields: [
+ {objectTypeId: '0-1', name: 'email', value: data.get('email')},
+ {objectTypeId: '0-1', name: 'company', value: data.get('company')},
+ {objectTypeId: '0-1', name: 'repository_band', value: band},
+ {objectTypeId: '0-1', name: 'ci_systems', value: ci.join('; ')},
+ {objectTypeId: '0-1', name: 'primary_problem', value: problem},
+ {objectTypeId: '0-1', name: 'context', value: data.get('context') || ''},
+ ],
+ context: {
+ pageUri: typeof window !== 'undefined' ? window.location.href : '',
+ pageName: 'Tirith Fleet governance',
+ },
+ }),
+ },
+ );
+ setState(response.ok ? 'sent' : 'failed');
+ } catch {
+ setState('failed');
+ }
+ };
+
+ if (state === 'sent') {
+ return (
+
+ Thanks. While we review your setup, govern one repository locally with
+ Tirith. The quick start needs no
+ account and takes about two lines.
+
+ );
+ }
+
+ return (
+ <>
+ {configured ? null : (
+
+ The HubSpot portal ID and form GUID are not configured, so this form is disabled. Set{' '}
+ HUBSPOT_PORTAL_ID and HUBSPOT_FORM_GUID in the docs deploy
+ workflow, and create the matching HubSpot properties:{' '}
+ email, company, repository_band,{' '}
+ ci_systems, primary_problem, context. Until then,
+ the GitHub issue route below is the working path.
+
+ )}
+
+
+ >
+ );
+}
+
+export default function Fleet() {
+ usePageView(EVENTS.fleetView);
+
+ return (
+
+
+
+
+
+ Most teams should stay local. This page is only worth reading if the second row describes
+ you.
+
+
+
+
+
+
+
+
+
+
+
+ The £0 offer is permanent. StackGuardian pricing is Custom on purpose: there
+ are no invented Free/Pro/Enterprise tiers here, and there will not be until commercial
+ packaging is settled.
+
+ One boundary stated plainly: moving execution into existing StackGuardian workflows is
+ available today. Tirith itself calling the workflow API to initiate execution is planned
+ and not shipped, so do not build a rollout plan around it yet.
+
+
+
+
+
+ Four views cover most of what a platform team does here: find the repositories, watch the
+ runs, manage the policy, and read a single plan in detail. They are screenshots of a
+ separate, commercial product, shown as a set and kept visually apart from the
+ open-source screenshots elsewhere on this site, so the boundary stays obvious.
+
+
+
+
+ Fleet overview. Terraform and OpenTofu repositories discovered across the
+ organisation, each with its governance state: gated, gap, or not yet evaluated. Show
+ coverage confidence and the gaps ranked, because the honest version of this screen has
+ unknowns on it.
+
+
+
+ Workflows. The run list: which pipeline, which repository, the policy verdict, who
+ approved and when. Include at least one errored run alongside the passes and failures,
+ so the screenshot shows the three-state result rather than a tidy green column.
+
+
+
+ Central policies. The policy sets an organisation enforces, their severity, and which
+ repositories each one currently applies to. Show one policy in both states: enforced
+ somewhere, not yet rolled out elsewhere.
+
+
+
+ Plan detail. A single evaluated plan: the verdict, severity, the resource and value
+ evidence behind it, and the run snapshot. This is the view that has to match the OSS
+ pull-request comment, because the claim is that it is the same verdict.
+
+
+
+
+ All four are unshot. Capture them from a demo organisation, never a customer's: these
+ screens carry repository names, cloud resource identifiers and run history. Redact
+ anything that survives the demo data, caption each one{' '}
+ Optional platform mode, and get the same approval the proof-strip logos need
+ before publishing.
+
+
+
+
+
+
+
+
+ {FAQ.map(([question, answer]) => (
+
+ {question}
+
{answer}
+
+ ))}
+
+ The complete masking behaviour, including what it does not catch, is in{' '}
+ the platform-check documentation. The
+ commitments are in GOVERNANCE.md.
+
+
+
+
+
+ Whatever it looks like now, we will map the shortest route from the pipelines you already
+ run to consistent governance across all of them, with no execution migration, and your apply
+ jobs stay where they are.
+
+
+
+ Would rather keep it public?{' '}
+
+ Open an issue
+ {' '}
+ instead. For anything that is not commercially sensitive, that gets you the maintainers
+ rather than a sales process.
+
+
+
+ );
+}
diff --git a/documentation/src/pages/index.js b/documentation/src/pages/index.js
index 90b94fbc..19d03239 100644
--- a/documentation/src/pages/index.js
+++ b/documentation/src/pages/index.js
@@ -1,9 +1,23 @@
import Link from '@docusaurus/Link';
-import Layout from '@theme/Layout';
-import Heading from '@theme/Heading';
-import CodeBlock from '@theme/CodeBlock';
+import Tabs from '@theme/Tabs';
+import TabItem from '@theme/TabItem';
-import styles from './index.module.css';
+import {EVENTS, track} from '../analytics';
+import {
+ ACTION_REPO,
+ Action,
+ Doorway,
+ Hero as PageHero,
+ NEW_ISSUE,
+ PageShell,
+ issueUrl,
+ REPO,
+ Section,
+ Todo,
+ TrackedCode,
+ VisualSlot,
+ styles,
+} from '../components/site';
/*
* ---------------------------------------------------------------------------
@@ -13,25 +27,71 @@ import styles from './index.module.css';
* apart from the markup below so it can be edited or lifted out without
* reading any JSX.
*
- * It is derived from the repository README, which is the source of truth. If
- * the two disagree, the README wins and this file is stale.
+ * Two rules this file follows, from the landing page messaging brief:
+ *
+ * 1. Local mode leads. Everything above the fold is true without an account,
+ * without credentials and without a network call. Platform mode is one
+ * clearly-fenced section near the bottom, and is never implied earlier.
+ *
+ * 2. Nothing is claimed that the repository cannot currently back. Where the
+ * brief asks for a number, a logo, a demo URL or a GitLab catalog
+ * component that does not exist yet, the value is a TODO placeholder --
+ * rendered visibly, listed in LAUNCH_BLOCKERS below, and not something
+ * this page can go public with still in place.
+ *
+ * Technical claims here are quoted from the docs, which are the source of
+ * truth: exit codes from docs/tirith-usage/exit-codes.md, the action snippet
+ * and inputs from docs/tirith-usage/ci-integration.md, masking behaviour from
+ * docs/tirith-usage/platform-check.md. If the two disagree, the docs win and
+ * this file is stale.
* ---------------------------------------------------------------------------
*/
+
+/*
+ * The page cannot ship publicly while any of these are unresolved. They are
+ * listed here rather than only inline so that one grep -- or one glance at
+ * this constant -- gives the whole set.
+ */
+const LAUNCH_BLOCKERS = [
+ // Home
+ 'Proof-strip numbers and any customer logo approval (section: proof)',
+ 'Hero visual and the PR-2 failure GIF (sections: hero, verdict)',
+ 'Demo repository and PR 1-4 URLs, plus a credential-free PR 0 (section: demo)',
+ 'GitLab CI catalog component URL, or drop the native claim (section: pipelines)',
+ 'Optional platform-mode screenshot (section: modes)',
+ 'Traction counters on this page share the Traction page snapshot job (section: receipts)',
+
+ // Companion pages
+ 'Traction: the scheduled snapshot job and traction-data.json (/traction)',
+ 'Traction: stars-over-time, cadence, policy-work and contributor displays (/traction)',
+ 'Learn: lessons 4-7 and the course shell that carries progress (/learn)',
+ 'Playground: encryption and location templates have no maintained example (/playground)',
+ 'Playground: per-template provenance before community policies are listed (/playground, /policies)',
+ 'Fleet: HUBSPOT_PORTAL_ID and HUBSPOT_FORM_GUID, and the matching HubSpot properties (/fleet)',
+ 'Policies: the advertised check count needs a status label and approval (/policies)',
+ 'Policies: the sign-up URL, currently routed to /fleet instead (/policies)',
+ 'AI: the StackGuardian MCP package name, so install commands can be generated (/ai)',
+];
+
const content = {
hero: {
- title: 'Tirith — IaC Governance plugin',
- tagline:
- 'Plugin IaC Governance for any pipeline, running anywhere. Evaluate plans with Tirith, ' +
- 'protect sensitive values, enforce centralised governance, and surface actionable results ' +
- 'before infrastructure changes are applied.',
+ eyebrow: 'Open-source IaC governance',
+ title: 'Put governance in front of every Terraform plan.',
body:
- 'Tirith reads the plan your pipeline already produces, checks it against your policies, and ' +
- 'exits non-zero so a violating change never reaches apply. Apache-2.0, and no account needed.',
- install: 'pip install git+https://github.com/StackGuardian/tirith.git',
+ 'Tirith plugs into the pipeline you already run, evaluates Terraform or OpenTofu plans on ' +
+ 'infrastructure you already control, and stops non-compliant changes before apply. A few ' +
+ 'lines to start. No ' +
+ 'account, no migration and no new policy language to program.',
+ trust: [
+ 'Apache-2.0',
+ 'Runs wherever your pipeline runs',
+ 'No account required',
+ 'Terraform + OpenTofu',
+ ],
+ primary: {label: 'Star Tirith on GitHub', href: REPO},
+ secondary: {label: 'Govern your first pipeline', to: '/docs/tirith-usage/ci-integration/'},
announcement: {
label: 'New',
- // No backticks: this is plain JSX text, not markdown, so they would render literally.
- // The command is set in by the renderer instead.
command: 'tirith ui',
text:
'— an interactive interface. Explore a failing evaluation down to the resource that ' +
@@ -39,114 +99,404 @@ const content = {
to: '/docs/tirith-usage/interactive-interface/',
linkLabel: 'Read more',
},
- actions: [
- {label: 'Get started', to: '/docs/getting-started-with-tirith/', primary: true},
- {label: 'GitHub', href: 'https://github.com/StackGuardian/tirith'},
- ],
},
- problem: {
- heading: 'The problem',
+ /*
+ * The quick start. Three tabs because the three audiences arrive with
+ * different constraints, and sending a GitLab reader to a GitHub Action is
+ * how you lose them. Each tab is a complete, copyable job -- not a fragment.
+ */
+ start: {
+ heading: 'Start where you are. Add one governance step.',
body:
- 'A pipeline that runs init, plan and apply deploys whatever the plan says. Nothing sits ' +
- 'between the plan and the change.',
- points: [
- 'Every repository does it its own way, so there is no one place to see what was deployed, or what was refused.',
- 'Rules that do exist live in whichever pipeline someone wrote them into, and get copied into the next repository by hand.',
- 'When a check does fail, the log says a job failed. It does not say which rule, on which resource, or what value broke it.',
+ 'Keep your existing plan job. Tirith reads the plan JSON it already produces and evaluates ' +
+ 'policies committed under .tirith/policies. Everything runs inside the pipeline you ' +
+ 'already have: your GitHub or GitLab runners, your Jenkins agents, your private build ' +
+ 'infrastructure, and nothing is uploaded.',
+ tabs: [
+ {
+ value: 'gha',
+ label: 'GitHub Actions',
+ language: 'yaml',
+ code:
+ '- run: terraform show -json tfplan > plan.json\n' +
+ '- uses: StackGuardian/tirith-iac-governance-action@v2\n' +
+ ' with: {fail-on-error: true}',
+ note:
+ 'With a plan.json in the working directory that is the whole integration. The action ' +
+ 'needs pull-requests: write and checks: write to post its sticky comment and check run.',
+ expandedLabel: 'Show the permissions block',
+ expanded:
+ 'permissions:\n' +
+ ' contents: read\n' +
+ ' pull-requests: write # sticky comment\n' +
+ ' checks: write # check run\n' +
+ '\n' +
+ 'steps:\n' +
+ ' - run: |\n' +
+ ' terraform plan -out=tfplan -input=false\n' +
+ ' terraform show -json tfplan > plan.json\n' +
+ '\n' +
+ ' - uses: StackGuardian/tirith-iac-governance-action@v2\n' +
+ ' with: {fail-on-error: true}',
+ },
+ {
+ value: 'gitlab',
+ label: 'GitLab CI',
+ language: 'yaml',
+ code:
+ 'policy:\n' +
+ ' image: python:3.12\n' +
+ ' needs: [plan]\n' +
+ ' script:\n' +
+ ' - pip install "git+https://github.com/StackGuardian/tirith.git@1.2.0"\n' +
+ ' - tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error',
+ note:
+ 'The CLI directly, which is all the action does underneath. Nothing here is ' +
+ 'GitLab-specific: any runner that can execute a container and produce a plan works the ' +
+ 'same way.',
+ },
+ {
+ value: 'cli',
+ label: 'pip / any CI',
+ language: 'bash',
+ code:
+ '# Tirith is not on PyPI. pip install tirith installs an unrelated\n' +
+ '# project of the same name -- install from git, and pin a tag so a\n' +
+ '# CI job cannot change behaviour underneath you.\n' +
+ 'pip install "git+https://github.com/StackGuardian/tirith.git@1.2.0"\n' +
+ '\n' +
+ 'terraform show -json tfplan > plan.json\n' +
+ 'tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error',
+ note:
+ 'Python 3.8 or newer. git ls-remote --tags lists the available tags; 1.2.0 is the ' +
+ 'newest.',
+ },
],
},
- add: {
- heading: 'What you add',
- body: 'Two lines, on GitHub Actions:',
- code:
- '- run: terraform show -json tfplan > plan.json\n' +
- '- uses: StackGuardian/tirith-iac-governance-action@v2',
- note:
- 'With a plan.json in the working directory that is the whole integration — no with: block. ' +
- 'Policies are JSON files committed under .tirith/policies.',
+ proof: {
+ heading: 'Policy evaluation trusted inside enterprise cloud delivery',
+ body:
+ 'Tirith powers policy evaluation within StackGuardian deployments used by teams governing ' +
+ 'some of the world’s largest cloud estates.',
+ stats: [
+ {value: '[X]+', label: 'repositories governed'},
+ {value: '[Y]+', label: 'plans evaluated'},
+ {value: '[Z]+', label: 'cloud resources covered'},
+ {value: '[N]', label: 'enterprise teams'},
+ ],
},
- get: {
- heading: 'What you get',
- items: [
+ problem: {
+ heading: 'Your pipeline can plan and apply. What decides whether it should?',
+ body:
+ 'Terraform and OpenTofu make infrastructure repeatable. They do not make every change safe, ' +
+ 'compliant or understood. Rules get copied between repositories, reviews depend on whoever ' +
+ 'is available, and a failing job often says less than the plan that caused it.',
+ points: [
{
- title: 'Policies as data, not code',
+ title: 'Different pipeline, different guardrails',
body:
- 'A rule is a JSON file describing what to look for, rather than a program you have to ' +
- 'maintain. Terraform plans, terraform state, Kubernetes manifests, Infracost breakdowns ' +
- 'and arbitrary JSON are all evaluated the same way.',
+ 'The same standard is implemented differently — or not at all — across repositories and ' +
+ 'CI systems.',
},
{
- title: 'Cost, before the change is applied',
+ title: 'A wall of findings is not a decision',
body:
- 'Point Tirith at an infracost breakdown and gate on the monthly or hourly total of the ' +
- 'resources the plan would create.',
+ 'Teams need to know what failed, on which resource and value, and whether apply is ' +
+ 'allowed.',
},
{
- title: 'Sensitive values masked on your own runner',
+ title: 'Governance should travel with the change',
body:
- 'Masking happens before anything leaves the machine, so a value marked sensitive stays ' +
- 'out of the report and out of any upload.',
+ 'The useful moment is after plan and before apply, inside the workflow developers ' +
+ 'already use.',
},
+ ],
+ },
+
+ verdict: {
+ heading: 'A verdict a developer can act on.',
+ body:
+ 'Tirith names the rule, resource, planned action and value behind the result. A failed ' +
+ 'policy can block the job; a tool error remains visibly different from a policy saying no; ' +
+ 'a check that could not run never earns a false pass.',
+ points: [
{
- title: 'An exit code your pipeline can act on',
+ title: 'Visible',
body:
- 'Exit 3 means a policy said no; exit 1 means Tirith could not tell you either way. A job ' +
- 'that treats every non-zero code alike cannot tell a working gate from a broken one.',
+ 'A sticky pull-request comment and GitHub Check put the result where the change is ' +
+ 'reviewed.',
},
{
- title: 'The plan and the code, kept together',
+ // Deliberately says "where it can": a check that fails because an
+ // attribute is absent has no value to hang a resource address on, so
+ // it reports the rule and the attribute rather than the address.
+ // Promising an address every time would be a promise the engine does
+ // not currently keep.
+ title: 'Explainable',
body:
- 'In platform mode each run uploads the masked documents alongside the terraform source ' +
- 'they describe, so a finding can still be read against the code that caused it later on.',
+ 'Every result names the rule and the value behind it, and where the attribute exists ' +
+ 'to be read, the resource address and its create, update or delete action.',
+ },
+ {
+ title: 'Controllable',
+ body: 'Use the exit code to warn, fail or stop the path to apply.',
+ },
+ ],
+ /*
+ * The exit-code table is the concrete form of "a non-answer must not
+ * masquerade as a pass", which is otherwise just a claim. Quoted from
+ * docs/tirith-usage/exit-codes.md.
+ */
+ exitCodes: [
+ {code: '0', meaning: 'Policies passed, or nothing was in scope to gate on'},
+ {code: '3', meaning: 'A policy ran and said no: your change violates a rule'},
+ {
+ code: '1',
+ meaning:
+ 'Tirith could not tell you either way: bad input, an unevaluable policy, every check ' +
+ 'skipped',
+ },
+ ],
+ exitNote:
+ '3 is not 1 by design. A pipeline can page the platform team on 1 and the change author ' +
+ 'on 3, and both surfaces fail closed: anything that leaves the verdict unknown exits ' +
+ 'non-zero regardless of --fail-on-error.',
+ exitLink: {label: 'The full exit-code contract', to: '/docs/tirith-usage/exit-codes/'},
+ },
+
+ ladder: {
+ heading: 'Add control one pull request at a time.',
+ body:
+ 'Tirith does not require a migration programme. Start with one plan, learn from the ' +
+ 'verdict, then expand only when the next level of control earns its place.',
+ stages: [
+ {stage: 'Observe', outcome: 'See every evaluated plan and result in the pull request.'},
+ {stage: 'Understand', outcome: 'Trace a failure to the rule, resource, action and value.'},
+ {
+ stage: 'Recommend',
+ outcome: 'Show the smallest compliant change or route it to the right owner.',
+ },
+ {
+ stage: 'Remediate',
+ outcome: 'Fix the code and watch the same gate clear.',
},
{
- title: 'One policy set, many pipelines',
+ stage: 'Govern',
+ outcome: 'Reuse policy across pipelines; optionally centralise policy, approvals and evidence.',
+ optional: true,
+ },
+ {
+ stage: 'Execute',
+ outcome: 'Keep your existing apply or, when ready, move governed execution into StackGuardian.',
+ optional: true,
+ },
+ ],
+ },
+
+ demo: {
+ heading: 'A real Terraform pipeline. Four pull requests.',
+ body:
+ 'Each one adds a little more control without replacing the pipeline. The first three are the ' +
+ 'whole before-apply loop: the gate arrives, it catches a real violation, and a one-line fix ' +
+ 'clears it.',
+ /*
+ * Each card reserves the asset that proves its claim. A demo section that
+ * only describes four pull requests asks the reader to take the whole
+ * before-apply loop on trust -- the one thing this page exists to show
+ * rather than assert.
+ */
+ cards: [
+ {
+ n: '1',
+ title: 'Add the gate',
+ body: 'Route the plan you already produce through Tirith. No new job and no change to Terraform.',
+ asset:
+ 'GIF of the workflow YAML diff on the left, then the pull-request comment and the ' +
+ '`Tirith IaC Governance` check run appearing on the right. Keep the whole diff in ' +
+ 'frame: eight lines is the point.',
+ },
+ {
+ n: '2',
+ title: 'See a real violation',
+ body: 'An empty Owner tag turns the check red. The resource is not created and Apply is visibly skipped.',
+ asset:
+ 'Screenshot of the failed check expanded: the rule that fired, the resource address, its ' +
+ 'planned action, the missing Owner value, and the Apply job showing as skipped below it.',
+ },
+ {
+ n: '3',
+ title: 'Fix the code',
+ body: 'One line satisfies the rule. The same gate clears without a ticket, exception workflow or separate console.',
+ asset:
+ 'GIF of the one-line diff adding the tag, then the same check turning green and Apply ' +
+ 'becoming available. Same viewport as card 2, so the only thing that changes is the verdict.',
+ },
+ {
+ n: '4',
+ title: 'Keep deployed evidence',
body:
- 'Because Tirith is a CLI rather than an integration built into one CI system, the same ' +
- 'policies gate a GitHub Actions job, a GitLab job and a laptop. In platform mode, Tirith ' +
- 'rules and Checkov findings come back in a single verdict.',
+ 'Optional platform mode publishes a masked state snapshot after apply so proposed and ' +
+ 'deployed infrastructure can be reviewed together.',
+ platform: true,
+ asset:
+ 'Screenshot of the run and state snapshot in StackGuardian after apply, with the masked ' +
+ 'values visible as `__SG_REDACTED__` so the masking is shown rather than claimed.',
+ },
+ ],
+ help: {
+ label: 'Ask a maintainer to help with your first pipeline',
+ href: issueUrl({template: 'first-pipeline-help.md'}),
+ },
+ },
+
+ scanner: {
+ heading: 'Use scanners for coverage. Use Tirith to govern the change.',
+ body:
+ 'Tirith is a policy engine, but its job is not to replace every scanner or policy language. ' +
+ 'It turns the plan your pipeline already produces and the policies you choose into one ' +
+ 'enforceable decision before apply. Run Tirith policies locally; bring OPA, Checkov and ' +
+ 'cost findings into the same governed verdict when you use platform mode.',
+ supporting:
+ 'The goal is fewer disconnected tools to interpret — not another list to reconcile.',
+ },
+
+ policies: {
+ heading: 'Policies are JSON data, not programs.',
+ body:
+ 'Describe the provider, the value to inspect and the condition it must satisfy. Tirith ' +
+ 'handles the traversal and returns the resource-level evidence.',
+ code: `{
+ "meta": {
+ "version": "v1",
+ "required_provider": "stackguardian/terraform_plan",
+ "name": "Every resource carries a costcenter tag"
+ },
+ "evaluators": [{
+ "id": "costcenter_tag_present",
+ "provider_args": {
+ "operation_type": "attribute",
+ "terraform_resource_type": "*",
+ "terraform_resource_attribute": "tags.costcenter"
+ },
+ "condition": {"type": "IsNotEmpty"}
+ }],
+ "eval_expression": "costcenter_tag_present"
+}`,
+ note:
+ 'What makes this workable is not that policy is easy, since a complicated rule is complicated in ' +
+ 'any language, but that there is a schema, a form-based builder, worked examples, and no ' +
+ 'second policy runtime to operate.',
+ actions: [
+ {label: 'Browse the policy catalogue', to: '/policies'},
+ {label: 'Evaluate or build one', to: '/playground'},
+ {label: 'Read the policy reference', to: '/docs/tirith-policies/tirith-policy-reference/'},
+ ],
+ },
+
+ pipelines: {
+ heading: 'One policy contract across every pipeline.',
+ body:
+ 'Tirith is a CLI, not an integration built into one CI system. Use the native GitHub ' +
+ 'Action, or invoke the CLI anywhere that can run a container and produce a Terraform or ' +
+ 'OpenTofu plan: GitLab CI, Jenkins, CircleCI, Azure Pipelines, Buildkite, a self-hosted ' +
+ 'runner inside your own network, or a laptop. The policy, result shape and exit-code ' +
+ 'contract stay the same in every one of them.',
+ /*
+ * Four tiles rather than a longer list, because the point is coverage
+ * rather than a directory: a reader on Jenkins or an air-gapped runner has
+ * to be able to place themselves here, and an exhaustive list of CI
+ * vendors would imply the ones missing from it are unsupported.
+ */
+ integrations: [
+ {name: 'GitHub Actions', how: 'Native action', href: ACTION_REPO},
+ {
+ name: 'GitLab CI',
+ how: 'CLI in the job',
+ todo: 'catalog component URL required before claiming native',
},
+ {name: 'Jenkins, CircleCI, any container CI', how: 'CLI in a container step'},
+ {name: 'Self-hosted, air-gapped, or a laptop', how: 'CLI, no network, no account'},
+ ],
+ secondary:
+ 'Tirith can also evaluate Terraform state, Kubernetes manifests, Infracost breakdowns and ' +
+ 'arbitrary JSON. This page stays focused on plans because that is where a decision can ' +
+ 'still stop an unsafe change.',
+ secondaryLink: {label: 'All providers', to: '/docs/tirith-providers/providers-overview/'},
+ },
+
+ modes: {
+ heading: 'Keep it local. Centralise it only when you need to.',
+ columns: ['OSS local mode', 'Optional platform mode'],
+ rows: [
+ ['Account', 'None', 'StackGuardian organisation and token'],
+ ['Policy source', 'Files in your repository', 'Centrally managed policy sets'],
+ ['Evaluation', 'Wherever your pipeline runs', 'Platform workflow, after client-side masking'],
+ ['Network', 'None', 'Masked plan, results and metadata; source is user-controlled'],
+ ['Reporting', 'PR comment, check and CLI output', 'Central history, evidence and prioritisation'],
+ ['Control', 'Warn or fail via the pipeline exit code', 'Approvals, credential brokering and governed execution'],
+ ['Remediation', 'Developer fixes the code', 'Assisted remediation'],
],
+ hook:
+ 'Governing more than one pipeline? StackGuardian can discover Terraform and OpenTofu ' +
+ 'repositories, prioritise gaps by severity and open installation pull requests for your ' +
+ 'approval.',
+ // Predates the Fleet page and used to dump the reader on a blank issue
+ // form. The page now exists and answers exactly this question.
+ cta: {label: 'What fleet-wide governance involves', to: '/fleet'},
+ dataHandling:
+ 'Credentials are what select platform mode; there is no other switch. Terraform-sensitive ' +
+ 'values are masked locally, by the machine running Tirith, before upload, but those ' +
+ 'markers are not exhaustive and a secret hardcoded in a .tf file is not masked. Use ' +
+ '--no-source when source must not leave your network.',
+ dataLink: {label: 'What is masked and uploaded', to: '/docs/tirith-usage/platform-check/'},
},
- worksWith: {
- heading: 'Works with',
- items: [
+ community: {
+ heading: 'Open source first. Clear boundaries by design.',
+ points: [
{
- title: 'GitHub Actions',
+ title: 'Apache-2.0',
+ body: 'Use, inspect, fork and run Tirith without a StackGuardian account.',
+ },
+ {
+ title: 'Local stays local',
body:
- 'A native action that finds the plan, posts a sticky pull-request comment, creates a ' +
- 'check run and sets the exit code.',
- link: {
- label: 'tirith-iac-governance-action',
- href: 'https://github.com/StackGuardian/tirith-iac-governance-action',
- },
+ 'In OSS mode policies and plans never leave the machine evaluating them, wherever that ' +
+ 'machine is; Tirith makes no network call.',
},
{
- title: 'GitLab CI, and any container-based CI',
+ title: 'Platform mode is explicit',
body:
- 'Install the CLI in the job and call it directly, which is all the action does ' +
- 'underneath. There is no GitLab-native equivalent of the action.',
+ 'Credentials select platform mode. The documentation states what is masked, what is ' +
+ 'uploaded and how to disable source upload.',
+ },
+ {
+ title: 'Maintainer-led',
+ body: 'Tirith is governed by its maintainers, with engineering support from StackGuardian.',
},
{
- title: 'Your machine',
- body: 'The same command, the same verdict, no account and no network.',
+ title: 'Built in public',
+ body: 'Report bugs, propose features and challenge decisions through GitHub Issues.',
},
],
+ actions: [
+ {label: 'Open an issue', href: NEW_ISSUE},
+ {label: 'Pick a good first issue', href: `${REPO}/labels/good%20first%20issue`},
+ {label: 'Read GOVERNANCE.md', href: `${REPO}/blob/main/GOVERNANCE.md`},
+ ],
},
- platform: {
- heading: 'Keeping policy in one place',
+ final: {
+ heading: 'Govern the next plan — not the next platform migration.',
body:
- 'Everything above works with policy files committed to your repository. If you would rather ' +
- 'not copy those files into every repository that needs gating, tirith platform check ' +
- 'evaluates against the policies a StackGuardian organization enforces instead — same ' +
- 'document, same verdict, same exit codes, plus a central run history. That mode is optional, ' +
- 'and is the only part that talks to a network.',
- link: {label: 'Read about platform mode', to: '/docs/tirith-usage/platform-check/'},
+ 'Add Tirith to one Terraform or OpenTofu pipeline, open a pull request and see the first ' +
+ 'evaluated plan. Keep it local for as long as that is all you need.',
+ micro:
+ 'Apache-2.0. No account. No cloud credentials. Your infrastructure, your policies, your ' +
+ 'pipeline.',
},
};
@@ -156,31 +506,10 @@ const content = {
* ---------------------------------------------------------------------------
*/
-// Uses Docusaurus's own button classes rather than hand-rolled ones: they carry
-// a readable foreground in both light and dark mode. A custom rule here had set
-// the label to var(--ifm-background-color), which is #0000 in light mode -- so
-// the text was transparent on a purple fill. The primary button additionally
-// takes a CSS-module class that recolours it to the StackGuardian icon blue by
-// overriding the --ifm-button-* custom properties (see index.module.css).
-function Action({label, to, href, primary}) {
- const className = primary
- ? `button button--lg button--primary ${styles.heroPrimary}`
- : 'button button--lg button--secondary';
- return to ? (
-
- {label}
-
- ) : (
-
- {label}
-
- );
-}
-
function Hero() {
- const {title, tagline, body, install, actions, announcement} = content.hero;
+ const {eyebrow, title, body, trust, primary, secondary, announcement} = content.hero;
return (
-
+ <>
{announcement.label}
@@ -188,86 +517,477 @@ function Hero() {
{announcement.linkLabel} →
-
- {title}
-
-
{tagline}
+
+
+
+ Hero visual. An animated split view: an existing pipeline YAML gains the Tirith step while
+ the adjacent PR comment resolves from evaluating to a precise pass/fail verdict. A visible
+ local mode label, and no StackGuardian UI in the first frame.
+
+
+ >
+ );
+}
+
+function QuickStart() {
+ const {heading, body, tabs} = content.start;
+ return (
+
+
+
+ GIF of PR 2 failing the Owner-tag rule. Highlight the resource, the missing value and Apply
+ being skipped. Do not crop away the repository context.
+
+
+ );
+}
+
+function Ladder() {
+ const {heading, body, stages} = content.ladder;
+ return (
+
+
+
+
+ The existing four-PR demo repository requires a StackGuardian organisation and token, so it
+ demonstrates platform mode. Label it as such, and add a credential-free PR 0 or companion
+ repository with .tirith/policies committed locally that reaches the same first
+ verdict with no credentials. Supply both URLs.
+
+
+
+ Terminal recording of the same evaluation run locally: the command, the per-resource results
+ scrolling past, the summary line, and echo $? printing 3. Proof
+ that the pull-request verdict and the laptop verdict are the same verdict.
+
+
+
+
+ {help.label}
+ {' '}
+ , which is an issue template rather than a sales form.
+
+
+
+ Screenshot of the StackGuardian plan view with policy verdict, severity, source evidence and run
+ snapshot. Caption it Optional platform mode and keep it visually separate from the
+ OSS screenshots.
+
+
+ );
+}
+
+function Community() {
+ const {heading, points, actions} = content.community;
+ return (
+
+
+ Already got a verdict?{' '}
+
+ Tell us how the first plan went
+
+ .
+
+
);
}
-function Section({heading, children}) {
+/*
+ * The doorways to the companion pages, placed where the brief asks for them:
+ * Learn straight after the quick start, Playground straight after policy
+ * authoring, Fleet immediately before the StackGuardian progression, and the
+ * traction counters just before the final CTA.
+ *
+ * Each sits at the moment the reader has just acquired the question it
+ * answers -- someone who has read two lines of YAML is exactly who wants a
+ * ten-minute course, and someone who has just read a policy is exactly who
+ * wants to watch one evaluate.
+ */
+
+function LearnDoorway() {
+ return (
+
+ );
+}
+
+function PlaygroundDoorway() {
+ return (
+
+ );
+}
+
+function AiDoorway() {
return (
-
-
- {heading}
-
- {children}
-
+
+ );
+}
+
+function FleetDoorway() {
+ return (
+
+ );
+}
+
+/*
+ * Counters rather than claims. Every figure is a placeholder for now: the
+ * scheduled job that fetches them has not been built, and inventing numbers on
+ * the way to a page about verifiability would undermine both.
+ */
+function TractionStrip() {
+ const counters = [
+ ['[X]', 'stars'],
+ ['[Y]', 'forks'],
+ ['[Z]', 'contributors'],
+ ['[R]', 'releases'],
+ ];
+ return (
+
+
+ {counters.map(([value, label]) => (
+
+
{value}
+
{label}
+
+ ))}
+
+
+ Public GitHub activity, linked back to the evidence. Local Tirith runs send no telemetry, so
+ these count attention and contribution rather than production use.{' '}
+
+ See the receipts
+
+ .
+
+
+ These four counters are placeholders. They are fed by the same unbuilt snapshot job as the
+ Traction page. Build it once and both surfaces become real.
+
+
);
}
export default function Home() {
return (
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
);
}
+
+export {LAUNCH_BLOCKERS};
diff --git a/documentation/src/pages/index.module.css b/documentation/src/pages/index.module.css
deleted file mode 100644
index 743e17a5..00000000
--- a/documentation/src/pages/index.module.css
+++ /dev/null
@@ -1,129 +0,0 @@
-/**
- * Landing page styles. Kept deliberately small: this page is a placeholder, and
- * it should not grow a design system that the real rebuild would have to undo.
- *
- * Colours come from Docusaurus theme variables so light and dark mode both work
- * without a second palette being defined here.
- */
-
-.page {
- max-width: 46rem;
- margin: 0 auto;
- padding: 3rem 1.25rem 5rem;
-}
-
-.hero {
- margin-bottom: 1rem;
-}
-
-.heroTitle {
- font-size: 2.25rem;
- letter-spacing: -0.02em;
- margin-bottom: 0.75rem;
-}
-
-.tagline {
- font-size: 1.15rem;
- margin-bottom: 1rem;
-}
-
-.muted {
- color: var(--ifm-color-emphasis-700);
-}
-
-.section {
- margin-top: 3rem;
- padding-top: 1.5rem;
- border-top: 1px solid var(--ifm-color-emphasis-300);
-}
-
-.sectionHeading {
- font-size: 1.25rem;
- margin-bottom: 0.75rem;
-}
-
-.list li {
- margin-bottom: 0.75rem;
-}
-
-.actions {
- display: flex;
- flex-wrap: wrap;
- gap: 0.75rem;
- margin-top: 1.5rem;
-}
-
-/*
- * The primary hero button uses the StackGuardian icon blue rather than the
- * site primary. The icon's #007aff fails 4.5:1 against white (4.02:1), so the
- * fill is the same hue darkened to #006ee6 (4.80:1), and darker again on
- * hover. The label is pinned to opaque white because --ifm-button-color
- * resolves differently per theme.
- */
-.heroPrimary {
- --ifm-button-background-color: #006ee6;
- --ifm-button-border-color: #006ee6;
- --ifm-button-color: #fff;
-}
-
-.heroPrimary:hover,
-.heroPrimary:active {
- --ifm-button-background-color: #0062cc;
- --ifm-button-border-color: #0062cc;
-}
-
-@media screen and (max-width: 996px) {
- .page {
- padding: 2rem 1rem 3rem;
- }
-
- .heroTitle {
- font-size: 1.75rem;
- }
-}
-
-/*
- * The announcement strip above the hero title. A link rather than a static banner: the whole
- * row is the target, since a one-line notice about a new feature is only useful if it is also
- * the way to reach it.
- *
- * Colours come from Infima's own variables so it follows the site's light and dark themes
- * rather than pinning a background that only works in one of them.
- */
-.announcement {
- display: inline-flex;
- align-items: baseline;
- flex-wrap: wrap;
- gap: 0.5rem;
- margin-bottom: 1.5rem;
- padding: 0.5rem 0.9rem;
- border: 1px solid var(--ifm-color-emphasis-300);
- border-radius: 2rem;
- background: var(--ifm-color-emphasis-100);
- color: var(--ifm-font-color-base);
- font-size: 0.95rem;
- line-height: 1.5;
- text-decoration: none;
-}
-
-.announcement:hover {
- border-color: var(--ifm-color-primary);
- text-decoration: none;
-}
-
-.announcementLabel {
- padding: 0.05rem 0.5rem;
- border-radius: 1rem;
- background: #006ee6;
- color: #fff;
- font-size: 0.78rem;
- font-weight: 700;
- letter-spacing: 0.02em;
- text-transform: uppercase;
-}
-
-.announcementLink {
- color: var(--ifm-color-primary);
- font-weight: 600;
- white-space: nowrap;
-}
diff --git a/documentation/src/pages/learn.js b/documentation/src/pages/learn.js
new file mode 100644
index 00000000..e10dbdd5
--- /dev/null
+++ b/documentation/src/pages/learn.js
@@ -0,0 +1,290 @@
+import Link from '@docusaurus/Link';
+
+import {EVENTS, track, usePageView} from '../analytics';
+import {
+ Action,
+ Hero,
+ PageShell,
+ REPO,
+ Section,
+ Todo,
+ TrackedCode,
+ Verdict,
+ issueUrl,
+ styles,
+} from '../components/site';
+import fixtures from '../data/fixtures.json';
+
+/*
+ * ---------------------------------------------------------------------------
+ * LEARN
+ *
+ * Job: turn an interested developer into someone who can explain a Tirith
+ * verdict and add the first local check, without reading the reference docs.
+ *
+ * This is the course outline plus a fully worked first lesson, not seven
+ * interactive lessons. The distinction is deliberate and is stated on the page:
+ * lesson one is complete and real -- the plan, the policy and the verdict below
+ * are the actual engine output for a fixture that ships with Tirith -- and the
+ * remaining six are specified but not yet built.
+ *
+ * Shipping one honest lesson beats shipping seven shells. A visitor who works
+ * through what is here can already read a verdict, which is the thing the page
+ * exists to teach.
+ * ---------------------------------------------------------------------------
+ */
+
+// Lesson one uses the tags example: it fails, and it fails on one resource out
+// of two, which is the clearest possible illustration of resource-level
+// evidence. A wholly-failing fixture teaches less.
+const LESSON_ONE = fixtures.examples.find((e) => e.key === '01-required-tags');
+
+const CURRICULUM = [
+ {
+ n: 1,
+ title: 'Meet the plan',
+ task: 'Identify create, update and delete actions in a supplied plan JSON.',
+ ladder: 'Understand',
+ ready: true,
+ },
+ {
+ n: 2,
+ title: 'Get the first verdict',
+ task: 'Run a maintained policy against the fixture and distinguish pass, fail, unevaluated and tool error.',
+ ladder: 'Observe',
+ ready: true,
+ },
+ {
+ n: 3,
+ title: 'Read the evidence',
+ task: 'Trace the failed result to rule, resource, action and before/after value.',
+ ladder: 'Understand',
+ ready: true,
+ },
+ {
+ n: 4,
+ title: 'Write a readable policy',
+ task: 'Modify a JSON rule that requires an Owner tag; validate the schema and its test coverage.',
+ ladder: 'Control',
+ },
+ {
+ n: 5,
+ title: 'Break it, then fix it',
+ task: 'Remove the tag, see apply become unsafe, then make the one-line fix and re-run.',
+ ladder: 'Remediate',
+ },
+ {
+ n: 6,
+ title: 'Add it to CI',
+ task: 'Choose GitHub Actions, GitLab CI or the CLI and copy a pinned, local-mode snippet.',
+ ladder: 'Govern',
+ },
+ {
+ n: 7,
+ title: 'Choose what comes next',
+ task: 'Keep policies local, contribute one, or look at optional fleet governance.',
+ ladder: 'Continue',
+ },
+];
+
+const content = {
+ hero: {
+ eyebrow: 'Learn Tirith by doing',
+ title: 'From Terraform plan to governed change — in one short course.',
+ body:
+ 'Work through a real plan, watch a policy fail on a specific resource, and take the same ' +
+ 'check to a pipeline. Everything runs against public fixtures. No StackGuardian account and ' +
+ 'no private repository required.',
+ trust: ['Free', 'Self-paced', 'Public fixtures', 'No account'],
+ },
+};
+
+function LessonOne() {
+ const example = LESSON_ONE;
+ const planned = example.input.resource_changes || [];
+
+ return (
+ <>
+
+ Here is a Terraform plan with two resources in it. Before reading any policy, read the plan:
+ every entry in resource_changes carries an address, the actions Terraform
+ intends, and the values it will apply.
+
+ terraform_resource_type: "*" means every resource in the plan is in scope, and{' '}
+ IsNotEmpty means the value has to be present and non-blank. Run it, and Tirith
+ returns this:
+
+
+
+
+
+ Read what that says. The rule did not simply fail. It was checked
+ against both resources and reported on each one separately: it passed on{' '}
+ aws_instance.web, naming the resource, its planned create action
+ and the value it found, product-123. That per-resource evidence is the whole
+ point of evaluating the plan rather than the source.
+
+
+
+ Now notice what the failing line does not say. It tells you the attribute{' '}
+ tags.costcenter was not found, but it does not name the resource it was looking
+ at. When an attribute is missing there is no value to attach a resource to, so the failure
+ arrives without an address. You find the culprit by looking at the plan for the resource
+ that has no such tag, which here is aws_s3_bucket.assets. Worth knowing before
+ you meet it in a pipeline: a policy that checks for the presence of something reports
+ differently from one that checks the shape of something that exists.
+
+
+
+ And the exit code is {example.exitCode}, not 1. Three means a
+ check ran and said no. One would mean Tirith could not tell you either way. A pipeline that
+ treats every non-zero code alike cannot tell a working gate from a broken one, which is why
+ the two are kept apart.
+
+ Seven short lessons, one concept and one thing to do in each. They run in the order the
+ job happens: you cannot fix what you cannot explain, and you should not enforce
+ what you have not watched fail once.
+
+
+ {CURRICULUM.map((lesson) => (
+
+ {lesson.n}
+
+ {lesson.title}
+ {lesson.ready ? null : not built yet}
+
+ {lesson.ladder}
+ {lesson.task}
+
+ ))}
+
+
+
+ Lesson 1 above covers the ground of lessons 1–3 in one page and is complete. Lessons 4–7
+ are specified but not built. Building them needs a course shell that does not exist yet:
+ a left rail with progress, a sticky Previous/Next footer, per-lesson completion driven by
+ the intended interaction rather than by scrolling, and local progress storage with a
+ working Reset. Until then this page is one worked lesson plus an outline, and says so.
+
+
+
+
+
+
+ Real output, not screenshots. Every verdict on this page is generated
+ by running the Tirith engine over a fixture that ships with the tool. If the engine's
+ behaviour changes, so does this page.
+
+
+ Reproducible on your machine. The fixtures are in the repository, so
+ every result here can be re-run locally, and nothing depends on a service staying up.
+
+
+ Never just a green tick. A result is only useful with the evidence and
+ the enforcement consequence attached, so both are always shown.
+
+
+ Nothing is uploaded and nothing is gated. No account, no sign-in, no
+ progress sent anywhere.
+
+
+
+
+
+
+ You read a concrete plan, evaluated a policy, explained the result and saw the exit code a
+ pipeline would act on. Take that contract into a repository you own, or keep experimenting
+ with another plan.
+
+
+
+
+
+
+
+
+ Star Tirith
+
+
+
+
+ Request a lesson
+
+
+
+
+ Do this with a coding agent
+
+
+
+ Explore fleet governance
+
+
+
+
+ );
+}
diff --git a/documentation/src/pages/playground.js b/documentation/src/pages/playground.js
new file mode 100644
index 00000000..bad3d684
--- /dev/null
+++ b/documentation/src/pages/playground.js
@@ -0,0 +1,424 @@
+import {useState} from 'react';
+import Link from '@docusaurus/Link';
+
+import {EVENTS, capture, track, usePageView} from '../analytics';
+import {
+ Action,
+ BUILDER_URL,
+ DataTable,
+ Hero,
+ PageShell,
+ Section,
+ Todo,
+ TrackedCode,
+ Verdict,
+ issueUrl,
+ outcomeOf,
+ styles,
+} from '../components/site';
+import fixtures from '../data/fixtures.json';
+
+/*
+ * ---------------------------------------------------------------------------
+ * PLAYGROUND
+ *
+ * Job: let a developer experience Tirith's core contract -- plan plus policy
+ * produces an explainable verdict -- before changing a repository or creating
+ * an account.
+ *
+ * How real is this? Every verdict on this page is genuine output from the
+ * Tirith engine, produced by documentation/scripts/generate-fixtures.py
+ * running the real evaluator over the worked examples that ship with
+ * `tirith ui`. Nothing here is mocked up.
+ *
+ * What it deliberately does not do is evaluate an edited policy. Tirith is
+ * Python, so live evaluation means either shipping a Python runtime to every
+ * visitor or standing up an endpoint that receives plans. The second option
+ * contradicts the whole promise of the tool, and the first is a large amount
+ * of machinery for a page whose job is comprehension. So the workbench is
+ * honest about the boundary: pick a fixture, read the real verdict and the
+ * real evidence, then take the exact command away and run it yourself.
+ *
+ * The copy says so plainly rather than implying an evaluation happened.
+ * ---------------------------------------------------------------------------
+ */
+
+const EXAMPLES = fixtures.examples;
+
+/*
+ * The template gallery's collections, mapped onto the examples that actually
+ * exist. The brief lists six collections; four of them have a real, tested
+ * example behind them today and two do not, which is stated rather than
+ * papered over with an invented policy.
+ */
+const COLLECTIONS = [
+ ['Required metadata', 'Owner, cost centre and environment tags', 'Beginner', '01-required-tags'],
+ ['Destructive change', 'Block deletes for protected resources', 'Beginner', '04-block-destroy'],
+ ['Public exposure', 'Reject public storage or unrestricted ingress', 'Intermediate', '02-no-public-buckets'],
+ ['Cost', 'Gate on the monthly total an Infracost breakdown reports', 'Intermediate', '03-cost-ceiling'],
+ ['Workload health', 'Require Kubernetes liveness and readiness probes', 'Intermediate', '05-kubernetes-probes'],
+];
+
+const MISSING_COLLECTIONS = ['Encryption (require supported encryption attributes)', 'Location (restrict provider regions and accounts)'];
+
+const content = {
+ hero: {
+ eyebrow: 'Policy workbench',
+ title: 'What do you want to govern?',
+ body:
+ 'Pick a guardrail, read the plan it runs against, and see the exact verdict Tirith returns: ' +
+ 'the rule, the resource, the planned action and the value behind the result. It needs no ' +
+ 'account, touches no repository, and uploads nothing.',
+ trust: ['Real engine output', 'Public fixtures only', 'Nothing uploaded', 'No account'],
+ },
+ prompts: 'Describe a guardrail or start from a maintained template.',
+};
+
+function snippetFor(example) {
+ return [
+ '# Save the policy as .tirith/policies/' + example.key + '.json,',
+ '# then evaluate the plan your pipeline already produces.',
+ 'tirith \\',
+ ' -policy-path .tirith/policies \\',
+ ' -input-path plan.json \\',
+ ' --fail-on-error',
+ '',
+ '# exit ' + example.exitCode + ': ' + example.exitMeaning,
+ ].join('\n');
+}
+
+function Workbench({example}) {
+ const outcome = outcomeOf(example.result);
+ return (
+ <>
+
+
+
+ >
+ );
+}
+
+/**
+ * The policy builder, embedded.
+ *
+ * It is a separate application on its own deployment, and framing somebody
+ * else's app is a dependency rather than an integration: it will not follow
+ * this site's theme, it is cramped on a phone, and if that deployment changes
+ * or goes away this frame goes blank without telling anyone.
+ *
+ * So the frame is never the only route. The heading says what it is, a plain
+ * link opens it standalone directly above the frame, and that link is what a
+ * visitor on a narrow screen or a blocked iframe still has. Loaded lazily,
+ * because most people come here to read a verdict, not to author a rule.
+ */
+function Builder() {
+ return (
+ <>
+
+ Authoring rather than evaluating: fill in a form and the builder assembles the JSON. It is a
+ separate tool on its own deployment, shown here so you do not lose your place. You can{' '}
+
+ open it in its own tab
+ {' '}
+ if you would rather have the room, or if the frame below does not load.
+
+
+
+
+
+ The builder writes a policy; it does not evaluate one. Bring what it gives you back to the
+ Evaluate tab, or save it under .tirith/policies and run it against a real plan.
+
+ >
+ );
+}
+
+/**
+ * The evaluate mode: choose a fixture, read the real verdict, take the command.
+ *
+ * Split out of the page body when the builder arrived, so the two modes are
+ * two components rather than one function with a branch buried in its middle.
+ */
+function EvaluateMode({example, select, activeKey}) {
+ return (
+ <>
+
{content.prompts}
+
+
+ {EXAMPLES.map((item) => (
+
+
+
+ ))}
+
+
+
{example.summary}
+
+
+
+ {/*
+ * Stated where a visitor would otherwise assume an evaluation just
+ * ran. Being straight about this is cheaper than the credibility
+ * cost of someone editing the policy, seeing nothing change, and
+ * concluding the tool is broken.
+ */}
+
+ These verdicts are produced by running the real Tirith engine over these fixtures at build
+ time, not by evaluating in your browser, so the page can show you what Tirith
+ returns without a plan of yours ever leaving your machine. To evaluate a policy you have
+ edited, take the command below and run it locally.
+
+
+
+ {snippetFor(example)}
+
+
+
+
+
+ Use this in my repository
+
+
+
+
+ Open in Learn
+
+
+
+ Browse all policies
+
+
+
+ Propose a template improvement
+
+
+
+ >
+ );
+}
+
+export default function Playground() {
+ usePageView(EVENTS.playgroundOpen);
+ const [activeKey, setActiveKey] = useState(EXAMPLES[0].key);
+ const [mode, setMode] = useState('evaluate');
+ const example = EXAMPLES.find((item) => item.key === activeKey) || EXAMPLES[0];
+
+ const select = (key) => {
+ setActiveKey(key);
+ const chosen = EXAMPLES.find((item) => item.key === key);
+ // Only the template id travels, never policy or plan content.
+ capture(EVENTS.templateSelect, {template_id: key});
+ capture(EVENTS.evaluationOutcome, {
+ template_id: key,
+ outcome: outcomeOf(chosen?.result).key,
+ });
+ };
+
+ return (
+
+ {/*
+ * Both hero actions land on the workbench directly below, and set the
+ * mode on the way. The page's whole claim is that a plan plus a policy
+ * produces an explainable verdict, so the primary action names the
+ * thing worth seeing -- a failure -- rather than saying "get started".
+ * Four of the five bundled examples fail, and the failing ones are the
+ * ones that teach anything.
+ */}
+ setMode('evaluate'),
+ },
+ {
+ label: 'Build a policy from a form',
+ href: '#workbench',
+ onClick: () => {
+ setMode('build');
+ capture(EVENTS.builderOpen, {mode: 'embedded', source: 'hero'});
+ },
+ },
+ ]}
+ />
+
+
+
+ {[
+ ['evaluate', 'Evaluate a policy'],
+ ['build', 'Build a policy'],
+ ].map(([key, label]) => (
+
+
+
+ ))}
+
+
+ {mode === 'build' ? : }
+
+
+
+ Most tools have two states: green and red. Tirith has four, because “the check could not
+ run” and “the check ran and said no” call for completely different responses: one pages
+ the platform team, the other pages the change author.
+
+
+
+ Both surfaces fail closed: anything that leaves the verdict unknown exits non-zero
+ regardless of --fail-on-error.{' '}
+ The full exit-code contract.
+
+
+
+
+
+ Load any of these into the workbench above. All of them ship with Tirith and are covered
+ by its test suite, so the verdict you read here is the verdict you get on your own
+ machine.
+
+ [
+ name,
+ example_,
+ level,
+ ,
+ ])}
+ />
+
+ Two collections in the brief have no maintained example behind them yet:{' '}
+ {MISSING_COLLECTIONS.join('; ')}. Add tested examples under{' '}
+ src/tirith/tui/examples/ and rerun{' '}
+ documentation/scripts/generate-fixtures.py. They will appear here and in{' '}
+ tirith ui at the same time. Template provenance (maintainer, last review,
+ Tirith version, tested fixture count) is not yet recorded per example and needs a manifest
+ before community policies are listed alongside maintained ones.
+
+
+
+
+
Nothing, and it is built so that there is nothing to do.
+
+
+ No upload path exists. The verdicts are precomputed and served as
+ static JSON. There is no evaluation endpoint, so there is nowhere for a plan to be sent
+ even by accident.
+
+
+ Analytics records template ids and outcomes only, never policy text,
+ plan content, source or free-form input.
+
+
+ No credentials, no Terraform. The page does not run Terraform or fetch
+ provider credentials; it renders evaluations of supplied plan JSON.
+
+
+
+ If live evaluation of your own plan is added later, it will say what it does before you
+ give it anything, and running the CLI locally will remain the private option.
+
+
+
+
+
+ The policy above is a file. Commit it under .tirith/policies, add one step to
+ the job you already run, and the same verdict shows up on your pull requests.
+
+
+
+
+
+
+
+ );
+}
diff --git a/documentation/src/pages/policies.js b/documentation/src/pages/policies.js
new file mode 100644
index 00000000..becc3241
--- /dev/null
+++ b/documentation/src/pages/policies.js
@@ -0,0 +1,345 @@
+import {useMemo, useState} from 'react';
+import Link from '@docusaurus/Link';
+
+import {EVENTS, capture, track} from '../analytics';
+import {
+ Action,
+ Hero,
+ PageShell,
+ Section,
+ Todo,
+ TrackedCode,
+ Verdict,
+ issueUrl,
+ styles,
+} from '../components/site';
+import fixtures from '../data/fixtures.json';
+import coverage from '../data/coverage.json';
+
+/*
+ * ---------------------------------------------------------------------------
+ * POLICIES
+ *
+ * Two populations of policy, shown in one grid so the relationship between
+ * them is obvious rather than argued:
+ *
+ * Open source -- the worked policies that ship in this repository. Real
+ * files, covered by the test suite, shown with the verdict the engine
+ * actually returns. Anyone can read, copy and run them today.
+ *
+ * Platform -- the check catalogue StackGuardian maintains for its users.
+ * Shown as categories with counts and a description of the territory each
+ * one covers. Individual check IDs, titles and detection logic are
+ * deliberately absent from this page AND from this repository: they are the
+ * substance of the commercial offer, and publishing them would give it away
+ * for nothing. src/data/coverage.json carries numbers only.
+ *
+ * The honest framing matters here. `available` is what the platform scanner
+ * can evaluate from its inputs; it is a catalogue, not a shipped feature list,
+ * and the page says so rather than implying 382 checks run today.
+ * ---------------------------------------------------------------------------
+ */
+
+/*
+ * The starter pack in examples/, not the five worked examples bundled with `tirith ui`. These are
+ * the policies meant to be copied into a real repository, and each has been run twice: against a
+ * fixture built to trip it, and against a clean plan. The second run is the one usually missing
+ * from a policy library -- a rule nobody has watched pass might be firing on everything.
+ */
+const OSS_POLICIES = fixtures.pack.map((entry) => ({
+ id: entry.key,
+ source: 'oss',
+ title: entry.title,
+ summary: '',
+ severity: entry.severity,
+ provider: (entry.provider || '').split('/').pop(),
+ operations: entry.operations || [],
+ checks: entry.evaluatorCount,
+ clean: entry.clean,
+ example: entry,
+}));
+
+const PLATFORM_POLICIES = coverage.categories.map((category) => ({
+ id: category.code,
+ source: 'platform',
+ title: category.name,
+ summary: category.focus,
+ provider: 'repository',
+ available: category.available,
+ catalogue: category.catalogue,
+}));
+
+const ALL = [...OSS_POLICIES, ...PLATFORM_POLICIES];
+
+const FILTERS = [
+ ['all', 'Everything', ALL.length],
+ ['oss', 'Open source', OSS_POLICIES.length],
+ ['platform', 'Platform', PLATFORM_POLICIES.length],
+];
+
+/*
+ * The sign-up destination. Kept as a constant with a TODO rather than guessed:
+ * a wrong sign-up URL on the one page whose job is conversion is worse than a
+ * visible gap.
+ */
+const SIGNUP_URL = null;
+
+function OssCard({policy}) {
+ const {example} = policy;
+ const failing = example.result.final_result === false;
+ return (
+
+
+
+ {/*
+ * Both runs, side by side. Either number alone misleads: a policy only ever seen failing
+ * might be firing on everything, and one only ever seen passing might be matching nothing.
+ */}
+
+
+ exit {example.exitCode} on a plan built to trip it
+
+ {policy.clean ? (
+ <>
+ {' · '}
+
+ exit {policy.clean.exitCode} on a clean plan
+
+ >
+ ) : null}
+
+
+
+ Read the policy and its verdict
+
+ {JSON.stringify(example.policy, null, 2)}
+
+
+
+
+
+ The starter pack gates the plan your pipeline produces. It cannot tell you that
+ a repository has state committed to it, that a workflow applies without a reviewed plan,
+ or that four teams pinned four different provider versions, because none of that is in a
+ plan. Those questions are what the platform catalogue is for.
+
+
+
+
+
{coverage.totals.available}
+
checks available to platform users
+
+
+
{coverage.totals.categories}
+
categories, repository to estate
+
+
+
{coverage.totals.catalogue}
+
checks written in total
+
+
+
{OSS_POLICIES.length}
+
open source, no account
+
+
+
+
+ The gap between {coverage.totals.available} and {coverage.totals.catalogue} is deliberate
+ and worth stating: the remainder need evidence a repository scan cannot reach, such as a plan, a
+ state file, a live cloud account, or a convention only your organisation can define. They
+ are counted here rather than quietly dropped.
+
+
+
+ Two things to settle before this page is public. First, the numbers:{' '}
+ {coverage.totals.available} is a catalogue, not a shipped feature list:
+ roughly {coverage.totals.shippedToday} of these run in the registry today. Either label
+ the figure as coverage-in-progress or advertise the shipped count, and get the wording
+ approved; a visitor who signs up expecting {coverage.totals.available} live checks and
+ finds {coverage.totals.shippedToday} will not come back. Second, the sign-up destination
+ below is unset, so supply the URL.
+
+
+
+
+
+ The open-source policies stay yours: Apache-2.0, no account, evaluated wherever your
+ pipeline runs. Signing up adds the checks a single plan cannot answer, across every
+ repository you own, on a schedule, with the findings in one place.
+
+
+
+ {SIGNUP_URL ? (
+
+ ) : (
+
+ )}
+
+
+
+ {SIGNUP_URL ? null : (
+
+ No sign-up URL is configured, so the primary action routes to the Fleet page, which is
+ a real destination, but one step longer than it should be. Supply the sign-up URL and
+ set SIGNUP_URL in this file.
+
+ )}
+
+
+
+
+ A tested policy is the most useful contribution this project can receive. If your team
+ relies on a rule that is not here, open an issue describing it, including the plan shape
+ it needs to match, so it can be tested rather than assumed.
+
+
+
+
+
+
+ Writing one with a coding agent? The MCP server and skill files give
+ it the closed condition list and let it evaluate a draft against a real plan, instead of
+ handing you JSON that fails at run time.
+
+
+
+ );
+}
diff --git a/documentation/src/pages/traction.js b/documentation/src/pages/traction.js
new file mode 100644
index 00000000..b1150f0f
--- /dev/null
+++ b/documentation/src/pages/traction.js
@@ -0,0 +1,180 @@
+import Link from '@docusaurus/Link';
+
+import {EVENTS, track, usePageView} from '../analytics';
+import {
+ Action,
+ DataTable,
+ Hero,
+ PageShell,
+ REPO,
+ Section,
+ Todo,
+ issueUrl,
+ styles,
+} from '../components/site';
+
+/*
+ * ---------------------------------------------------------------------------
+ * TRACTION
+ *
+ * Job: make OSS momentum independently verifiable, while being candid about
+ * what an accountless, telemetry-free local tool cannot observe.
+ *
+ * Every figure here is a placeholder, on purpose and by decision. The brief
+ * specifies a scheduled server-side job that caches a versioned snapshot to
+ * traction-data.json; that pipeline has not been built, and inventing numbers
+ * on a page whose entire subject is verifiability would be self-defeating.
+ *
+ * The honest half of the page -- what these numbers do and do not measure, and
+ * which metrics must not be published at all -- is real and shippable now.
+ * ---------------------------------------------------------------------------
+ */
+
+const METRICS = [
+ ['[X]', 'GitHub stars'],
+ ['[Y]', 'Forks'],
+ ['[Z]', 'Contributors'],
+ ['[N]', 'Merged community PRs'],
+ ['[P]', 'Maintained policies'],
+ ['[R]', 'Releases'],
+];
+
+const WITHHELD = [
+ ['Plans evaluated', 'Local mode has no telemetry. Any figure would count only connected platform use, and would have to say so.'],
+ ['Repositories governed', 'Same reason. Private adoption is invisible by design, and that is a feature of the tool, not a gap to paper over.'],
+ ['Weekly downloads', 'Unusable until the package-name collision is resolved: `tirith` on PyPI is an unrelated project, so its download count is not ours.'],
+];
+
+export default function Traction() {
+ usePageView(EVENTS.tractionView);
+
+ return (
+
+
+
+
+
+ {METRICS.map(([value, label]) => (
+
+
{value}
+
{label}
+
+ ))}
+
+
+
+
+ See the repository on GitHub
+
+
+
+
+ Every figure above is a placeholder and the page cannot ship with them. The brief
+ specifies the fix: a scheduled job that fetches these server-side with the repository
+ token, commits a versioned traction-data.json carrying each metric's
+ source URL, query, bot exclusions and timestamp, and renders the last good snapshot with{' '}
+ Updated [timestamp], keeping the previous values and showing a compact stale
+ state if a refresh fails, rather than dropping to zero. Do not fetch per page view: it
+ burns rate limit and makes a static site depend on an API being up.
+
+
+
+
+
+ GitHub activity shows attention, contribution and shipping. It does not prove successful
+ production use. Tirith local mode intentionally sends no plan or usage telemetry, so
+ adoption inside private repositories is largely invisible to us. We would rather publish a
+ smaller number we can defend than a larger one we cannot.
+
+
+
Three numbers we will not print
+
+
+
+ Connected StackGuardian figures, where they exist, are labelled separately and never mixed
+ into the public OSS counts.
+
+
+
+
+
+ Four displays are specified and none are built: stars over time{' '}
+ (cumulative daily stargazers, annotated with launch and release dates),{' '}
+ shipping cadence (commits and releases over twelve months, annotating
+ release days rather than rewarding noisy commits), community policy work{' '}
+ (counting only reviewed, tested policies, each linked), and a{' '}
+ contributor wall (opt-out, bots deduplicated, sorted by recency or
+ contribution band, never by popularity). Each needs an accessible data table behind it, a
+ summary that survives JavaScript being disabled, and reduced-motion behaviour.
+
+
+
+
+
+ ADOPTERS.md is opt-in and currently
+ empty, which is the honest state of it. Nobody has been added without asking and nobody
+ will be.
+
+
+ Using Tirith? Add your organisation or project, named or anonymous, with whatever scope
+ you are comfortable sharing. It is a pull request adding one row.
+
+
+
+
+
+ A tested policy, a CI example for a system we do not cover well, a bug reproduction, or a
+ lesson improvement. All four are more useful than a star, though stars are welcome too.
+
+
+
+
+
+
+
+
+ Contribute a policy
+
+
+
+
+ Request a roadmap item
+
+
+
+
+ Report a discrepancy in these numbers
+
+
+
+
+
+ );
+}
diff --git a/examples/README.md b/examples/README.md
new file mode 100644
index 00000000..1972a48f
--- /dev/null
+++ b/examples/README.md
@@ -0,0 +1,171 @@
+# Starter policies
+
+Seven rules that are worth putting in front of a Terraform or OpenTofu pipeline on day one, and a
+sample input for each that trips it. Everything here runs with no cloud account and no network call.
+
+```bash
+pip install "git+https://github.com/StackGuardian/tirith.git@1.2.0"
+
+# a rule against the input built to trip it
+tirith -policy-path policies/03-block-destroy.json \
+ -input-path inputs/03-block-destroy.fails.json --fail-on-error
+# → exit 3, naming aws_db_instance.orders and the action that tripped it
+
+# and the same rule against a plan with nothing wrong with it
+tirith -policy-path policies/03-block-destroy.json \
+ -input-path inputs/00-clean-plan.passes.json --fail-on-error
+# → exit 0
+```
+
+Every policy here has been run both ways. See [Verification](#verification).
+
+### Rules a per-resource engine cannot express
+
+| | Policy | Sev | Reads | Why it needs a whole-plan engine |
+|---|---|---|---|---|
+| 03 | `block-destroy` | critical | `action` | 17 stateful types. A replacement plans as `["delete","create"]`, so this catches the case that reads like an edit |
+| 08 | `provider-version-pinned` | medium | `provider_config` | A fact about the provider block, not any resource. `>= 5.0` is how a provider major arrives on a Monday and rewrites 200 resources |
+| 11 | `prohibited-resource-types` | medium | `count` | `count == 0` is an assertion about the plan. When the answer is "none, correctly", there is no resource to hang a finding on |
+| 04 | `allowed-regions` | medium | `provider_config` | Also a provider-block fact |
+| 05 | `minimum-terraform-version` | low | `terraform_version` | A fact about the run, not the code |
+| 10 | `root-module-size` | low | `count` | A property of the whole module |
+
+### Rules everyone needs, which other tools also do well
+
+Included because a starter pack has to be usable, not because they are differentiators.
+
+| | Policy | Sev | Catches |
+|---|---|---|---|
+| 01 | `required-tags` | low | a resource with no owner — including the blank-string case, which is the one that survives review |
+| 02 | `public-access-blocked` | high | a public access block that closes only some of the four vectors |
+| 06 | `database-safeguards` | high | an unencrypted database, or one deletable by accident |
+| 09 | `no-credentials-in-resolved-values` | critical | a key reaching the plan through a variable default — invisible in the resource block, fully resolved here |
+| 12 | `no-open-ingress` | high | `0.0.0.0/0`, in both the legacy and current rule resources |
+| 07 | `cost-ceiling` | medium | a change that blows the budget (needs an Infracost breakdown) |
+
+## Four things in here that are easy to get wrong
+
+**`03` catches replaces, not just destroys.** Terraform plans a replacement as
+`["delete", "create"]`, so a rule that checks for `delete` catches both. This is the rule that has no
+static-analysis equivalent: nothing in your `.tf` files says *this change will destroy the database*,
+because that depends on what is already running.
+
+**`04` skips rather than fails when it cannot see the region.** Tirith reads
+`expressions.region.constant_value` off the provider block, so a repository writing
+`region = var.region` produces a severity-2 read error. `error_tolerance: 2` turns that into a skip.
+That is the point: **a rule that could not read the value must not report a pass it did not earn.**
+
+**`05` uses `RegexMatch`, and must.** `GreaterThanEqualTo` is a plain Python `>=`, so on version
+strings it compares lexicographically — `"1.9.0" >= "1.11.4"` is `True`, and a naive minimum-version
+rule would pass everything forever. Note also that `terraform_version` is the version that *ran the
+plan*, a runtime fact, not the `required_version` constraint in your source.
+
+**`07` needs a different input.** It reads an Infracost breakdown, not a plan. The GitHub Action
+ignores Infracost in local mode with a warning, so without an Infracost step this rule reports
+nothing — which is honest, and worth knowing before you wonder why it is quiet.
+
+## Two rules that are deliberately absent
+
+**"State must be remote."** Not possible here, and not a gap in Tirith: `terraform show -json`
+contains no backend block at all — `format_version`, `terraform_version`, `variables`,
+`planned_values`, `resource_changes`, `output_changes`, `prior_state`, `configuration`, and nothing
+else. A local-state finding belongs to a scan of the source, which is a different evidence source
+answering a different question. Putting it here would mean inventing a check that silently never fires.
+
+**"No public buckets", as a guarantee.** `02` verifies that the public access blocks you *declared*
+close all four vectors. Proving every bucket *has* one needs the `direct_references` operation, which
+is worth adding — it is the shape of check that static analysis handles worst and Tirith handles best.
+The rule is named for what it actually does rather than what would sell better.
+
+## Error severities, and how to choose `error_tolerance`
+
+Undocumented anywhere else, and the thing most likely to make a rule lie. `error_tolerance` skips read
+problems **at or below** its value; skipped checks leave `eval_expression` entirely.
+
+| Severity | Raised when | Set tolerance to |
+|---|---|---|
+| **0** | `change.after` is `null` — the resource is being **deleted** | anything; a delete has no attributes to read |
+| **1** | the resource type is not in this plan at all | `1`, so a plan without databases skips the database rules |
+| **2** | the attribute is not on the resource | `0` if absence *is* the violation (a missing tag), `2` if the rule only applies where the field exists |
+| **99** | the policy is malformed | never tolerate; fix the policy |
+
+Severity 0 is why no attribute rule can inspect a resource being destroyed — `after` is `null`. That is
+an engine limit, not a policy mistake. Tracked in [ROADMAP.md](../ROADMAP.md).
+
+## Pending engine support
+
+[`policies-pending/`](policies-pending/) holds two rules that **do not run yet** — a blast-radius gate
+and a deletion-protection-removed detector. Each needs a small engine change, both tracked in
+[ROADMAP.md](../ROADMAP.md):
+
+- **blast radius** needs `count` to filter by action. It matches on `terraform_resource_type` only, and
+ Terraform reports unchanged resources as `no-op` entries, so `count(*)` returns the size of the root
+ module rather than the size of the change. `inputs/10-root-module-size.fails.json` is 240 resources
+ of which 238 are no-ops — the number a blast-radius rule wants is 2.
+- **deletion-protection-removed** needs `change.before`. The `attribute` operation reads `change.after`,
+ so a policy can see a value but never a transition — and on a delete, `after` is `null`.
+
+They are staged here so each policy and the change that enables it can be reviewed together, and kept
+out of `policies/` so nothing runs them by accident or counts them as working. `validate.py` enforces
+that separation in both directions.
+
+**If you want either rule, say so on the issue** — a policy someone is waiting for is a better argument
+for an engine change than a maintainer's hunch.
+
+## Verification
+
+Every policy in `policies/` was run against real Tirith at `1.2.0`, in both directions:
+
+| | Result |
+|---|---|
+| 13 policies against their own `*.fails.json` | **13 exit 3** — each fails the way it was designed to |
+| 11 plan policies against `inputs/00-clean-plan.passes.json` | **11 exit 0** — no false positives |
+| `04-allowed-regions` against `04-allowed-regions.skips.json` | **exit 1**, `SKIPPED: region is not found in the provider_config (severity_value: 2)` |
+
+That third row is the one worth reading twice. The region is set from a variable, so the rule cannot
+see it — and it reports *skipped*, not passed. A rule that could not read its input must never look
+like a rule that was satisfied.
+
+Reproduce the whole set:
+
+```bash
+for p in examples/policies/*.json; do
+ n=$(basename "$p" .json)
+ i="examples/inputs/$n.fails.json"
+ [ -f "$i" ] || continue
+ tirith -policy-path "$p" -input-path "$i" --fail-on-error >/dev/null 2>&1
+ echo "$n -> exit $? (3 expected)"
+done
+```
+
+## Checking your own additions
+
+```bash
+python3 examples/validate.py
+```
+
+Run it before opening a pull request that adds a policy.
+
+Confirms every condition and operation exists, that `eval_expression` references only declared ids and
+uses all of them, that `error_tolerance` sits inside `condition` where it belongs, that no numeric
+comparison is pointed at a version string — and that each policy's sample input contains something the
+rule would actually look at.
+
+It also rejects any `provider_args` key the engine does not read — the failure that has no symptom,
+because the handler ignores unknown keys and the rule silently constrains nothing.
+
+It reports expected warnings on `03` and `09`: the sample plans hold only some of the covered types, so
+the rest skip. That is `error_tolerance: 1` doing its job, and seeing it in the output is more useful
+than a clean run.
+
+## Adding a rule
+
+`evaluators` are the checks; `eval_expression` combines them with `&&`, `||` and `!`. Thirteen
+conditions exist: `Equals` `NotEquals` `Contains` `NotContains` `ContainedIn` `NotContainedIn`
+`IsEmpty` `IsNotEmpty` `LessThan` `LessThanEqualTo` `GreaterThan` `GreaterThanEqualTo` `RegexMatch`.
+
+For a plan, seven operations: `attribute` `action` `count` `direct_dependencies` `direct_references`
+`terraform_version` `provider_config`.
+
+Set `meta.severity` — `critical`, `high`, `medium`, `low` — on anything you add. It is what makes a set
+of rules orderable once there are more than a handful.
diff --git a/examples/inputs/00-clean-plan.passes.json b/examples/inputs/00-clean-plan.passes.json
new file mode 100644
index 00000000..ef67c4ff
--- /dev/null
+++ b/examples/inputs/00-clean-plan.passes.json
@@ -0,0 +1,131 @@
+{
+ "format_version": "1.2",
+ "terraform_version": "1.11.4",
+ "resource_changes": [
+ {
+ "address": "aws_s3_bucket.artifacts",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "artifacts",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "acme-artifacts",
+ "tags": {
+ "Owner": "platform-team",
+ "Name": "acme",
+ "Environment": "prod"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket_public_access_block.artifacts",
+ "mode": "managed",
+ "type": "aws_s3_bucket_public_access_block",
+ "name": "artifacts",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "acme-artifacts",
+ "block_public_acls": true,
+ "block_public_policy": true,
+ "ignore_public_acls": true,
+ "restrict_public_buckets": true
+ }
+ }
+ },
+ {
+ "address": "aws_db_instance.orders",
+ "mode": "managed",
+ "type": "aws_db_instance",
+ "name": "orders",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "identifier": "orders",
+ "engine": "postgres",
+ "storage_encrypted": true,
+ "deletion_protection": true,
+ "tags": {
+ "Owner": "platform-team",
+ "Name": "acme",
+ "Environment": "prod"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_vpc_security_group_ingress_rule.api",
+ "mode": "managed",
+ "type": "aws_vpc_security_group_ingress_rule",
+ "name": "api",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "from_port": 443,
+ "to_port": 443,
+ "ip_protocol": "tcp",
+ "cidr_ipv4": "10.0.0.0/8",
+ "tags": {
+ "Owner": "platform-team",
+ "Name": "acme",
+ "Environment": "prod"
+ }
+ }
+ }
+ },
+ {
+ "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.small",
+ "tags": {
+ "Owner": "platform-team",
+ "Name": "acme",
+ "Environment": "prod"
+ },
+ "user_data": "#!/bin/bash\nsystemctl start app\n"
+ }
+ }
+ }
+ ],
+ "configuration": {
+ "provider_config": {
+ "aws": {
+ "name": "aws",
+ "full_name": "registry.terraform.io/hashicorp/aws",
+ "version_constraint": "~> 5.0",
+ "expressions": {
+ "region": {
+ "constant_value": "us-east-1"
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/examples/inputs/01-required-tags.fails.json b/examples/inputs/01-required-tags.fails.json
new file mode 100644
index 00000000..457621f6
--- /dev/null
+++ b/examples/inputs/01-required-tags.fails.json
@@ -0,0 +1,56 @@
+{
+ "format_version": "1.2",
+ "terraform_version": "1.11.4",
+ "resource_changes": [
+ {
+ "address": "aws_s3_bucket.analytics",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "analytics",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "acme-analytics",
+ "tags": {
+ "Name": "analytics",
+ "Owner": ""
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket_versioning.analytics",
+ "mode": "managed",
+ "type": "aws_s3_bucket_versioning",
+ "name": "analytics",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "acme-analytics"
+ }
+ }
+ }
+ ],
+ "configuration": {
+ "provider_config": {
+ "aws": {
+ "name": "aws",
+ "full_name": "registry.terraform.io/hashicorp/aws",
+ "version_constraint": "~> 5.0",
+ "expressions": {
+ "region": {
+ "constant_value": "us-east-1"
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/examples/inputs/02-public-access-blocked.fails.json b/examples/inputs/02-public-access-blocked.fails.json
new file mode 100644
index 00000000..0054b05d
--- /dev/null
+++ b/examples/inputs/02-public-access-blocked.fails.json
@@ -0,0 +1,40 @@
+{
+ "format_version": "1.2",
+ "terraform_version": "1.11.4",
+ "resource_changes": [
+ {
+ "address": "aws_s3_bucket_public_access_block.web",
+ "mode": "managed",
+ "type": "aws_s3_bucket_public_access_block",
+ "name": "web",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "acme-web",
+ "block_public_acls": true,
+ "block_public_policy": false,
+ "ignore_public_acls": true,
+ "restrict_public_buckets": false
+ }
+ }
+ }
+ ],
+ "configuration": {
+ "provider_config": {
+ "aws": {
+ "name": "aws",
+ "full_name": "registry.terraform.io/hashicorp/aws",
+ "version_constraint": "~> 5.0",
+ "expressions": {
+ "region": {
+ "constant_value": "us-east-1"
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/examples/inputs/03-block-destroy.fails.json b/examples/inputs/03-block-destroy.fails.json
new file mode 100644
index 00000000..beb2c1bf
--- /dev/null
+++ b/examples/inputs/03-block-destroy.fails.json
@@ -0,0 +1,40 @@
+{
+ "format_version": "1.2",
+ "terraform_version": "1.11.4",
+ "resource_changes": [
+ {
+ "address": "aws_db_instance.orders",
+ "mode": "managed",
+ "type": "aws_db_instance",
+ "name": "orders",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "delete",
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "identifier": "orders",
+ "engine": "postgres",
+ "storage_encrypted": true,
+ "deletion_protection": true
+ }
+ }
+ }
+ ],
+ "configuration": {
+ "provider_config": {
+ "aws": {
+ "name": "aws",
+ "full_name": "registry.terraform.io/hashicorp/aws",
+ "version_constraint": "~> 5.0",
+ "expressions": {
+ "region": {
+ "constant_value": "us-east-1"
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/examples/inputs/04-allowed-regions.fails.json b/examples/inputs/04-allowed-regions.fails.json
new file mode 100644
index 00000000..1d67f209
--- /dev/null
+++ b/examples/inputs/04-allowed-regions.fails.json
@@ -0,0 +1,39 @@
+{
+ "format_version": "1.2",
+ "terraform_version": "1.11.4",
+ "resource_changes": [
+ {
+ "address": "aws_s3_bucket.eu",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "eu",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "acme-eu",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ }
+ ],
+ "configuration": {
+ "provider_config": {
+ "aws": {
+ "name": "aws",
+ "full_name": "registry.terraform.io/hashicorp/aws",
+ "version_constraint": "~> 5.0",
+ "expressions": {
+ "region": {
+ "constant_value": "ap-southeast-2"
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/examples/inputs/04-allowed-regions.skips.json b/examples/inputs/04-allowed-regions.skips.json
new file mode 100644
index 00000000..b983c461
--- /dev/null
+++ b/examples/inputs/04-allowed-regions.skips.json
@@ -0,0 +1,41 @@
+{
+ "format_version": "1.2",
+ "terraform_version": "1.11.4",
+ "resource_changes": [
+ {
+ "address": "aws_s3_bucket.eu",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "eu",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "acme-eu",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ }
+ ],
+ "configuration": {
+ "provider_config": {
+ "aws": {
+ "name": "aws",
+ "full_name": "registry.terraform.io/hashicorp/aws",
+ "version_constraint": "~> 5.0",
+ "expressions": {
+ "region": {
+ "references": [
+ "var.region"
+ ]
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/examples/inputs/05-minimum-terraform-version.fails.json b/examples/inputs/05-minimum-terraform-version.fails.json
new file mode 100644
index 00000000..3de64246
--- /dev/null
+++ b/examples/inputs/05-minimum-terraform-version.fails.json
@@ -0,0 +1,39 @@
+{
+ "format_version": "1.2",
+ "terraform_version": "1.9.2",
+ "resource_changes": [
+ {
+ "address": "aws_s3_bucket.legacy",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "legacy",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "acme-legacy",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ }
+ ],
+ "configuration": {
+ "provider_config": {
+ "aws": {
+ "name": "aws",
+ "full_name": "registry.terraform.io/hashicorp/aws",
+ "version_constraint": "~> 5.0",
+ "expressions": {
+ "region": {
+ "constant_value": "us-east-1"
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/examples/inputs/06-database-safeguards.fails.json b/examples/inputs/06-database-safeguards.fails.json
new file mode 100644
index 00000000..5e151ee3
--- /dev/null
+++ b/examples/inputs/06-database-safeguards.fails.json
@@ -0,0 +1,39 @@
+{
+ "format_version": "1.2",
+ "terraform_version": "1.11.4",
+ "resource_changes": [
+ {
+ "address": "aws_db_instance.reporting",
+ "mode": "managed",
+ "type": "aws_db_instance",
+ "name": "reporting",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "identifier": "reporting",
+ "engine": "postgres",
+ "storage_encrypted": false,
+ "deletion_protection": false
+ }
+ }
+ }
+ ],
+ "configuration": {
+ "provider_config": {
+ "aws": {
+ "name": "aws",
+ "full_name": "registry.terraform.io/hashicorp/aws",
+ "version_constraint": "~> 5.0",
+ "expressions": {
+ "region": {
+ "constant_value": "us-east-1"
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/examples/inputs/07-cost-ceiling.fails.json b/examples/inputs/07-cost-ceiling.fails.json
new file mode 100644
index 00000000..d365f78c
--- /dev/null
+++ b/examples/inputs/07-cost-ceiling.fails.json
@@ -0,0 +1,25 @@
+{
+ "version": "0.2",
+ "currency": "USD",
+ "projects": [
+ {
+ "name": "acme",
+ "breakdown": {
+ "resources": [
+ {
+ "name": "aws_instance.batch",
+ "resourceType": "aws_instance",
+ "monthlyCost": "612.40"
+ },
+ {
+ "name": "aws_s3_bucket.artifacts",
+ "resourceType": "aws_s3_bucket",
+ "monthlyCost": "1.15"
+ }
+ ],
+ "totalMonthlyCost": "613.55"
+ }
+ }
+ ],
+ "totalMonthlyCost": "613.55"
+}
diff --git a/examples/inputs/08-provider-version-pinned.fails.json b/examples/inputs/08-provider-version-pinned.fails.json
new file mode 100644
index 00000000..5405642d
--- /dev/null
+++ b/examples/inputs/08-provider-version-pinned.fails.json
@@ -0,0 +1,39 @@
+{
+ "format_version": "1.2",
+ "terraform_version": "1.11.4",
+ "resource_changes": [
+ {
+ "address": "aws_s3_bucket.a",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "a",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "a",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ }
+ ],
+ "configuration": {
+ "provider_config": {
+ "aws": {
+ "name": "aws",
+ "full_name": "registry.terraform.io/hashicorp/aws",
+ "version_constraint": ">= 5.0",
+ "expressions": {
+ "region": {
+ "constant_value": "us-east-1"
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/examples/inputs/09-no-credentials-in-resolved-values.fails.json b/examples/inputs/09-no-credentials-in-resolved-values.fails.json
new file mode 100644
index 00000000..529fc459
--- /dev/null
+++ b/examples/inputs/09-no-credentials-in-resolved-values.fails.json
@@ -0,0 +1,40 @@
+{
+ "format_version": "1.2",
+ "terraform_version": "1.11.4",
+ "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.small",
+ "tags": {
+ "Owner": "platform"
+ },
+ "user_data": "#!/bin/bash\nexport AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE\naws s3 sync /data s3://backups\n"
+ }
+ }
+ }
+ ],
+ "configuration": {
+ "provider_config": {
+ "aws": {
+ "name": "aws",
+ "full_name": "registry.terraform.io/hashicorp/aws",
+ "version_constraint": "~> 5.0",
+ "expressions": {
+ "region": {
+ "constant_value": "us-east-1"
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/examples/inputs/10-root-module-size.fails.json b/examples/inputs/10-root-module-size.fails.json
new file mode 100644
index 00000000..6517f2cd
--- /dev/null
+++ b/examples/inputs/10-root-module-size.fails.json
@@ -0,0 +1,4580 @@
+{
+ "format_version": "1.2",
+ "terraform_version": "1.11.4",
+ "resource_changes": [
+ {
+ "address": "aws_s3_bucket.b0",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b0",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b0",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b1",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b1",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b1",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b2",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b2",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b2",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b3",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b3",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b3",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b4",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b4",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b4",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b5",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b5",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b5",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b6",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b6",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b6",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b7",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b7",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b7",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b8",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b8",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b8",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b9",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b9",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b9",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b10",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b10",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b10",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b11",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b11",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b11",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b12",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b12",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b12",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b13",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b13",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b13",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b14",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b14",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b14",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b15",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b15",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b15",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b16",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b16",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b16",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b17",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b17",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b17",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b18",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b18",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b18",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b19",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b19",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b19",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b20",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b20",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b20",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b21",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b21",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b21",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b22",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b22",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b22",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b23",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b23",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b23",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b24",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b24",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b24",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b25",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b25",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b25",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b26",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b26",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b26",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b27",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b27",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b27",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b28",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b28",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b28",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b29",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b29",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b29",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b30",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b30",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b30",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b31",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b31",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b31",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b32",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b32",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b32",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b33",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b33",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b33",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b34",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b34",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b34",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b35",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b35",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b35",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b36",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b36",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b36",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b37",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b37",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b37",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b38",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b38",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b38",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b39",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b39",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b39",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b40",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b40",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b40",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b41",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b41",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b41",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b42",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b42",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b42",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b43",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b43",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b43",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b44",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b44",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b44",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b45",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b45",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b45",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b46",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b46",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b46",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b47",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b47",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b47",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b48",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b48",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b48",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b49",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b49",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b49",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b50",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b50",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b50",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b51",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b51",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b51",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b52",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b52",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b52",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b53",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b53",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b53",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b54",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b54",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b54",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b55",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b55",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b55",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b56",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b56",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b56",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b57",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b57",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b57",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b58",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b58",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b58",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b59",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b59",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b59",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b60",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b60",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b60",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b61",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b61",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b61",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b62",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b62",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b62",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b63",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b63",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b63",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b64",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b64",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b64",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b65",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b65",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b65",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b66",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b66",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b66",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b67",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b67",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b67",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b68",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b68",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b68",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b69",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b69",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b69",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b70",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b70",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b70",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b71",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b71",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b71",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b72",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b72",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b72",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b73",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b73",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b73",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b74",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b74",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b74",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b75",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b75",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b75",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b76",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b76",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b76",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b77",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b77",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b77",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b78",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b78",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b78",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b79",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b79",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b79",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b80",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b80",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b80",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b81",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b81",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b81",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b82",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b82",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b82",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b83",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b83",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b83",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b84",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b84",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b84",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b85",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b85",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b85",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b86",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b86",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b86",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b87",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b87",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b87",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b88",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b88",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b88",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b89",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b89",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b89",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b90",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b90",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b90",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b91",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b91",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b91",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b92",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b92",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b92",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b93",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b93",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b93",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b94",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b94",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b94",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b95",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b95",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b95",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b96",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b96",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b96",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b97",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b97",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b97",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b98",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b98",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b98",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b99",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b99",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b99",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b100",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b100",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b100",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b101",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b101",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b101",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b102",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b102",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b102",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b103",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b103",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b103",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b104",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b104",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b104",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b105",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b105",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b105",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b106",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b106",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b106",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b107",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b107",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b107",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b108",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b108",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b108",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b109",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b109",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b109",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b110",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b110",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b110",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b111",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b111",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b111",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b112",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b112",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b112",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b113",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b113",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b113",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b114",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b114",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b114",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b115",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b115",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b115",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b116",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b116",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b116",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b117",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b117",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b117",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b118",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b118",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b118",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b119",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b119",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b119",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b120",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b120",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b120",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b121",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b121",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b121",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b122",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b122",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b122",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b123",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b123",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b123",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b124",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b124",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b124",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b125",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b125",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b125",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b126",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b126",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b126",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b127",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b127",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b127",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b128",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b128",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b128",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b129",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b129",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b129",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b130",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b130",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b130",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b131",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b131",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b131",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b132",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b132",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b132",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b133",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b133",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b133",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b134",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b134",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b134",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b135",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b135",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b135",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b136",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b136",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b136",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b137",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b137",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b137",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b138",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b138",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b138",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b139",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b139",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b139",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b140",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b140",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b140",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b141",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b141",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b141",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b142",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b142",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b142",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b143",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b143",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b143",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b144",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b144",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b144",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b145",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b145",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b145",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b146",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b146",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b146",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b147",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b147",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b147",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b148",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b148",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b148",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b149",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b149",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b149",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b150",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b150",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b150",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b151",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b151",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b151",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b152",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b152",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b152",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b153",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b153",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b153",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b154",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b154",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b154",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b155",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b155",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b155",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b156",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b156",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b156",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b157",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b157",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b157",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b158",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b158",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b158",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b159",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b159",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b159",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b160",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b160",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b160",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b161",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b161",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b161",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b162",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b162",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b162",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b163",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b163",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b163",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b164",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b164",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b164",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b165",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b165",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b165",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b166",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b166",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b166",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b167",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b167",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b167",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b168",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b168",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b168",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b169",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b169",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b169",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b170",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b170",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b170",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b171",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b171",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b171",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b172",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b172",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b172",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b173",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b173",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b173",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b174",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b174",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b174",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b175",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b175",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b175",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b176",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b176",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b176",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b177",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b177",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b177",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b178",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b178",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b178",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b179",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b179",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b179",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b180",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b180",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b180",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b181",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b181",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b181",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b182",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b182",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b182",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b183",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b183",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b183",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b184",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b184",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b184",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b185",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b185",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b185",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b186",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b186",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b186",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b187",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b187",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b187",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b188",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b188",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b188",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b189",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b189",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b189",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b190",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b190",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b190",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b191",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b191",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b191",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b192",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b192",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b192",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b193",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b193",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b193",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b194",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b194",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b194",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b195",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b195",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b195",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b196",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b196",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b196",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b197",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b197",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b197",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b198",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b198",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b198",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b199",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b199",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b199",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b200",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b200",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b200",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b201",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b201",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b201",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b202",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b202",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b202",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b203",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b203",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b203",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b204",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b204",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b204",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b205",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b205",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b205",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b206",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b206",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b206",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b207",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b207",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b207",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b208",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b208",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b208",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b209",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b209",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b209",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b210",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b210",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b210",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b211",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b211",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b211",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b212",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b212",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b212",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b213",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b213",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b213",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b214",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b214",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b214",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b215",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b215",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b215",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b216",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b216",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b216",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b217",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b217",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b217",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b218",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b218",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b218",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b219",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b219",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b219",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b220",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b220",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b220",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b221",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b221",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b221",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b222",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b222",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b222",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b223",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b223",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b223",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b224",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b224",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b224",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b225",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b225",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b225",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b226",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b226",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b226",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b227",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b227",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b227",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b228",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b228",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b228",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b229",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b229",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b229",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b230",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b230",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b230",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b231",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b231",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b231",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b232",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b232",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b232",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b233",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b233",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b233",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b234",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b234",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b234",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b235",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b235",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b235",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b236",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b236",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b236",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.b237",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "b237",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "no-op"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "b237",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_s3_bucket.new",
+ "mode": "managed",
+ "type": "aws_s3_bucket",
+ "name": "new",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "bucket": "new",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_kms_key.k",
+ "mode": "managed",
+ "type": "aws_kms_key",
+ "name": "k",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "update"
+ ],
+ "before": null,
+ "after": {
+ "description": "k",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ }
+ ],
+ "configuration": {
+ "provider_config": {
+ "aws": {
+ "name": "aws",
+ "full_name": "registry.terraform.io/hashicorp/aws",
+ "version_constraint": "~> 5.0",
+ "expressions": {
+ "region": {
+ "constant_value": "us-east-1"
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/examples/inputs/11-prohibited-resource-types.fails.json b/examples/inputs/11-prohibited-resource-types.fails.json
new file mode 100644
index 00000000..f2c9e594
--- /dev/null
+++ b/examples/inputs/11-prohibited-resource-types.fails.json
@@ -0,0 +1,55 @@
+{
+ "format_version": "1.2",
+ "terraform_version": "1.11.4",
+ "resource_changes": [
+ {
+ "address": "aws_iam_user.ci",
+ "mode": "managed",
+ "type": "aws_iam_user",
+ "name": "ci",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "name": "ci-deploy",
+ "tags": {
+ "Owner": "platform"
+ }
+ }
+ }
+ },
+ {
+ "address": "aws_iam_access_key.ci",
+ "mode": "managed",
+ "type": "aws_iam_access_key",
+ "name": "ci",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "user": "ci-deploy"
+ }
+ }
+ }
+ ],
+ "configuration": {
+ "provider_config": {
+ "aws": {
+ "name": "aws",
+ "full_name": "registry.terraform.io/hashicorp/aws",
+ "version_constraint": "~> 5.0",
+ "expressions": {
+ "region": {
+ "constant_value": "us-east-1"
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/examples/inputs/12-no-open-ingress.fails.json b/examples/inputs/12-no-open-ingress.fails.json
new file mode 100644
index 00000000..68d793e1
--- /dev/null
+++ b/examples/inputs/12-no-open-ingress.fails.json
@@ -0,0 +1,62 @@
+{
+ "format_version": "1.2",
+ "terraform_version": "1.11.4",
+ "resource_changes": [
+ {
+ "address": "aws_security_group_rule.ssh",
+ "mode": "managed",
+ "type": "aws_security_group_rule",
+ "name": "ssh",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "type": "ingress",
+ "from_port": 22,
+ "to_port": 22,
+ "protocol": "tcp",
+ "cidr_blocks": [
+ "10.0.0.0/8",
+ "0.0.0.0/0"
+ ]
+ }
+ }
+ },
+ {
+ "address": "aws_vpc_security_group_ingress_rule.api",
+ "mode": "managed",
+ "type": "aws_vpc_security_group_ingress_rule",
+ "name": "api",
+ "provider_name": "registry.terraform.io/hashicorp/aws",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "before": null,
+ "after": {
+ "from_port": 443,
+ "to_port": 443,
+ "ip_protocol": "tcp",
+ "cidr_ipv4": "0.0.0.0/0"
+ }
+ }
+ }
+ ],
+ "configuration": {
+ "provider_config": {
+ "aws": {
+ "name": "aws",
+ "full_name": "registry.terraform.io/hashicorp/aws",
+ "version_constraint": "~> 5.0",
+ "expressions": {
+ "region": {
+ "constant_value": "us-east-1"
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/examples/inputs/13-scanner-output-is-trustworthy.fails.json b/examples/inputs/13-scanner-output-is-trustworthy.fails.json
new file mode 100644
index 00000000..ac2511d7
--- /dev/null
+++ b/examples/inputs/13-scanner-output-is-trustworthy.fails.json
@@ -0,0 +1,34 @@
+{
+ "summary": {
+ "passed": 0,
+ "failed": 3,
+ "skipped": 1,
+ "parsing_errors": 2,
+ "resource_count": 41
+ },
+ "results": {
+ "failed_checks": [
+ {
+ "check_id": "CKV_AWS_18",
+ "check_name": "Ensure the S3 bucket has access logging enabled",
+ "resource": "aws_s3_bucket.analytics",
+ "file_path": "/main.tf",
+ "severity": "LOW"
+ },
+ {
+ "check_id": "CKV_AWS_21",
+ "check_name": "Ensure all data stored in the S3 bucket have versioning enabled",
+ "resource": "aws_s3_bucket.analytics",
+ "file_path": "/main.tf",
+ "severity": "LOW"
+ },
+ {
+ "check_id": "CKV_AWS_145",
+ "check_name": "Ensure that S3 buckets are encrypted with KMS by default",
+ "resource": "aws_s3_bucket.analytics",
+ "file_path": "/main.tf",
+ "severity": "HIGH"
+ }
+ ]
+ }
+}
\ No newline at end of file
diff --git a/examples/policies-pending/blast-radius.json b/examples/policies-pending/blast-radius.json
new file mode 100644
index 00000000..78d6ba72
--- /dev/null
+++ b/examples/policies-pending/blast-radius.json
@@ -0,0 +1,31 @@
+{
+ "meta": {
+ "version": "v1",
+ "required_provider": "stackguardian/terraform_plan",
+ "name": "PENDING ENGINE SUPPORT -- No single change touches more than 10 resources",
+ "severity": "high"
+ },
+ "evaluators": [
+ {
+ "id": "changes_under_ceiling",
+ "description": "REQUIRES an `actions` filter on the count operation, which Tirith does not have today: count matches on terraform_resource_type only, and Terraform includes unchanged resources in resource_changes as no-op entries, so count(*) returns the size of the root module rather than the size of the change. Tracked in ROADMAP.md. This file is here so the policy and the change that enables it can be reviewed together -- it does NOT run yet, which is why it is not in policies/.",
+ "provider_args": {
+ "operation_type": "count",
+ "terraform_resource_type": "*",
+ "actions": ["create", "update", "delete"]
+ },
+ "condition": { "type": "LessThanEqualTo", "value": 10 }
+ },
+ {
+ "id": "destructions_under_ceiling",
+ "description": "The number that actually matters. Two deletions is a Tuesday; forty is an incident.",
+ "provider_args": {
+ "operation_type": "count",
+ "terraform_resource_type": "*",
+ "actions": ["delete"]
+ },
+ "condition": { "type": "LessThanEqualTo", "value": 2 }
+ }
+ ],
+ "eval_expression": "changes_under_ceiling && destructions_under_ceiling"
+}
diff --git a/examples/policies-pending/deletion-protection-removed.json b/examples/policies-pending/deletion-protection-removed.json
new file mode 100644
index 00000000..01e796b4
--- /dev/null
+++ b/examples/policies-pending/deletion-protection-removed.json
@@ -0,0 +1,22 @@
+{
+ "meta": {
+ "version": "v1",
+ "required_provider": "stackguardian/terraform_plan",
+ "name": "PENDING ENGINE SUPPORT -- Deletion protection is never turned off",
+ "severity": "critical"
+ },
+ "evaluators": [
+ {
+ "id": "protection_not_removed",
+ "description": "REQUIRES reading change.before, which Tirith cannot do: the attribute operation reads change.after only. The dangerous pattern is a transition -- deletion_protection true becoming false, usually in the PR before the one that deletes the thing. after=false alone cannot distinguish 'was already off' from 'someone just turned it off', and only the second is an incident in progress. Tracked in ROADMAP.md. It is the largest single gap in what a policy can currently express.",
+ "provider_args": {
+ "operation_type": "attribute",
+ "attribute_source": "delta",
+ "terraform_resource_type": "*",
+ "terraform_resource_attribute": "deletion_protection"
+ },
+ "condition": { "type": "Equals", "value": { "before": true, "after": false }, "error_tolerance": 2 }
+ }
+ ],
+ "eval_expression": "!protection_not_removed"
+}
diff --git a/examples/policies/01-required-tags.json b/examples/policies/01-required-tags.json
new file mode 100644
index 00000000..02617d5d
--- /dev/null
+++ b/examples/policies/01-required-tags.json
@@ -0,0 +1,42 @@
+{
+ "meta": {
+ "version": "v1",
+ "required_provider": "stackguardian/terraform_plan",
+ "name": "Every taggable resource declares an owner",
+ "severity": "low"
+ },
+ "evaluators": [
+ {
+ "id": "owner_tag_present",
+ "description": "tags.Owner is present and not blank. Sub-resources that AWS does not accept tags on are excluded by name -- without that list every aws_s3_bucket_versioning in the plan fails a rule it cannot satisfy.",
+ "provider_args": {
+ "operation_type": "attribute",
+ "terraform_resource_type": "*",
+ "terraform_resource_attribute": "tags.Owner",
+ "exclude_resource_types": [
+ "aws_s3_bucket_acl",
+ "aws_s3_bucket_lifecycle_configuration",
+ "aws_s3_bucket_policy",
+ "aws_s3_bucket_public_access_block",
+ "aws_s3_bucket_server_side_encryption_configuration",
+ "aws_s3_bucket_versioning",
+ "aws_iam_policy_attachment",
+ "aws_iam_role_policy",
+ "aws_iam_role_policy_attachment",
+ "aws_route_table_association",
+ "aws_security_group_rule",
+ "aws_lambda_permission",
+ "null_resource",
+ "terraform_data",
+ "random_id",
+ "random_password",
+ "random_string"
+ ]
+ },
+ "condition": {
+ "type": "IsNotEmpty"
+ }
+ }
+ ],
+ "eval_expression": "owner_tag_present"
+}
diff --git a/examples/policies/02-public-access-blocked.json b/examples/policies/02-public-access-blocked.json
new file mode 100644
index 00000000..dd973ef0
--- /dev/null
+++ b/examples/policies/02-public-access-blocked.json
@@ -0,0 +1,51 @@
+{
+ "meta": {
+ "version": "v1",
+ "required_provider": "stackguardian/terraform_plan",
+ "name": "S3 public access blocks deny all four vectors",
+ "severity": "high"
+ },
+ "evaluators": [
+ {
+ "id": "block_public_acls",
+ "description": "aws_s3_bucket_public_access_block.block_public_acls is true",
+ "provider_args": {
+ "operation_type": "attribute",
+ "terraform_resource_type": "aws_s3_bucket_public_access_block",
+ "terraform_resource_attribute": "block_public_acls"
+ },
+ "condition": { "type": "Equals", "value": true, "error_tolerance": 1 }
+ },
+ {
+ "id": "block_public_policy",
+ "description": "aws_s3_bucket_public_access_block.block_public_policy is true",
+ "provider_args": {
+ "operation_type": "attribute",
+ "terraform_resource_type": "aws_s3_bucket_public_access_block",
+ "terraform_resource_attribute": "block_public_policy"
+ },
+ "condition": { "type": "Equals", "value": true, "error_tolerance": 1 }
+ },
+ {
+ "id": "ignore_public_acls",
+ "description": "aws_s3_bucket_public_access_block.ignore_public_acls is true",
+ "provider_args": {
+ "operation_type": "attribute",
+ "terraform_resource_type": "aws_s3_bucket_public_access_block",
+ "terraform_resource_attribute": "ignore_public_acls"
+ },
+ "condition": { "type": "Equals", "value": true, "error_tolerance": 1 }
+ },
+ {
+ "id": "restrict_public_buckets",
+ "description": "aws_s3_bucket_public_access_block.restrict_public_buckets is true",
+ "provider_args": {
+ "operation_type": "attribute",
+ "terraform_resource_type": "aws_s3_bucket_public_access_block",
+ "terraform_resource_attribute": "restrict_public_buckets"
+ },
+ "condition": { "type": "Equals", "value": true, "error_tolerance": 1 }
+ }
+ ],
+ "eval_expression": "block_public_acls && block_public_policy && ignore_public_acls && restrict_public_buckets"
+}
diff --git a/examples/policies/03-block-destroy.json b/examples/policies/03-block-destroy.json
new file mode 100644
index 00000000..b62b6c69
--- /dev/null
+++ b/examples/policies/03-block-destroy.json
@@ -0,0 +1,216 @@
+{
+ "meta": {
+ "version": "v1",
+ "required_provider": "stackguardian/terraform_plan",
+ "name": "Stateful resources are never destroyed or replaced",
+ "severity": "critical"
+ },
+ "evaluators": [
+ {
+ "id": "db_instance_kept",
+ "provider_args": {
+ "operation_type": "action",
+ "terraform_resource_type": "aws_db_instance"
+ },
+ "condition": {
+ "type": "NotContains",
+ "value": "delete",
+ "error_tolerance": 1
+ },
+ "description": "No aws_db_instance is deleted. Terraform plans a replacement as [\"delete\",\"create\"], so checking for delete catches replacement too -- which is the case that surprises people, because the diff reads like an edit. error_tolerance 1 on every evaluator so a plan containing none of a given type skips rather than failing."
+ },
+ {
+ "id": "rds_cluster_kept",
+ "provider_args": {
+ "operation_type": "action",
+ "terraform_resource_type": "aws_rds_cluster"
+ },
+ "condition": {
+ "type": "NotContains",
+ "value": "delete",
+ "error_tolerance": 1
+ }
+ },
+ {
+ "id": "dynamodb_table_kept",
+ "provider_args": {
+ "operation_type": "action",
+ "terraform_resource_type": "aws_dynamodb_table"
+ },
+ "condition": {
+ "type": "NotContains",
+ "value": "delete",
+ "error_tolerance": 1
+ }
+ },
+ {
+ "id": "s3_bucket_kept",
+ "provider_args": {
+ "operation_type": "action",
+ "terraform_resource_type": "aws_s3_bucket"
+ },
+ "condition": {
+ "type": "NotContains",
+ "value": "delete",
+ "error_tolerance": 1
+ }
+ },
+ {
+ "id": "efs_kept",
+ "provider_args": {
+ "operation_type": "action",
+ "terraform_resource_type": "aws_efs_file_system"
+ },
+ "condition": {
+ "type": "NotContains",
+ "value": "delete",
+ "error_tolerance": 1
+ }
+ },
+ {
+ "id": "elasticache_kept",
+ "provider_args": {
+ "operation_type": "action",
+ "terraform_resource_type": "aws_elasticache_cluster"
+ },
+ "condition": {
+ "type": "NotContains",
+ "value": "delete",
+ "error_tolerance": 1
+ }
+ },
+ {
+ "id": "elasticache_replication_group_kept",
+ "provider_args": {
+ "operation_type": "action",
+ "terraform_resource_type": "aws_elasticache_replication_group"
+ },
+ "condition": {
+ "type": "NotContains",
+ "value": "delete",
+ "error_tolerance": 1
+ }
+ },
+ {
+ "id": "opensearch_kept",
+ "provider_args": {
+ "operation_type": "action",
+ "terraform_resource_type": "aws_opensearch_domain"
+ },
+ "condition": {
+ "type": "NotContains",
+ "value": "delete",
+ "error_tolerance": 1
+ }
+ },
+ {
+ "id": "redshift_kept",
+ "provider_args": {
+ "operation_type": "action",
+ "terraform_resource_type": "aws_redshift_cluster"
+ },
+ "condition": {
+ "type": "NotContains",
+ "value": "delete",
+ "error_tolerance": 1
+ }
+ },
+ {
+ "id": "neptune_kept",
+ "provider_args": {
+ "operation_type": "action",
+ "terraform_resource_type": "aws_neptune_cluster"
+ },
+ "condition": {
+ "type": "NotContains",
+ "value": "delete",
+ "error_tolerance": 1
+ }
+ },
+ {
+ "id": "docdb_kept",
+ "provider_args": {
+ "operation_type": "action",
+ "terraform_resource_type": "aws_docdb_cluster"
+ },
+ "condition": {
+ "type": "NotContains",
+ "value": "delete",
+ "error_tolerance": 1
+ }
+ },
+ {
+ "id": "ebs_volume_kept",
+ "provider_args": {
+ "operation_type": "action",
+ "terraform_resource_type": "aws_ebs_volume"
+ },
+ "condition": {
+ "type": "NotContains",
+ "value": "delete",
+ "error_tolerance": 1
+ }
+ },
+ {
+ "id": "fsx_lustre_kept",
+ "provider_args": {
+ "operation_type": "action",
+ "terraform_resource_type": "aws_fsx_lustre_file_system"
+ },
+ "condition": {
+ "type": "NotContains",
+ "value": "delete",
+ "error_tolerance": 1
+ }
+ },
+ {
+ "id": "kms_key_kept",
+ "provider_args": {
+ "operation_type": "action",
+ "terraform_resource_type": "aws_kms_key"
+ },
+ "condition": {
+ "type": "NotContains",
+ "value": "delete",
+ "error_tolerance": 1
+ }
+ },
+ {
+ "id": "log_group_kept",
+ "provider_args": {
+ "operation_type": "action",
+ "terraform_resource_type": "aws_cloudwatch_log_group"
+ },
+ "condition": {
+ "type": "NotContains",
+ "value": "delete",
+ "error_tolerance": 1
+ }
+ },
+ {
+ "id": "secret_kept",
+ "provider_args": {
+ "operation_type": "action",
+ "terraform_resource_type": "aws_secretsmanager_secret"
+ },
+ "condition": {
+ "type": "NotContains",
+ "value": "delete",
+ "error_tolerance": 1
+ }
+ },
+ {
+ "id": "efs_backup_kept",
+ "provider_args": {
+ "operation_type": "action",
+ "terraform_resource_type": "aws_backup_vault"
+ },
+ "condition": {
+ "type": "NotContains",
+ "value": "delete",
+ "error_tolerance": 1
+ }
+ }
+ ],
+ "eval_expression": "db_instance_kept && rds_cluster_kept && dynamodb_table_kept && s3_bucket_kept && efs_kept && elasticache_kept && elasticache_replication_group_kept && opensearch_kept && redshift_kept && neptune_kept && docdb_kept && ebs_volume_kept && fsx_lustre_kept && kms_key_kept && log_group_kept && secret_kept && efs_backup_kept"
+}
diff --git a/examples/policies/04-allowed-regions.json b/examples/policies/04-allowed-regions.json
new file mode 100644
index 00000000..6a4af3a8
--- /dev/null
+++ b/examples/policies/04-allowed-regions.json
@@ -0,0 +1,25 @@
+{
+ "meta": {
+ "version": "v1",
+ "required_provider": "stackguardian/terraform_plan",
+ "name": "The AWS provider deploys only to approved regions",
+ "severity": "medium"
+ },
+ "evaluators": [
+ {
+ "id": "aws_region_approved",
+ "description": "provider_config reads the region straight off the provider block. error_tolerance 2 is deliberate: Tirith reads expressions.region.constant_value, so a repository setting region = var.region reports severity 2 and this SKIPS rather than fails -- a rule that cannot see the value must not pretend to have judged it. Severity 1 covers the AWS provider being absent entirely.",
+ "provider_args": {
+ "operation_type": "provider_config",
+ "terraform_provider_full_name": "registry.terraform.io/hashicorp/aws",
+ "attribute": "region"
+ },
+ "condition": {
+ "type": "ContainedIn",
+ "value": ["us-east-1", "eu-central-1"],
+ "error_tolerance": 2
+ }
+ }
+ ],
+ "eval_expression": "aws_region_approved"
+}
diff --git a/examples/policies/05-minimum-terraform-version.json b/examples/policies/05-minimum-terraform-version.json
new file mode 100644
index 00000000..9eee4fab
--- /dev/null
+++ b/examples/policies/05-minimum-terraform-version.json
@@ -0,0 +1,17 @@
+{
+ "meta": {
+ "version": "v1",
+ "required_provider": "stackguardian/terraform_plan",
+ "name": "The plan was produced by Terraform 1.10 or newer",
+ "severity": "low"
+ },
+ "evaluators": [
+ {
+ "id": "version_at_least_1_10",
+ "description": "RegexMatch, NOT GreaterThanEqualTo. Version comparison here is a plain Python >= on strings, which is lexicographic -- '1.9.0' >= '1.11.4' evaluates true and the rule would silently pass everything. The pattern matches 1.10 through 1.99 and any 2.x or later. Note this is the version that RAN the plan, a runtime fact, not the required_version constraint in the source.",
+ "provider_args": { "operation_type": "terraform_version" },
+ "condition": { "type": "RegexMatch", "value": "^(1\\.[1-9][0-9]|[2-9])" }
+ }
+ ],
+ "eval_expression": "version_at_least_1_10"
+}
diff --git a/examples/policies/06-database-safeguards.json b/examples/policies/06-database-safeguards.json
new file mode 100644
index 00000000..7bd95476
--- /dev/null
+++ b/examples/policies/06-database-safeguards.json
@@ -0,0 +1,29 @@
+{
+ "meta": {
+ "version": "v1",
+ "required_provider": "stackguardian/terraform_plan",
+ "name": "Databases are encrypted and protected from deletion",
+ "severity": "high"
+ },
+ "evaluators": [
+ {
+ "id": "deletion_protection_on",
+ "provider_args": {
+ "operation_type": "attribute",
+ "terraform_resource_type": "aws_db_instance",
+ "terraform_resource_attribute": "deletion_protection"
+ },
+ "condition": { "type": "Equals", "value": true, "error_tolerance": 1 }
+ },
+ {
+ "id": "storage_encrypted",
+ "provider_args": {
+ "operation_type": "attribute",
+ "terraform_resource_type": "aws_db_instance",
+ "terraform_resource_attribute": "storage_encrypted"
+ },
+ "condition": { "type": "Equals", "value": true, "error_tolerance": 1 }
+ }
+ ],
+ "eval_expression": "deletion_protection_on && storage_encrypted"
+}
diff --git a/examples/policies/07-cost-ceiling.json b/examples/policies/07-cost-ceiling.json
new file mode 100644
index 00000000..d1f5a1ae
--- /dev/null
+++ b/examples/policies/07-cost-ceiling.json
@@ -0,0 +1,22 @@
+{
+ "meta": {
+ "version": "v1",
+ "required_provider": "stackguardian/infracost",
+ "name": "Planned monthly cost stays under 500 USD -- REQUIRES an Infracost breakdown as input, not a plan",
+ "severity": "medium"
+ },
+ "evaluators": [
+ {
+ "id": "total_under_budget",
+ "description": "Run against `infracost breakdown --format json`, not against plan.json. In the GitHub Action's local mode Infracost is ignored with a warning, so this rule only reports when you generate the breakdown yourself.",
+ "provider_args": { "operation_type": "total_monthly_cost", "resource_type": ["*"] },
+ "condition": { "type": "LessThanEqualTo", "value": 500 }
+ },
+ {
+ "id": "compute_under_budget",
+ "provider_args": { "operation_type": "total_monthly_cost", "resource_type": ["aws_instance"] },
+ "condition": { "type": "LessThanEqualTo", "value": 200 }
+ }
+ ],
+ "eval_expression": "total_under_budget && compute_under_budget"
+}
diff --git a/examples/policies/08-provider-version-pinned.json b/examples/policies/08-provider-version-pinned.json
new file mode 100644
index 00000000..371b848c
--- /dev/null
+++ b/examples/policies/08-provider-version-pinned.json
@@ -0,0 +1,25 @@
+{
+ "meta": {
+ "version": "v1",
+ "required_provider": "stackguardian/terraform_plan",
+ "name": "The AWS provider is pinned to a bounded version range",
+ "severity": "medium"
+ },
+ "evaluators": [
+ {
+ "id": "aws_provider_bounded",
+ "description": "version_constraint must carry an upper bound -- '~> 5.0' or an explicit '< 6.0'. A bare '>= 5.0' lets a major release in on a Monday morning, and provider majors rewrite resource schemas: the plan that follows is full of replacements nobody asked for. This is a fact about the provider block, not about any one resource, which is why a per-resource policy engine has nowhere to put it.",
+ "provider_args": {
+ "operation_type": "provider_config",
+ "terraform_provider_full_name": "registry.terraform.io/hashicorp/aws",
+ "attribute": "version_constraint"
+ },
+ "condition": {
+ "type": "RegexMatch",
+ "value": "(~>|<=|<|==|^=?[0-9])",
+ "error_tolerance": 2
+ }
+ }
+ ],
+ "eval_expression": "aws_provider_bounded"
+}
diff --git a/examples/policies/09-no-credentials-in-resolved-values.json b/examples/policies/09-no-credentials-in-resolved-values.json
new file mode 100644
index 00000000..24e24fde
--- /dev/null
+++ b/examples/policies/09-no-credentials-in-resolved-values.json
@@ -0,0 +1,49 @@
+{
+ "meta": {
+ "version": "v1",
+ "required_provider": "stackguardian/terraform_plan",
+ "name": "No credential material in resolved plan values",
+ "severity": "critical"
+ },
+ "evaluators": [
+ {
+ "id": "key_in_user_data",
+ "description": "Detector, inverted in eval_expression. This is the case a scan of the source cannot reach: a key written literally into a .tf file is easy to catch, but one arriving through a variable default, a locals lookup or a tfvars file is invisible in the resource block and fully resolved in the plan. error_tolerance 2 because most plans have no user_data at all -- severity 2 is 'attribute not found', and a rule that found nothing to read must skip, not pass.",
+ "provider_args": {
+ "operation_type": "attribute",
+ "terraform_resource_type": "*",
+ "terraform_resource_attribute": "user_data"
+ },
+ "condition": { "type": "RegexMatch", "value": "(AKIA|ASIA)[0-9A-Z]{16}", "error_tolerance": 2 }
+ },
+ {
+ "id": "private_key_in_user_data",
+ "provider_args": {
+ "operation_type": "attribute",
+ "terraform_resource_type": "*",
+ "terraform_resource_attribute": "user_data"
+ },
+ "condition": { "type": "RegexMatch", "value": "BEGIN [A-Z ]*PRIVATE KEY", "error_tolerance": 2 }
+ },
+ {
+ "id": "key_in_container_definitions",
+ "description": "container_definitions is a JSON string in the plan. RegexMatch stringifies whatever it is handed, so the whole nested document is searchable without knowing its shape.",
+ "provider_args": {
+ "operation_type": "attribute",
+ "terraform_resource_type": "aws_ecs_task_definition",
+ "terraform_resource_attribute": "container_definitions"
+ },
+ "condition": { "type": "RegexMatch", "value": "(AKIA|ASIA)[0-9A-Z]{16}", "error_tolerance": 2 }
+ },
+ {
+ "id": "key_in_lambda_environment",
+ "provider_args": {
+ "operation_type": "attribute",
+ "terraform_resource_type": "aws_lambda_function",
+ "terraform_resource_attribute": "environment"
+ },
+ "condition": { "type": "RegexMatch", "value": "(AKIA|ASIA)[0-9A-Z]{16}", "error_tolerance": 2 }
+ }
+ ],
+ "eval_expression": "!key_in_user_data && !private_key_in_user_data && !key_in_container_definitions && !key_in_lambda_environment"
+}
diff --git a/examples/policies/10-root-module-size.json b/examples/policies/10-root-module-size.json
new file mode 100644
index 00000000..56f9544d
--- /dev/null
+++ b/examples/policies/10-root-module-size.json
@@ -0,0 +1,20 @@
+{
+ "meta": {
+ "version": "v1",
+ "required_provider": "stackguardian/terraform_plan",
+ "name": "This root module holds no more than 200 resources",
+ "severity": "low"
+ },
+ "evaluators": [
+ {
+ "id": "under_two_hundred",
+ "description": "Named for what it measures. `count` counts every entry in resource_changes, and Terraform includes unchanged resources as no-op entries -- so this is the SIZE OF THE ROOT MODULE, not the size of the change. A ceiling on module size is still worth having: a 900-resource root module means every plan is slow, every apply is high-stakes, and every lock contends. For a ceiling on what a change TOUCHES, see policies-pending/blast-radius.json.",
+ "provider_args": {
+ "operation_type": "count",
+ "terraform_resource_type": "*"
+ },
+ "condition": { "type": "LessThanEqualTo", "value": 200 }
+ }
+ ],
+ "eval_expression": "under_two_hundred"
+}
diff --git a/examples/policies/11-prohibited-resource-types.json b/examples/policies/11-prohibited-resource-types.json
new file mode 100644
index 00000000..3515ea75
--- /dev/null
+++ b/examples/policies/11-prohibited-resource-types.json
@@ -0,0 +1,33 @@
+{
+ "meta": {
+ "version": "v1",
+ "required_provider": "stackguardian/terraform_plan",
+ "name": "Resource types the organisation has retired do not appear",
+ "severity": "medium"
+ },
+ "evaluators": [
+ {
+ "id": "no_iam_users",
+ "description": "count == 0 is an assertion about the whole plan rather than about any resource in it -- a shape a per-resource policy engine has nowhere to put, because there is no resource to attach the finding to when the answer is 'none, correctly'. Long-lived IAM users are the standard example: roles and OIDC federation replaced them, and the rule that keeps them gone has to be able to say 'zero'.",
+ "provider_args": { "operation_type": "count", "terraform_resource_type": "aws_iam_user" },
+ "condition": { "type": "Equals", "value": 0 }
+ },
+ {
+ "id": "no_iam_access_keys",
+ "provider_args": { "operation_type": "count", "terraform_resource_type": "aws_iam_access_key" },
+ "condition": { "type": "Equals", "value": 0 }
+ },
+ {
+ "id": "no_inline_bucket_objects",
+ "description": "aws_s3_bucket_object was deprecated in favour of aws_s3_object.",
+ "provider_args": { "operation_type": "count", "terraform_resource_type": "aws_s3_bucket_object" },
+ "condition": { "type": "Equals", "value": 0 }
+ },
+ {
+ "id": "no_default_vpc_adoption",
+ "provider_args": { "operation_type": "count", "terraform_resource_type": "aws_default_vpc" },
+ "condition": { "type": "Equals", "value": 0 }
+ }
+ ],
+ "eval_expression": "no_iam_users && no_iam_access_keys && no_inline_bucket_objects && no_default_vpc_adoption"
+}
diff --git a/examples/policies/12-no-open-ingress.json b/examples/policies/12-no-open-ingress.json
new file mode 100644
index 00000000..34d80ef1
--- /dev/null
+++ b/examples/policies/12-no-open-ingress.json
@@ -0,0 +1,30 @@
+{
+ "meta": {
+ "version": "v1",
+ "required_provider": "stackguardian/terraform_plan",
+ "name": "Security group rules do not open to the whole internet",
+ "severity": "high"
+ },
+ "evaluators": [
+ {
+ "id": "legacy_rule_not_open",
+ "provider_args": {
+ "operation_type": "attribute",
+ "terraform_resource_type": "aws_security_group_rule",
+ "terraform_resource_attribute": "cidr_blocks"
+ },
+ "condition": { "type": "NotContains", "value": "0.0.0.0/0", "error_tolerance": 1 }
+ },
+ {
+ "id": "vpc_ingress_rule_not_open",
+ "description": "aws_vpc_security_group_ingress_rule is the current resource and takes a single cidr_ipv4 string rather than a list.",
+ "provider_args": {
+ "operation_type": "attribute",
+ "terraform_resource_type": "aws_vpc_security_group_ingress_rule",
+ "terraform_resource_attribute": "cidr_ipv4"
+ },
+ "condition": { "type": "NotEquals", "value": "0.0.0.0/0", "error_tolerance": 1 }
+ }
+ ],
+ "eval_expression": "legacy_rule_not_open && vpc_ingress_rule_not_open"
+}
diff --git a/examples/policies/13-scanner-output-is-trustworthy.json b/examples/policies/13-scanner-output-is-trustworthy.json
new file mode 100644
index 00000000..6512ddfa
--- /dev/null
+++ b/examples/policies/13-scanner-output-is-trustworthy.json
@@ -0,0 +1,28 @@
+{
+ "meta": {
+ "version": "v1",
+ "required_provider": "stackguardian/json",
+ "name": "The resource scanner ran completely and reported nothing failing",
+ "severity": "high"
+ },
+ "evaluators": [
+ {
+ "id": "scanner_parsed_everything",
+ "description": "Run against your resource scanner's own JSON output, not against a plan. This first check is the reason the policy exists: a scanner that silently failed to parse half the repository reports a clean run, and almost nobody asserts otherwise. Governing another tool's output with the same policy language, the same severity vocabulary and the same exit codes means four tools can behave like one gate.",
+ "provider_args": { "operation_type": "get_value", "key_path": "summary.parsing_errors" },
+ "condition": { "type": "Equals", "value": 0 }
+ },
+ {
+ "id": "no_failing_checks",
+ "provider_args": { "operation_type": "get_value", "key_path": "summary.failed" },
+ "condition": { "type": "LessThanEqualTo", "value": 0 }
+ },
+ {
+ "id": "scanner_actually_checked_something",
+ "description": "A run that evaluated nothing at all passes every other assertion here.",
+ "provider_args": { "operation_type": "get_value", "key_path": "summary.passed" },
+ "condition": { "type": "GreaterThan", "value": 0 }
+ }
+ ],
+ "eval_expression": "scanner_parsed_everything && no_failing_checks && scanner_actually_checked_something"
+}
diff --git a/examples/validate.py b/examples/validate.py
new file mode 100644
index 00000000..900675f6
--- /dev/null
+++ b/examples/validate.py
@@ -0,0 +1,186 @@
+#!/usr/bin/env python3
+"""Structural check for the starter pack, against the grammar read out of Tirith's source.
+
+Tirith itself is not installed here, so this cannot tell you a policy produces the verdict you
+wanted. It can tell you a policy is well-formed, that every condition and operation actually
+exists, and that each rule's sample input contains something the rule would look at -- which is
+where a hand-written policy usually goes wrong: a rule that matches nothing reports nothing and
+looks exactly like a rule that passed.
+
+ python3 examples/validate.py
+"""
+import json, os, re, sys
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+POLICIES = os.path.join(HERE, 'policies')
+PENDING = os.path.join(HERE, 'policies-pending')
+INPUTS = os.path.join(HERE, 'inputs')
+
+# src/tirith/core/evaluators/__init__.py
+CONDITIONS = {'ContainedIn','Contains','Equals','GreaterThan','GreaterThanEqualTo','IsEmpty',
+ 'IsNotEmpty','LessThan','LessThanEqualTo','NotContainedIn','NotContains',
+ 'NotEquals','RegexMatch'}
+VALUELESS = {'IsEmpty','IsNotEmpty'}
+# src/tirith/providers/terraform_plan/handler.py
+PLAN_OPS = {'attribute','action','count','direct_dependencies','direct_references',
+ 'terraform_version','provider_config'}
+PROVIDER_CONFIG_ATTRS = {'version_constraint','region'}
+# Every provider_args key the handler actually reads. Anything else is silently
+# ignored at run time, which is the worst failure mode there is: the policy looks
+# like it constrains something and does not. Keys proposed but NOT yet supported
+# live in ENGINE_PENDING and are only legal under policies-pending/.
+PLAN_ARGS = {'operation_type','terraform_resource_type','terraform_resource_attribute',
+ 'exclude_resource_types','terraform_provider_full_name','attribute'}
+ENGINE_PENDING = {'actions','attribute_source','action_reason','module_address',
+ 'sensitive','index'}
+# providers/terraform_plan/handler.py, for choosing error_tolerance
+SEVERITY_NOTES = {0:'change.after is null (the resource is being deleted)',
+ 1:'resource type not present in the plan',
+ 2:'attribute not present on the resource'}
+PROVIDERS = {'stackguardian/terraform_plan','stackguardian/infracost',
+ 'stackguardian/kubernetes','stackguardian/json','stackguardian/sg_workflow'}
+SEVERITIES = {'critical','high','medium','low'}
+
+errors, warnings = [], []
+def err(f, m): errors.append(f'{f}: {m}')
+def warn(f, m): warnings.append(f'{f}: {m}')
+
+def check_policy(path, pending=False):
+ f = os.path.basename(path)
+ try:
+ p = json.load(open(path))
+ except Exception as e:
+ return err(f, f'not valid JSON -- {e}')
+
+ meta = p.get('meta') or {}
+ if meta.get('version') != 'v1':
+ err(f, f'meta.version is {meta.get("version")!r}, expected "v1"')
+ prov = meta.get('required_provider')
+ if prov not in PROVIDERS:
+ err(f, f'meta.required_provider {prov!r} is not a known provider')
+ if not meta.get('name'):
+ err(f, 'meta.name missing -- it is what the verdict shows the reader')
+ if meta.get('severity') not in SEVERITIES:
+ err(f, f'meta.severity {meta.get("severity")!r} not in {sorted(SEVERITIES)}')
+ if pending and 'PENDING' not in (meta.get('name') or ''):
+ err(f, 'a policy under policies-pending/ must say PENDING in meta.name -- '
+ 'it does not run, and nothing should be able to mistake it for a rule that does')
+
+ evs = p.get('evaluators') or []
+ if not evs:
+ return err(f, 'no evaluators')
+ ids = []
+ for e in evs:
+ i = e.get('id')
+ if not i:
+ err(f, 'an evaluator has no id'); continue
+ if i in ids:
+ err(f, f'duplicate evaluator id {i!r}')
+ ids.append(i)
+
+ pa = e.get('provider_args') or {}
+ op = pa.get('operation_type')
+ if prov == 'stackguardian/terraform_plan':
+ if op not in PLAN_OPS:
+ err(f, f'{i}: operation_type {op!r} not in {sorted(PLAN_OPS)}')
+ if op == 'attribute':
+ for k in ('terraform_resource_type','terraform_resource_attribute'):
+ if k not in pa: err(f, f'{i}: {op} needs {k}')
+ if op in ('action','count','direct_dependencies','direct_references'):
+ if 'terraform_resource_type' not in pa:
+ err(f, f'{i}: {op} needs terraform_resource_type')
+ if op == 'provider_config':
+ if 'terraform_provider_full_name' not in pa:
+ err(f, f'{i}: provider_config needs terraform_provider_full_name')
+ if pa.get('attribute') not in PROVIDER_CONFIG_ATTRS:
+ err(f, f'{i}: provider_config attribute must be one of {sorted(PROVIDER_CONFIG_ATTRS)}')
+ if 'exclude_resource_types' in pa and pa.get('terraform_resource_type') != '*':
+ warn(f, f'{i}: exclude_resource_types is only honoured when terraform_resource_type is "*"')
+ unknown = set(pa) - PLAN_ARGS
+ proposed = unknown & ENGINE_PENDING
+ if proposed and not pending:
+ err(f, f'{i}: provider_args {sorted(proposed)} are NOT supported by the engine -- '
+ f'the handler ignores unknown keys, so this rule would silently constrain '
+ f'nothing. Move it to policies-pending/ until the patch lands')
+ if pending and not proposed:
+ warn(f, f'{i}: nothing here needs an engine change, so it belongs in policies/')
+ for u in sorted(unknown - ENGINE_PENDING):
+ err(f, f'{i}: provider_args {u!r} is not read by the handler and is not a known proposal')
+
+ c = e.get('condition') or {}
+ t = c.get('type')
+ if t not in CONDITIONS:
+ err(f, f'{i}: condition {t!r} does not exist (have: {", ".join(sorted(CONDITIONS))})')
+ elif t in VALUELESS and 'value' in c:
+ err(f, f'{i}: {t} takes no value')
+ elif t not in VALUELESS and 'value' not in c:
+ err(f, f'{i}: {t} needs a value')
+ if t in ('GreaterThanEqualTo','GreaterThan','LessThan','LessThanEqualTo') \
+ and isinstance(c.get('value'), str):
+ err(f, f'{i}: {t} against the string {c["value"]!r} compares lexicographically '
+ f'("1.9.0" >= "1.11.4" is true) -- use RegexMatch')
+ if 'error_tolerance' in c and not (isinstance(c['error_tolerance'], int)
+ and c['error_tolerance'] >= 0):
+ err(f, f'{i}: error_tolerance must be an integer >= 0')
+ for k in ('error_tolerance',):
+ if k in e:
+ err(f, f'{i}: {k} belongs inside condition, not on the evaluator')
+
+ expr = p.get('eval_expression')
+ if not expr:
+ return err(f, 'no eval_expression')
+ referenced = set(re.findall(r'[A-Za-z_][A-Za-z0-9_]*', expr))
+ for r in referenced - set(ids):
+ err(f, f'eval_expression references {r!r}, which is not a declared evaluator id')
+ for i in set(ids) - referenced:
+ err(f, f'evaluator {i!r} is never used in eval_expression, so it can never fail the policy')
+ return p
+
+def check_reachability(f, p):
+ """Would this policy see anything at all in its sample input?"""
+ stem = f[:-5]
+ cands = [c for c in os.listdir(INPUTS) if c.startswith(stem)]
+ if not cands:
+ return warn(f, 'no sample input -- the pack promises one per policy')
+ if p['meta']['required_provider'] != 'stackguardian/terraform_plan':
+ return
+ for c in sorted(cands):
+ if '.skips.' in c:
+ continue
+ doc = json.load(open(os.path.join(INPUTS, c)))
+ present = {rc['type'] for rc in doc.get('resource_changes', [])}
+ for e in p['evaluators']:
+ pa = e['provider_args']
+ want = pa.get('terraform_resource_type')
+ if pa['operation_type'] in ('attribute','action') and want not in ('*', None):
+ if want not in present:
+ warn(f, f'{c}: no {want} in resource_changes, so {e["id"]!r} '
+ f'skips rather than reporting (error_tolerance '
+ f'{e["condition"].get("error_tolerance","unset")})')
+
+parsed = {}
+for name in sorted(os.listdir(POLICIES)):
+ if name.endswith('.json'):
+ p = check_policy(os.path.join(POLICIES, name))
+ if p: parsed[name] = p
+for name, p in parsed.items():
+ check_reachability(name, p)
+
+pending = 0
+if os.path.isdir(PENDING):
+ for name in sorted(os.listdir(PENDING)):
+ if name.endswith('.json'):
+ pending += 1
+ check_policy(os.path.join(PENDING, name), pending=True)
+
+for name in sorted(os.listdir(INPUTS)):
+ try: json.load(open(os.path.join(INPUTS, name)))
+ except Exception as e: err(name, f'sample input is not valid JSON -- {e}')
+
+print(f'{len(parsed)} live policies, {pending} pending engine support, '
+ f'{len(os.listdir(INPUTS))} sample inputs\n')
+for w in warnings: print(' warn ' + w)
+for e in errors: print(' FAIL ' + e)
+print('\n' + ('%d error(s)' % len(errors) if errors
+ else 'ok -- every policy is well-formed and its sample input reaches it'))
+sys.exit(1 if errors else 0)
diff --git a/setup.py b/setup.py
index 6dc8be09..19ff0a04 100644
--- a/setup.py
+++ b/setup.py
@@ -95,6 +95,17 @@ def read(*names, **kwargs):
'textual>=8.0; python_version >= "3.9"',
'textual-serve>=1.0; python_version >= "3.9"',
],
+ # `pip install py-tirith[mcp]` adds the MCP server (`tirith mcp`), which lets a coding
+ # agent evaluate, lint and explain policies against the real engine instead of guessing
+ # at the schema.
+ #
+ # An extra for the same two reasons as `tui`. The SDK requires Python >=3.10 while tirith
+ # supports >=3.8, so the environment marker lets 3.8 and 3.9 users install the extra and
+ # get nothing rather than an error -- mcp/cli.py then reports the missing extra. And a CI
+ # gate should not pay install time for a server it never starts.
+ "mcp": [
+ 'mcp>=1.2; python_version >= "3.10"',
+ ],
},
setup_requires=[
"pytest-runner",
diff --git a/src/tirith/cli.py b/src/tirith/cli.py
index f75c9417..80cc408d 100755
--- a/src/tirith/cli.py
+++ b/src/tirith/cli.py
@@ -46,7 +46,12 @@ def eprint(*args, **kwargs):
# 3.9 and tirith supports 3.8 -- so tui/cli.py reports the missing extra rather than failing on
# an import here.
UI_SUBCOMMAND = "ui"
-SUBCOMMANDS = {SUBCOMMAND, UI_SUBCOMMAND}
+
+# `mcp` joins them on the same terms: dispatched before the flat parser, and an optional extra --
+# the MCP SDK needs Python 3.10 while tirith supports 3.8 -- so mcp/cli.py reports the missing
+# extra rather than failing on an import here.
+MCP_SUBCOMMAND = "mcp"
+SUBCOMMANDS = {SUBCOMMAND, UI_SUBCOMMAND, MCP_SUBCOMMAND}
def main(args=None) -> ExitStatus:
@@ -65,6 +70,11 @@ def main(args=None) -> ExitStatus:
return tui_cli.main(argv)
+ if argv and argv[0] == MCP_SUBCOMMAND:
+ from tirith.mcp import cli as mcp_cli
+
+ return mcp_cli.main(argv)
+
if argv and argv[0] in SUBCOMMANDS:
from tirith.platform import cli as platform_cli
@@ -88,6 +98,9 @@ def __init__(self, prog="PROG") -> None:
tirith ui --help Explore results, build policies and experiment in
an interactive interface. Needs the 'tui' extra.
+ tirith mcp --help Serve Tirith to a coding agent over MCP: evaluate,
+ lint and explain policies. Needs the 'mcp' extra.
+
About Tirith:
* Abstract away the implementation complexity of policy engine underneath.
diff --git a/src/tirith/mcp/__init__.py b/src/tirith/mcp/__init__.py
new file mode 100644
index 00000000..9ba81f78
--- /dev/null
+++ b/src/tirith/mcp/__init__.py
@@ -0,0 +1,37 @@
+"""
+Model Context Protocol server for Tirith: let a coding agent author, evaluate and explain
+policies without leaving the editor.
+
+Optional, on the same terms as the `tui` extra. Everything that needs the MCP SDK lives in
+.server, imported lazily by run() rather than here, so that:
+
+ * `import tirith.mcp` works without the extra installed -- which is what lets the tool-shape
+ tests run on CI's Python 3.8 leg, where the SDK cannot be installed at all (it requires
+ >=3.10, tirith supports >=3.8); and
+ * a user who installed plain `py-tirith` gets an actionable message instead of an ImportError
+ traceback.
+
+Why an MCP server at all: an agent asked to "add a policy requiring an Owner tag" will otherwise
+guess at the schema, invent condition types that do not exist, and hand back JSON that fails at
+evaluation time -- which the human then debugs. These tools replace guessing with the real
+schema, the real provider operations, and a real verdict from the real engine.
+"""
+
+MCP_EXTRA_HINT = (
+ "The Tirith MCP server needs the optional 'mcp' extra:\n"
+ " pip install 'py-tirith[mcp] @ git+https://github.com/StackGuardian/tirith.git'\n"
+ "It is optional so that using tirith as a CI gate stays dependency-light. It needs "
+ "Python 3.10 or newer; tirith itself supports 3.8."
+)
+
+
+def run(argv=None):
+ """
+ Entry point for `tirith mcp`. Imports the server lazily; see the module docstring.
+
+ :param argv: Arguments after the `mcp` subcommand.
+ :return: An ExitStatus.
+ """
+ from .cli import main
+
+ return main(argv or [])
diff --git a/src/tirith/mcp/cli.py b/src/tirith/mcp/cli.py
new file mode 100644
index 00000000..d91be813
--- /dev/null
+++ b/src/tirith/mcp/cli.py
@@ -0,0 +1,56 @@
+"""
+`tirith mcp` -- argument parsing and launch.
+
+Kept separate from server.py so that argument errors and the missing-extra message are reported
+without importing the MCP SDK at all, which matters because the commonest reason to reach this
+file is not having the extra installed.
+"""
+
+import argparse
+import sys
+
+from .. import __version__
+from ..status import ExitStatus
+from . import MCP_EXTRA_HINT
+
+
+def _is_missing_sdk(error):
+ """
+ Whether an ImportError is the optional extra being absent, rather than a real fault.
+
+ `ImportError.name` is the module that could not be found, so this distinguishes "mcp is not
+ installed" from "server.py imports a symbol that no longer exists" -- which arrives as the
+ same exception type from the same import statement.
+ """
+ module = getattr(error, "name", None) or ""
+ return module.split(".")[0] == "mcp"
+
+
+def main(argv=None) -> ExitStatus:
+ parser = argparse.ArgumentParser(
+ prog="tirith mcp",
+ description=(
+ "Run Tirith as a Model Context Protocol server, so a coding agent can evaluate "
+ "policies, lint them, look up the schema and explain a result."
+ ),
+ epilog=(
+ "Speaks MCP over stdio; it is started by your editor or agent, not run by hand. "
+ "It makes no network calls and writes nothing to disk."
+ ),
+ )
+ parser.add_argument("--version", action="version", version=__version__)
+ parser.parse_args(list(argv or [])[1:])
+
+ try:
+ from .server import serve
+ except ImportError as error:
+ if _is_missing_sdk(error):
+ print(MCP_EXTRA_HINT, file=sys.stderr)
+ return ExitStatus.ERROR
+ raise
+
+ try:
+ serve()
+ except KeyboardInterrupt:
+ return ExitStatus.ERROR_CTRL_C
+ return ExitStatus.SUCCESS
diff --git a/src/tirith/mcp/server.py b/src/tirith/mcp/server.py
new file mode 100644
index 00000000..459af35e
--- /dev/null
+++ b/src/tirith/mcp/server.py
@@ -0,0 +1,93 @@
+"""
+The MCP protocol layer.
+
+Thin on purpose: every tool's behaviour lives in tools.py, which imports nothing from the SDK.
+This file only describes those functions to a client and adapts their return values. Keeping the
+split means the logic is testable on Python 3.8, where the SDK cannot be installed.
+
+Transport is stdio. The server reads a policy and a document it is given and returns a verdict;
+it opens no sockets, makes no network calls and writes nothing to disk, so pointing an agent at
+it cannot change anyone's infrastructure.
+"""
+
+import json
+
+from . import tools
+
+# The SDK renamed its server class between generations: 1.x exposes
+# `mcp.server.fastmcp.FastMCP`, 2.x exposes `mcp.server.mcpserver.MCPServer`. Both keep the same
+# `.tool()` decorator and the same `.run(transport=...)`, so supporting each is an import shim
+# rather than two code paths -- and worth doing, because which one a user has installed is not
+# something this project gets to choose.
+try: # SDK 2.x
+ from mcp.server.mcpserver import MCPServer as _Server
+except ImportError: # pragma: no cover - depends on which SDK generation is installed
+ from mcp.server.fastmcp import FastMCP as _Server # SDK 1.x
+
+mcp = _Server("tirith")
+
+
+def _json(payload):
+ return json.dumps(payload, indent=2, default=str)
+
+
+@mcp.tool()
+def evaluate(policy: dict, document: dict, variables: dict = None) -> str:
+ """
+ Run a Tirith policy against an input document and return the real verdict.
+
+ Use this instead of reasoning about whether a policy is correct. A policy that matches
+ nothing looks identical to one that works until it is evaluated, and the result distinguishes
+ passed, failed and unevaluated -- where unevaluated is NOT a pass.
+
+ :param policy: The policy, as a JSON object.
+ :param document: The document to evaluate: a terraform plan, state, Infracost breakdown,
+ Kubernetes manifest or arbitrary JSON, matching the policy's provider.
+ :param variables: Optional values for {{ var.x }} placeholders in the policy.
+ """
+ return _json(tools.evaluate(policy, document, variables))
+
+
+@mcp.tool()
+def lint_policy(policy: dict) -> str:
+ """
+ Check a policy's shape before running it, and report what would go wrong.
+
+ Catches an unknown condition type, a missing eval_expression, and evaluators the expression
+ never references. Worth calling on every policy you write or edit: the engine reports several
+ of these mistakes as an ordinary failed check, which reads as a real infrastructure violation.
+
+ :param policy: The policy, as a JSON object.
+ """
+ return _json(tools.lint_policy(policy))
+
+
+@mcp.tool()
+def describe_provider(provider: str = None) -> str:
+ """
+ List the providers a policy can read, the operation_type values each accepts, and every
+ available condition type.
+
+ Call this before writing a policy rather than guessing at the vocabulary. Omit the argument
+ for all providers, or name one for its documentation link.
+
+ :param provider: Optional provider name, e.g. 'terraform_plan'.
+ """
+ return _json(tools.describe_provider(provider))
+
+
+@mcp.tool()
+def explain_result(result: dict) -> str:
+ """
+ Turn a Tirith result document into which rule failed, on which resource, and why.
+
+ Use this on the JSON from `tirith --json` or from a CI artefact to explain a red build.
+
+ :param result: A Tirith result document.
+ """
+ return _json(tools.explain_result(result))
+
+
+def serve():
+ """Run the server on stdio until the client disconnects."""
+ mcp.run(transport="stdio")
diff --git a/src/tirith/mcp/tools.py b/src/tirith/mcp/tools.py
new file mode 100644
index 00000000..a7edf107
--- /dev/null
+++ b/src/tirith/mcp/tools.py
@@ -0,0 +1,368 @@
+"""
+The four tools, as plain functions.
+
+Deliberately free of any MCP import: the protocol layer in server.py adapts these, and keeping
+them separate means the behaviour can be tested on Python 3.8 where the SDK cannot even be
+installed. It also means the same functions are callable from anything else that wants them.
+
+Every tool returns a JSON-serialisable dict. None of them touch the network, and none of them
+write to disk -- an agent operating on someone's infrastructure repository should not be able to
+cause a change by asking a question.
+"""
+
+from typing import Any, Dict, List, Optional
+
+from ..core.core import start_policy_evaluation_from_dict
+from ..core.evaluators import EVALUATORS_DICT
+
+# What each provider accepts as `operation_type`.
+#
+# Read from the providers themselves wherever they keep a registry, so this cannot drift: json
+# and kubernetes both expose SUPPORTED_OPS. terraform_plan dispatches through an if/elif chain
+# with no registry to import, so its operations are listed here and guarded by a test that reads
+# the handler source -- see tests/mcp/test_tools.py.
+_TERRAFORM_PLAN_OPS = [
+ "action",
+ "attribute",
+ "count",
+ "direct_dependencies",
+ "direct_references",
+ "provider_config",
+ "terraform_version",
+]
+
+
+# Which provider_args key names the value to read. Each provider chose its own, so a key that is
+# correct for one is silently ignored by another -- and an ignored key means the evaluator reads
+# None and tests the condition against nothing.
+_ATTRIBUTE_KEY = {
+ "terraform_plan": "terraform_resource_attribute",
+ "terraform_state": "terraform_resource_attribute",
+ "kubernetes": "attribute_path",
+ "json": "key_path",
+}
+
+
+def _operations_for(name):
+ if name == "terraform_plan":
+ return list(_TERRAFORM_PLAN_OPS)
+ if name == "json":
+ from ..providers.json import handler as json_handler
+
+ return sorted(json_handler.SUPPORTED_OPS)
+ if name == "kubernetes":
+ from ..providers.kubernetes import handler as k8s_handler
+
+ return sorted(k8s_handler.SUPPORTED_OPS)
+ if name == "infracost":
+ return ["total_monthly_cost", "total_hourly_cost"]
+ return []
+
+
+def _exit_code_for(result: Dict[str, Any]) -> Dict[str, Any]:
+ """
+ The `--fail-on-error` exit contract, as data.
+
+ Duplicated from the CLI's decision rather than imported because the CLI decides this while
+ also handling flags, printing and process exit; what a tool caller needs is only the mapping
+ from a tri-state final_result to the code a pipeline would see -- and the sentence explaining
+ it, which is the part an agent gets wrong.
+ """
+ if "final_result" not in result:
+ return {
+ "exit_code": 1,
+ "outcome": "errored",
+ "meaning": "The policy could not be evaluated at all. This is not a policy failure.",
+ }
+ final = result.get("final_result")
+ if final is True:
+ return {"exit_code": 0, "outcome": "passed", "meaning": "Every check that ran passed."}
+ if final is False:
+ return {
+ "exit_code": 3,
+ "outcome": "failed",
+ "meaning": "A check ran and failed. With --fail-on-error this stops the job.",
+ }
+ return {
+ "exit_code": 1,
+ "outcome": "unevaluated",
+ "meaning": (
+ "Every check was skipped, so the policy evaluated nothing. This is NOT a pass -- "
+ "it usually means the provider matched no resources, or error_tolerance swallowed a "
+ "provider error."
+ ),
+ }
+
+
+# Things the schema does not tell you, each of which produces a policy that looks correct and
+# behaves wrongly. Returned by describe_provider so an agent gets them before writing, rather
+# than discovering them one confusing verdict at a time.
+GOTCHAS = [
+ "The key that names the value to read differs per provider: terraform_plan and "
+ "terraform_state use `terraform_resource_attribute`, kubernetes uses `attribute_path` (with "
+ "`kubernetes_kind`), json uses `key_path`. Using the wrong one is not an error -- the key is "
+ "ignored, so the evaluator reads None and tests the condition against nothing.",
+ "`error_tolerance` lives inside `condition`. Its severities are specific: 0 = the resource is "
+ "being deleted (`change.after` is null), 1 = the type is absent from the plan, 2 = the "
+ "attribute is absent. Choose the tolerance from which of those you mean to forgive.",
+ "There is no NotRegexMatch, and no inverse conditions generally. Write the positive detector "
+ "and invert it in `eval_expression` with `!`.",
+ "`operation_type: attribute` reads `change.after` only. Nothing about a resource being "
+ "destroyed is visible through it -- use `action` for that.",
+ "`count` has no action filter, and Terraform reports unchanged resources as `no-op`, so "
+ "`count(*)` measures root-module size rather than the size of the change.",
+ "`jmespath` and `jq_query` do not ship, despite appearing in some test fixtures. The json "
+ "provider supports `get_value`.",
+]
+
+
+def evaluate(policy: Dict[str, Any], document: Any, variables: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
+ """
+ Run a policy against an input document and return the real verdict.
+
+ This is the tool that stops an agent guessing. A policy that looks correct and matches
+ nothing is indistinguishable from one that works until you run it, so the honest answer to
+ "is this policy right?" is always to evaluate it.
+ """
+ result = start_policy_evaluation_from_dict(policy, document, variables or {})
+ verdict = _exit_code_for(result)
+ return {
+ "verdict": verdict,
+ "result": result,
+ "note": (
+ "`unevaluated` is not a pass. If you expected resources to be checked and none were, "
+ "the provider_args are matching nothing -- check terraform_resource_type and the "
+ "attribute path before changing the condition."
+ ),
+ }
+
+
+def lint_policy(policy: Dict[str, Any]) -> Dict[str, Any]:
+ """
+ Check a policy's shape before it is ever run.
+
+ Catches the mistakes that produce a confusing verdict rather than an error: a missing
+ eval_expression, an evaluator id the expression never references, a condition type the engine
+ does not implement. The engine reports several of these as an ordinary failed check with no
+ error attached -- indistinguishable from a real violation -- so finding them here is the
+ difference between "your policy is wrong" and "your infrastructure is wrong".
+ """
+ problems: List[Dict[str, str]] = []
+
+ def fail(field, message):
+ problems.append({"severity": "error", "field": field, "message": message})
+
+ def warn(field, message):
+ problems.append({"severity": "warning", "field": field, "message": message})
+
+ if not isinstance(policy, dict):
+ return {"ok": False, "problems": [{"severity": "error", "field": ".", "message": "Policy must be a JSON object."}]}
+
+ meta = policy.get("meta")
+ if not isinstance(meta, dict):
+ fail("meta", "Missing `meta` object.")
+ else:
+ if not meta.get("required_provider"):
+ fail("meta.required_provider", "Missing. Name the provider this policy reads, e.g. 'stackguardian/terraform_plan'.")
+ if not meta.get("name"):
+ warn("meta.name", "No name. The name is what appears in the verdict, so an unnamed policy is hard to act on.")
+ if not meta.get("version"):
+ warn("meta.version", "No version. 'v1' is the current schema version.")
+
+ provider_name = ""
+ if isinstance(meta, dict):
+ provider_name = (meta.get("required_provider") or "").split("/")[-1]
+
+ evaluators = policy.get("evaluators")
+ ids = []
+ if not isinstance(evaluators, list) or not evaluators:
+ fail("evaluators", "Missing or empty. A policy needs at least one evaluator.")
+ else:
+ for index, evaluator in enumerate(evaluators):
+ where = f"evaluators[{index}]"
+ if not isinstance(evaluator, dict):
+ fail(where, "Each evaluator must be an object.")
+ continue
+ eid = evaluator.get("id")
+ if not eid:
+ fail(f"{where}.id", "Missing id. The eval_expression refers to evaluators by id.")
+ else:
+ if eid in ids:
+ fail(f"{where}.id", f"Duplicate id '{eid}'. Ids must be unique within a policy.")
+ ids.append(eid)
+ args = evaluator.get("provider_args")
+ if not isinstance(args, dict):
+ fail(f"{where}.provider_args", "Missing provider_args. This is what selects the values to test.")
+ else:
+ operation = args.get("operation_type")
+ if not operation:
+ fail(f"{where}.provider_args.operation_type", "Missing operation_type.")
+ elif operation in ("jmespath", "jq_query"):
+ # A test fixture in this repository uses these, which makes them look
+ # supported. They are not: the json provider's SUPPORTED_OPS is {"get_value"}
+ # and there is no jq dependency anywhere.
+ fail(
+ f"{where}.provider_args.operation_type",
+ f"'{operation}' does not ship. Some test fixtures reference it, which is "
+ "misleading. Use 'get_value' for the json provider.",
+ )
+ # A key belonging to a different provider is the trap: it is not rejected,
+ # it is ignored.
+ expected = _ATTRIBUTE_KEY.get(provider_name)
+ if expected:
+ foreign = [k for k in set(_ATTRIBUTE_KEY.values()) if k != expected and k in args]
+ for key in sorted(foreign):
+ fail(
+ f"{where}.provider_args.{key}",
+ f"'{key}' belongs to a different provider. {provider_name} reads "
+ f"'{expected}'. An unrecognised key is ignored rather than rejected, "
+ "so the evaluator would read None and test the condition against "
+ "nothing.",
+ )
+ if provider_name == "kubernetes" and operation == "attribute" and not args.get("kubernetes_kind"):
+ fail(
+ f"{where}.provider_args.kubernetes_kind",
+ "The kubernetes provider requires kubernetes_kind alongside attribute_path.",
+ )
+ if "error_tolerance" in args:
+ warn(
+ f"{where}.provider_args.error_tolerance",
+ "error_tolerance belongs inside `condition`, not in provider_args, where "
+ "it has no effect.",
+ )
+ if "error_tolerance" in evaluator:
+ warn(
+ f"{where}.error_tolerance",
+ "error_tolerance belongs inside `condition`, not on the evaluator.",
+ )
+ condition = evaluator.get("condition")
+ if not isinstance(condition, dict):
+ fail(f"{where}.condition", "Missing condition object.")
+ elif not condition.get("type"):
+ fail(f"{where}.condition.type", "Missing condition type.")
+ elif condition["type"] == "NotRegexMatch":
+ fail(
+ f"{where}.condition.type",
+ "There is no NotRegexMatch. Write the positive detector with RegexMatch and "
+ "invert it in eval_expression with '!' -- that is the only negation mechanism.",
+ )
+ elif condition["type"] not in EVALUATORS_DICT:
+ # The highest-value lint here. An unsupported condition type comes back from the
+ # engine as an ordinary failed check with no error attached -- indistinguishable
+ # from a real violation -- so it fails closed while pointing at the user's
+ # infrastructure when the fault is in the policy. Catching it before the run is
+ # the difference between a confusing red build and a fixed typo.
+ fail(
+ f"{where}.condition.type",
+ f"'{condition['type']}' is not a condition type. The engine reports an unknown "
+ "type as an ordinary failed check, so this would look like a real violation. "
+ "Available: " + ", ".join(sorted(EVALUATORS_DICT)),
+ )
+
+ expression = policy.get("eval_expression")
+ if not expression:
+ fail("eval_expression", "Missing. Name which evaluators must pass, e.g. 'a and b'.")
+ elif isinstance(expression, str) and ids:
+ unreferenced = [i for i in ids if i not in expression]
+ if unreferenced:
+ warn(
+ "eval_expression",
+ "These evaluator ids are never referenced, so they cannot affect the verdict: "
+ + ", ".join(unreferenced),
+ )
+
+ return {"ok": not any(p["severity"] == "error" for p in problems), "problems": problems}
+
+
+def describe_provider(provider: Optional[str] = None) -> Dict[str, Any]:
+ """
+ What a policy is allowed to say.
+
+ An agent that has not been told the vocabulary invents plausible-looking values --
+ `operation_type: "tag"`, `condition.type: "Exists"` -- which the engine either rejects or,
+ worse, treats as an ordinary failing check. Reading the real registries is cheaper than
+ debugging that.
+ """
+ conditions = sorted(EVALUATORS_DICT)
+
+ providers = {
+ "terraform_plan": "A `terraform show -json` plan. The main surface: gate a change before apply.",
+ "terraform_state": "A state document, for auditing what is deployed rather than what is proposed.",
+ "infracost": "An Infracost breakdown, for gating on monthly or hourly cost.",
+ "kubernetes": "Kubernetes manifests.",
+ "json": "Any JSON document, when nothing more specific fits.",
+ "sg_workflow": "A StackGuardian workflow definition.",
+ }
+
+ if provider:
+ key = provider.split("/")[-1]
+ if key not in providers:
+ return {"error": f"Unknown provider '{provider}'.", "known_providers": sorted(providers)}
+ operations = _operations_for(key)
+ return {
+ "provider": f"stackguardian/{key}",
+ "reads": providers[key],
+ "operation_types": operations or "See the documentation link; this provider's operations are not enumerated here.",
+ "condition_types": conditions,
+ "docs": f"https://stackguardian.github.io/tirith/docs/tirith-providers/{key.replace('_', '-')}-provider/",
+ }
+
+ return {
+ "providers": [
+ {"name": f"stackguardian/{k}", "reads": v, "operation_types": _operations_for(k)}
+ for k, v in sorted(providers.items())
+ ],
+ "condition_types": conditions,
+ "gotchas": GOTCHAS,
+ "note": "Call again with a provider name for its documentation link.",
+ }
+
+
+def explain_result(result: Dict[str, Any]) -> Dict[str, Any]:
+ """
+ Turn a result document into the sentence a human needs.
+
+ The raw result is nested and repetitive; what someone actually wants is which rule failed, on
+ which resource, and what value caused it. One caveat this surfaces honestly: when a check
+ fails because an attribute is *absent*, the engine has no value to attach a resource to, so
+ that failure arrives without an address. Saying so is more useful than pretending otherwise.
+ """
+ verdict = _exit_code_for(result)
+ failures = []
+ missing_address = 0
+
+ for evaluator in result.get("evaluators", []) or []:
+ for item in evaluator.get("result", []) or []:
+ if item.get("passed") is not False:
+ continue
+ meta = item.get("meta") or {}
+ address = meta.get("address")
+ if not address:
+ missing_address += 1
+ failures.append(
+ {
+ "rule": evaluator.get("description") or evaluator.get("id"),
+ "evaluator_id": evaluator.get("id"),
+ "resource": address,
+ "actions": (meta.get("change") or {}).get("actions"),
+ "message": item.get("message"),
+ }
+ )
+
+ summary = f"{verdict['outcome']}: {verdict['meaning']}"
+ if failures:
+ summary += f" {len(failures)} failing check{'' if len(failures) == 1 else 's'}."
+
+ out = {
+ "policy": (result.get("meta") or {}).get("name"),
+ "verdict": verdict,
+ "failures": failures,
+ "summary": summary,
+ }
+ if missing_address:
+ out["note"] = (
+ f"{missing_address} failing check(s) carry no resource address. That happens when the "
+ "attribute is absent -- there is no value to attach a resource to -- so find the "
+ "culprit by looking in the input document for the resource lacking that attribute."
+ )
+ return out
diff --git a/src/tirith/tui/examples/02-no-public-buckets/about.md b/src/tirith/tui/examples/02-no-public-buckets/about.md
index 5174f9ba..1ed07b33 100644
--- a/src/tirith/tui/examples/02-no-public-buckets/about.md
+++ b/src/tirith/tui/examples/02-no-public-buckets/about.md
@@ -1,22 +1,25 @@
-Two checks combined with `&&`, and two buckets that disagree.
+Four checks combined with `&&`, and one resource that satisfies only half of them.
-This is the shape most real policies take: several independent checks, joined into one
-verdict by `eval_expression`.
+This is the shape most real policies take: several independent checks joined into one verdict by
+`eval_expression`. All four have to hold, because S3 public access has four separate switches and
+blocking two of them is not blocking public access.
-`NotContainedIn` tests membership against a list — the value must not be any of the ACLs
-named. `IsNotEmpty` catches the other common shape of misconfiguration: the attribute is
-present but set to nothing, which is what an unencrypted bucket looks like in a plan.
+`aws_s3_bucket_public_access_block.web` sets `block_public_acls` and `ignore_public_acls` to true
+and leaves the other two false. So two evaluators pass, two fail, and `&&` fails — which is the
+correct answer. A policy that only checked one attribute would have called this bucket safe.
-Both checks fail, and both fail on the *same* resource: `aws_s3_bucket.public_site`. The
-other bucket passes both. This is what the results view is for — the four messages are
-nearly identical, and only the resource address tells you that one bucket is the problem
-and the other is fine.
+This example previously targeted `aws_s3_bucket.acl` and an inline
+`server_side_encryption_configuration` block. Both were removed in AWS provider v4, so it matched
+nothing on a modern plan while still appearing to work against its own fixture — a policy that
+matches nothing is the failure mode this interface exists to make visible, so shipping one as a
+teaching example was the wrong lesson.
**Things to try**
-- Change `eval_expression` to `acl_is_private || encryption_enabled`. Still fails — `||`
- needs only one to pass, and neither does.
-- Set the public bucket's `acl` to `"private"`. Now `acl_is_private` passes and only the
- encryption check fails, so `&&` fails but `||` would pass.
-- Add `!` to negate a check: `!acl_is_private` passes precisely when the ACL *is* public.
- Useful for writing a policy that detects a condition rather than forbidding it.
+- Set `block_public_policy` and `restrict_public_buckets` to `true` in the plan. All four pass and
+ the verdict turns green.
+- Change `eval_expression` to `block_public_acls || block_public_policy`. It passes — `||` needs
+ only one, which is exactly why this policy uses `&&`.
+- Add `!` to negate a check: `!block_public_policy` passes precisely when the setting is missing.
+ That is how you write a detector rather than a prohibition — there is no `NotRegexMatch` or
+ inverse condition, so `!` in the expression is the mechanism.
diff --git a/src/tirith/tui/examples/02-no-public-buckets/input.json b/src/tirith/tui/examples/02-no-public-buckets/input.json
index 4c51e976..0054b05d 100644
--- a/src/tirith/tui/examples/02-no-public-buckets/input.json
+++ b/src/tirith/tui/examples/02-no-public-buckets/input.json
@@ -1,50 +1,40 @@
{
"format_version": "1.2",
- "terraform_version": "1.5.7",
+ "terraform_version": "1.11.4",
"resource_changes": [
{
- "address": "aws_s3_bucket.private_data",
+ "address": "aws_s3_bucket_public_access_block.web",
"mode": "managed",
- "type": "aws_s3_bucket",
- "name": "private_data",
+ "type": "aws_s3_bucket_public_access_block",
+ "name": "web",
"provider_name": "registry.terraform.io/hashicorp/aws",
"change": {
- "actions": ["create"],
+ "actions": [
+ "create"
+ ],
"before": null,
"after": {
- "bucket": "acme-private-data",
- "acl": "private",
- "server_side_encryption_configuration": [
- {
- "rule": [
- {
- "apply_server_side_encryption_by_default": [
- { "sse_algorithm": "AES256" }
- ]
- }
- ]
- }
- ]
- },
- "after_unknown": { "arn": true }
+ "bucket": "acme-web",
+ "block_public_acls": true,
+ "block_public_policy": false,
+ "ignore_public_acls": true,
+ "restrict_public_buckets": false
+ }
}
- },
- {
- "address": "aws_s3_bucket.public_site",
- "mode": "managed",
- "type": "aws_s3_bucket",
- "name": "public_site",
- "provider_name": "registry.terraform.io/hashicorp/aws",
- "change": {
- "actions": ["create"],
- "before": null,
- "after": {
- "bucket": "acme-public-site",
- "acl": "public-read",
- "server_side_encryption_configuration": []
- },
- "after_unknown": { "arn": true }
+ }
+ ],
+ "configuration": {
+ "provider_config": {
+ "aws": {
+ "name": "aws",
+ "full_name": "registry.terraform.io/hashicorp/aws",
+ "version_constraint": "~> 5.0",
+ "expressions": {
+ "region": {
+ "constant_value": "us-east-1"
+ }
+ }
}
}
- ]
+ }
}
diff --git a/src/tirith/tui/examples/02-no-public-buckets/policy.json b/src/tirith/tui/examples/02-no-public-buckets/policy.json
index ded9ef25..dd973ef0 100644
--- a/src/tirith/tui/examples/02-no-public-buckets/policy.json
+++ b/src/tirith/tui/examples/02-no-public-buckets/policy.json
@@ -2,35 +2,50 @@
"meta": {
"version": "v1",
"required_provider": "stackguardian/terraform_plan",
- "name": "No public S3 buckets",
+ "name": "S3 public access blocks deny all four vectors",
"severity": "high"
},
"evaluators": [
{
- "id": "acl_is_private",
- "description": "Bucket ACLs must not grant public access",
+ "id": "block_public_acls",
+ "description": "aws_s3_bucket_public_access_block.block_public_acls is true",
"provider_args": {
"operation_type": "attribute",
- "terraform_resource_type": "aws_s3_bucket",
- "terraform_resource_attribute": "acl"
+ "terraform_resource_type": "aws_s3_bucket_public_access_block",
+ "terraform_resource_attribute": "block_public_acls"
},
- "condition": {
- "type": "NotContainedIn",
- "value": ["public-read", "public-read-write", "authenticated-read"]
- }
+ "condition": { "type": "Equals", "value": true, "error_tolerance": 1 }
},
{
- "id": "encryption_enabled",
- "description": "Buckets declare server-side encryption",
+ "id": "block_public_policy",
+ "description": "aws_s3_bucket_public_access_block.block_public_policy is true",
"provider_args": {
"operation_type": "attribute",
- "terraform_resource_type": "aws_s3_bucket",
- "terraform_resource_attribute": "server_side_encryption_configuration"
+ "terraform_resource_type": "aws_s3_bucket_public_access_block",
+ "terraform_resource_attribute": "block_public_policy"
},
- "condition": {
- "type": "IsNotEmpty"
- }
+ "condition": { "type": "Equals", "value": true, "error_tolerance": 1 }
+ },
+ {
+ "id": "ignore_public_acls",
+ "description": "aws_s3_bucket_public_access_block.ignore_public_acls is true",
+ "provider_args": {
+ "operation_type": "attribute",
+ "terraform_resource_type": "aws_s3_bucket_public_access_block",
+ "terraform_resource_attribute": "ignore_public_acls"
+ },
+ "condition": { "type": "Equals", "value": true, "error_tolerance": 1 }
+ },
+ {
+ "id": "restrict_public_buckets",
+ "description": "aws_s3_bucket_public_access_block.restrict_public_buckets is true",
+ "provider_args": {
+ "operation_type": "attribute",
+ "terraform_resource_type": "aws_s3_bucket_public_access_block",
+ "terraform_resource_attribute": "restrict_public_buckets"
+ },
+ "condition": { "type": "Equals", "value": true, "error_tolerance": 1 }
}
],
- "eval_expression": "acl_is_private && encryption_enabled"
+ "eval_expression": "block_public_acls && block_public_policy && ignore_public_acls && restrict_public_buckets"
}
diff --git a/tests/cli/test_dispatch.py b/tests/cli/test_dispatch.py
index b0dc8ab0..1e5ff7e5 100644
--- a/tests/cli/test_dispatch.py
+++ b/tests/cli/test_dispatch.py
@@ -101,10 +101,11 @@ def test_the_subcommand_names_are_exactly_these(capsys):
`remote` is not quietly still accepted.
`ui` was added alongside it later, on the same terms: dispatched before the flat parser so the
- local surface and its golden-file output are untouched. The set is pinned rather than merely
- checked for membership, so a new subcommand has to be a deliberate edit here.
+ local surface and its golden-file output are untouched. `mcp` joined them on those same terms.
+ The set is pinned rather than merely checked for membership, so a new subcommand has to be a
+ deliberate edit here.
"""
- assert cli.SUBCOMMANDS == {"platform", "ui"}
+ assert cli.SUBCOMMANDS == {"platform", "ui", "mcp"}
status = cli.main(["remote"])
@@ -128,3 +129,40 @@ def test_ui_dispatches_to_its_own_parser(capsys):
error = capsys.readouterr().err
assert "tirith ui" in error, error
assert "-policy-path" not in error
+
+
+def test_mcp_dispatches_to_its_own_parser(capsys):
+ """
+ `mcp` must reach its own parser rather than the flat one, which would reject it for having no
+ -policy-path.
+
+ Asserted through a bad flag, so the server never starts and this stays runnable without the
+ optional extra installed -- the same shape as the `ui` test above, and for the same reason:
+ the commonest state of a machine running this test is not having the extra.
+ """
+ with pytest.raises(SystemExit):
+ cli.main(["mcp", "--nope"])
+
+ assert "tirith mcp" in capsys.readouterr().err
+
+
+def test_mcp_without_the_extra_reports_the_extra(capsys):
+ """
+ Missing optional dependency is a message, not a traceback.
+
+ `tirith mcp` on a machine without the SDK is the commonest way to reach that code path, and
+ an ImportError stack there tells the reader nothing about what to install.
+ """
+ import tirith.mcp.cli as mcp_cli
+
+ try:
+ import mcp # noqa: F401
+ except ImportError:
+ pass
+ else:
+ pytest.skip("the mcp extra is installed, so the missing-extra path cannot be exercised")
+
+ status = mcp_cli.main(["mcp"])
+
+ assert status == ExitStatus.ERROR
+ assert "'mcp' extra" in capsys.readouterr().err
diff --git a/tests/mcp/__init__.py b/tests/mcp/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/mcp/test_tools.py b/tests/mcp/test_tools.py
new file mode 100644
index 00000000..dfc23446
--- /dev/null
+++ b/tests/mcp/test_tools.py
@@ -0,0 +1,257 @@
+"""
+Tests for the MCP tools.
+
+They import tirith.mcp.tools directly, never tirith.mcp.server, so the whole file runs on Python
+3.8 where the MCP SDK cannot be installed. That split is the reason tools.py holds the behaviour
+and server.py only describes it.
+"""
+
+import json
+import os
+import re
+
+import pytest
+
+from tirith.core.evaluators import EVALUATORS_DICT
+from tirith.mcp import tools
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+REPO = os.path.dirname(os.path.dirname(HERE))
+EXAMPLES = os.path.join(REPO, "src", "tirith", "tui", "examples")
+
+
+def _example(name):
+ with open(os.path.join(EXAMPLES, name, "policy.json")) as handle:
+ policy = json.load(handle)
+ with open(os.path.join(EXAMPLES, name, "input.json")) as handle:
+ document = json.load(handle)
+ return policy, document
+
+
+# --------------------------------------------------------------------- evaluate ---
+
+
+def test_evaluate_reports_a_real_failure():
+ policy, document = _example("01-required-tags")
+ out = tools.evaluate(policy, document)
+ assert out["verdict"]["outcome"] == "failed"
+ assert out["verdict"]["exit_code"] == 3
+ assert out["result"]["final_result"] is False
+
+
+def test_evaluate_reports_a_real_pass():
+ policy, document = _example("03-cost-ceiling")
+ out = tools.evaluate(policy, document)
+ assert out["verdict"]["outcome"] == "passed"
+ assert out["verdict"]["exit_code"] == 0
+
+
+def test_unevaluated_is_not_reported_as_a_pass():
+ """
+ The distinction the whole exit-code contract exists to protect. A policy whose checks were
+ all skipped must never come back as `passed`, and must not come back as exit 3 either -- it
+ is not a violation, it is an absence of an answer.
+ """
+ verdict = tools._exit_code_for({"final_result": None})
+ assert verdict["outcome"] == "unevaluated"
+ assert verdict["exit_code"] == 1
+ assert "NOT a pass" in verdict["meaning"]
+
+
+def test_missing_final_result_is_an_error_not_a_verdict():
+ verdict = tools._exit_code_for({})
+ assert verdict["outcome"] == "errored"
+ assert verdict["exit_code"] == 1
+
+
+# ------------------------------------------------------------------ lint_policy ---
+
+
+@pytest.mark.parametrize("name", sorted(os.listdir(EXAMPLES)))
+def test_shipped_examples_lint_clean(name):
+ """Every policy we ship should pass our own linter, or one of the two is wrong."""
+ policy, _ = _example(name)
+ report = tools.lint_policy(policy)
+ assert report["ok"], report["problems"]
+
+
+def test_lint_rejects_an_invented_condition_type():
+ """
+ The highest-value lint. An unknown condition type reaches the engine as an ordinary failed
+ check with no error attached, so it reads as a real infrastructure violation.
+ """
+ report = tools.lint_policy(
+ {
+ "meta": {"version": "v1", "required_provider": "stackguardian/terraform_plan", "name": "n"},
+ "evaluators": [
+ {"id": "a", "provider_args": {"operation_type": "attribute"}, "condition": {"type": "Exists"}}
+ ],
+ "eval_expression": "a",
+ }
+ )
+ assert not report["ok"]
+ assert any("Exists" in p["message"] for p in report["problems"])
+
+
+def test_lint_flags_an_evaluator_the_expression_never_references():
+ report = tools.lint_policy(
+ {
+ "meta": {"version": "v1", "required_provider": "stackguardian/terraform_plan", "name": "n"},
+ "evaluators": [
+ {"id": "a", "provider_args": {"operation_type": "attribute"}, "condition": {"type": "IsNotEmpty"}},
+ {"id": "orphan", "provider_args": {"operation_type": "attribute"}, "condition": {"type": "IsEmpty"}},
+ ],
+ "eval_expression": "a",
+ }
+ )
+ assert report["ok"], "an unreferenced evaluator is a warning, not an error"
+ assert any("orphan" in p["message"] for p in report["problems"])
+
+
+def test_lint_rejects_duplicate_evaluator_ids():
+ report = tools.lint_policy(
+ {
+ "meta": {"version": "v1", "required_provider": "stackguardian/terraform_plan", "name": "n"},
+ "evaluators": [
+ {"id": "a", "provider_args": {"operation_type": "attribute"}, "condition": {"type": "IsEmpty"}},
+ {"id": "a", "provider_args": {"operation_type": "attribute"}, "condition": {"type": "IsEmpty"}},
+ ],
+ "eval_expression": "a",
+ }
+ )
+ assert not report["ok"]
+
+
+def test_lint_survives_rubbish():
+ assert tools.lint_policy("not a policy")["ok"] is False
+
+
+# ------------------------------------------------------------ describe_provider ---
+
+
+def test_describe_provider_lists_every_real_condition_type():
+ """Sourced from the engine's registry, so a new evaluator appears here without an edit."""
+ assert tools.describe_provider()["condition_types"] == sorted(EVALUATORS_DICT)
+
+
+def test_describe_provider_reads_real_operation_registries():
+ from tirith.providers.json import handler as json_handler
+ from tirith.providers.kubernetes import handler as k8s_handler
+
+ assert tools.describe_provider("json")["operation_types"] == sorted(json_handler.SUPPORTED_OPS)
+ assert tools.describe_provider("kubernetes")["operation_types"] == sorted(k8s_handler.SUPPORTED_OPS)
+
+
+def test_terraform_plan_operation_list_has_not_drifted():
+ """
+ terraform_plan dispatches through an if/elif chain with no registry to import, so its
+ operations are hardcoded in tools.py. This reads the handler source and fails when the two
+ disagree -- which is the whole reason it is safe to hardcode them.
+ """
+ path = os.path.join(REPO, "src", "tirith", "providers", "terraform_plan", "handler.py")
+ with open(path) as handle:
+ source = handle.read()
+ found = set(re.findall(r'input_type\s*==\s*"([a-z_]+)"', source))
+ assert found == set(tools._TERRAFORM_PLAN_OPS), (
+ "terraform_plan's operation_type values changed; update _TERRAFORM_PLAN_OPS in "
+ "src/tirith/mcp/tools.py"
+ )
+
+
+def test_describe_provider_rejects_an_unknown_provider():
+ out = tools.describe_provider("stackguardian/nope")
+ assert "error" in out and out["known_providers"]
+
+
+# --------------------------------------------------------------- explain_result ---
+
+
+def test_explain_result_names_rule_resource_and_value():
+ policy, document = _example("02-no-public-buckets")
+ result = tools.evaluate(policy, document)["result"]
+ out = tools.explain_result(result)
+ assert out["verdict"]["outcome"] == "failed"
+ assert out["failures"]
+ assert any(f["resource"] for f in out["failures"]), "at least one failure should name a resource"
+
+
+def test_explain_result_admits_when_a_failure_has_no_resource():
+ """
+ The missing-attribute case: the engine has no value to attach a resource to, so the failure
+ arrives without an address. Saying so beats leaving the reader to wonder.
+ """
+ policy, document = _example("01-required-tags")
+ result = tools.evaluate(policy, document)["result"]
+ out = tools.explain_result(result)
+ assert any(f["resource"] is None for f in out["failures"])
+ assert "no resource address" in out["note"]
+
+
+# ------------------------------------------------------- the documented traps ---
+#
+# Each of these is a mistake that produces a policy which looks correct and behaves wrongly.
+# They were found the hard way while writing the starter pack, so they are pinned here.
+
+
+def _policy(evaluator):
+ return {
+ "meta": {"version": "v1", "required_provider": "stackguardian/terraform_plan", "name": "n"},
+ "evaluators": [evaluator],
+ "eval_expression": "a",
+ }
+
+
+def test_lint_catches_the_attribute_path_typo():
+ """An unrecognised provider_args key is ignored, so the evaluator silently reads nothing."""
+ report = tools.lint_policy(
+ _policy(
+ {
+ "id": "a",
+ "provider_args": {"operation_type": "attribute", "attribute_path": "tags.Owner"},
+ "condition": {"type": "IsNotEmpty"},
+ }
+ )
+ )
+ assert not report["ok"]
+ assert any("terraform_resource_attribute" in p["message"] for p in report["problems"])
+
+
+def test_lint_explains_how_to_negate_instead_of_NotRegexMatch():
+ report = tools.lint_policy(
+ _policy({"id": "a", "provider_args": {"operation_type": "attribute"}, "condition": {"type": "NotRegexMatch"}})
+ )
+ assert not report["ok"]
+ assert any("eval_expression" in p["message"] and "!" in p["message"] for p in report["problems"])
+
+
+@pytest.mark.parametrize("operation", ["jmespath", "jq_query"])
+def test_lint_rejects_operations_that_do_not_ship(operation):
+ """Test fixtures in this repository reference these, which makes them look supported."""
+ report = tools.lint_policy(
+ _policy({"id": "a", "provider_args": {"operation_type": operation}, "condition": {"type": "IsNotEmpty"}})
+ )
+ assert not report["ok"]
+
+
+def test_lint_warns_when_error_tolerance_is_in_the_wrong_place():
+ report = tools.lint_policy(
+ _policy(
+ {
+ "id": "a",
+ "provider_args": {"operation_type": "attribute"},
+ "condition": {"type": "IsNotEmpty"},
+ "error_tolerance": 1,
+ }
+ )
+ )
+ assert report["ok"], "misplaced error_tolerance is a warning: the policy still runs"
+ assert any("inside `condition`" in p["message"] for p in report["problems"])
+
+
+def test_gotchas_are_exposed_to_an_agent():
+ """describe_provider carries them, so an agent gets them before writing rather than after."""
+ gotchas = tools.describe_provider()["gotchas"]
+ assert len(gotchas) >= 6
+ joined = " ".join(gotchas)
+ for token in ["terraform_resource_attribute", "error_tolerance", "NotRegexMatch", "change.after", "count", "jq_query"]:
+ assert token in joined