diff --git a/.claude/skills/tirith-migrate/SKILL.md b/.claude/skills/tirith-migrate/SKILL.md new file mode 100644 index 00000000..f7e0d580 --- /dev/null +++ b/.claude/skills/tirith-migrate/SKILL.md @@ -0,0 +1,99 @@ +--- +name: tirith-migrate +description: Translate existing policy-as-code into Tirith policies. HashiCorp Sentinel and Azure Policy today; Checkov, OPA/Rego and conftest are planned. Use when asked to migrate, convert, port or translate policies to Tirith, when a repository contains .sentinel files or a sentinel.hcl, when given an Azure Policy definition, initiative or assignment (a policyRule with if/then), or when asked what a Sentinel or Azure policy would look like in Tirith. Requires the tirith-policies skill for the target vocabulary. +--- + +# Migrate policies to Tirith + +A migration is a projection from a larger language onto a smaller one. Sentinel and Rego are +programs; a Tirith policy is JSON that names a provider, a value, and a condition. Most real +policies fit. Some do not, and the failure mode is quiet: a translation that parses, looks right, +and gates nothing. **This skill exists to say which is which before any JSON is written.** + +## Vocabulary comes from `tirith-policies` + +Do not translate from memory. Read `../tirith-policies/reference/schema.md` for the closed list of +providers, operations, argument keys and the thirteen condition types. If that skill is not +installed, fetch `https://stackguardian.github.io/tirith/llms.txt` and follow it to the schema +page. Everything below assumes that vocabulary. + +## Per-source references + +| Source | Reference | Status | +| --- | --- | --- | +| HashiCorp Sentinel | `reference/sentinel.md`, corpus in `reference/sentinel-corpus.md` | Measured against 110 public policies | +| Azure Policy | `reference/azure-policy.md`, corpus in `reference/azure-policy-corpus.md` | 713 built-ins classified; Terraform and ARM targets | +| Checkov | | Planned | +| OPA / Rego, conftest | | Planned | + +## The protocol + +1. **Inventory.** List every source policy. Read the policy-set manifest (`sentinel.hcl`, an + Azure initiative or assignment) for enforcement levels and parameter values. Note which + policies are registered twice with different parameters; they translate once. For Azure, + decide the target first: Terraform plan or ARM template. +2. **Classify before translating.** For each policy, name its pattern from the source reference + and assign a fidelity: + - `exact`: a Tirith policy returns the same verdict on every plan. + - `approximate`: expressible, but stricter or looser in a case you can name. + - `not expressible`: needs something Tirith lacks. Name it, and link the tracking issue. +3. **Translate `exact` and `approximate`.** Carry `meta.name` from the source policy name, put the + Sentinel enforcement level in `meta.enforcement`, and map every `param` to `{{ var.NAME }}`. + If an approximation drops the test a `param` fed, do not ship an unread `variables.json`: name + the orphaned parameter in the notes and in the report row. +4. **Refuse `not expressible` in words.** Write what the policy does, what Tirith cannot see, and + the issue that would change that. Do not write a policy that checks something adjacent. +5. **Verify every translation against the source's own tests.** Sentinel policies ship mocks under + `test//`. Transcribe the failing mock into `should-fail.json` and the passing one into + `should-pass.json` (the mocks already have the `resource_changes` shape). Run both: + ```bash + tirith -policy-path policy.json -input-path should-fail.json --fail-on-error; echo $? # 3 + tirith -policy-path policy.json -input-path should-pass.json --fail-on-error; echo $? # 0 + ``` + For an `approximate` translation, also write `diverges.json`: a plan where the source and the + translation disagree. The reviewer needs to see the divergence, not read about it. +6. **Hand back a report**, one row per source policy: name, fidelity, Tirith file, and one line + on what changed. Fidelity is the column the reader looks at first. + +## Rules that hold for every source + +- A Tirith evaluator yields one result per matching resource and fails if any fails. That is the + universal quantifier. There is no existential: "at least one resource satisfies X" does not map. +- `eval_expression` combines evaluator verdicts, each already collapsed across all resources. It + cannot bind two tests to the same resource or the same nested block. "Where type is ingress, + cidr must not be open" becomes "no block may have cidr open", which is stricter. Say so. +- `attribute` reads `change.after` only. Anything about the previous value, a destroyed resource, + or a value unknown until apply is invisible. +- Configuration is not the plan. Module sources, variables, outputs, provisioners and expression + references live in `tfconfig`; Tirith reads none of them. +- A resource skipped through `error_tolerance` does not touch the verdict of the others: an + evaluator fails if any resource fails, passes if none fail and at least one was evaluated, and + is skipped only when every resource was tolerated away. Test with mixed plans anyway; that is + where a scope difference shows. + +## Before you hand it back + +1. Did every policy get a fidelity before it got JSON? +2. Does every `approximate` row name the case where verdicts differ, and ship `diverges.json`? +3. Does every `not expressible` row link a Tirith issue or say "not tracked"? +4. Did every translated policy exit `3` on `should-fail.json` and `0` on `should-pass.json`? +5. Is every `param` a `{{ var.NAME }}` with a `variables.json` beside the policy, or named as + orphaned in the report? +6. Is every condition type and argument key taken from `schema.md`, not recalled? + +## Worked examples + +`examples/azure-policy/` holds eight Azure built-ins, the ones a customer assigns first, each with +the verbatim definition, the Tirith policy for a Terraform plan (one also for an ARM template), and +the plans that prove it. `examples/azure-policy/README.md` is the index. + +`examples/sentinel/` holds five translations from the idioms of HashiCorp's public policy +libraries, each with its Sentinel source, the Tirith policy, and the plans that prove it: + +| Example | Fidelity | Shows | +| --- | --- | --- | +| `restrict-instance-type` | exact | `filter_attribute_not_in_list` to `ContainedIn`; `param` to `-var` | +| `mandatory-tags` | exact | Tag keys via `Contains` on the map; one evaluator per key and type | +| `prevent-database-destroy` | exact | `action` emits one result per action; `NotEquals "delete"` catches deletes and replacements | +| `restrict-ssh-ingress` | approximate | The per-block conjunction collapses to a stricter rule | +| `require-private-registry-modules` | not expressible | A `tfconfig` policy, refused in words | diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/README.md b/.claude/skills/tirith-migrate/examples/azure-policy/README.md new file mode 100644 index 00000000..6db9531d --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/azure-policy/README.md @@ -0,0 +1,24 @@ +# Azure Policy migrations + +Eight built-ins from `Azure/azure-policy`, chosen because they are the ones a customer assigns +first and because the public corpus skipped them (parameter-driven, or untyped). Each directory has +`source.json` (the definition, verbatim), `notes.md`, and for a translation `policy.json` with +`should-fail.json` and `should-pass.json`. Parameterised ones add `variables.json`; approximate ones +add `diverges.json`. + +```bash +cd allowed-locations +tirith -policy-path policy.json -input-path should-fail.json --fail-on-error -var-path variables.json; echo $? # 3 +tirith -policy-path policy.json -input-path should-pass.json --fail-on-error -var-path variables.json; echo $? # 0 +``` + +| Example | Effect, mode | Fidelity | Shows | +| --- | --- | --- | --- | +| `allowed-locations` | deny, Indexed | exact | `notIn` to `ContainedIn`; Indexed mode is `error_tolerance: 2`; a role assignment is skipped | +| `require-tag` | deny, Indexed | exact | `tags['x'] exists false` to `Contains` on the map; `tags: null` fails | +| `not-allowed-resource-types` | deny, All | exact | `count` `Equals 0` per forbidden type; one Azure type is three azurerm types | +| `allowed-resource-types` | deny, Indexed | approximate | `count` over `*` with the allow-list excluded; non-Azure resources must be excluded by hand | +| `allowed-vm-skus` | deny, Indexed | exact | `sku.name` is `size` or `vm_size`; three evaluators, `&&` | +| `storage-secure-transfer` | audit, Indexed | exact | provider version rename handled with `\|\|`; an ARM version in `arm/` shows the json type guard | +| `nsg-rdp-from-internet` | audit, All | approximate | four tests on one rule cannot be bound; `diverges.json` shows the stricter verdict | +| `inherit-tag-from-resource-group` | modify | not expressible | refused in words | diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/allowed-locations/notes.md b/.claude/skills/tirith-migrate/examples/azure-policy/allowed-locations/notes.md new file mode 100644 index 00000000..d24caf02 --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/azure-policy/allowed-locations/notes.md @@ -0,0 +1,16 @@ +# Allowed locations: exact + +`if: allOf [location notIn P, location notEquals global, type notEquals b2cDirectories]`, effect from +a parameter, mode Indexed. Violation when the location is outside the list and is not `global`. + +Compliance is `location ContainedIn P`. `global` is appended to the list in `variables.json`, which +is what Azure's second leaf does. The third leaf exempts one type that has no Terraform resource. +Indexed mode is `error_tolerance: 2` over `*`: a resource type with no `location`, the role +assignment in both plans, is skipped rather than failed. + +| Plan | Azure | Tirith | +| --- | --- | --- | +| `should-fail.json` (a VM in eastus) | deny | exit 3 | +| `should-pass.json` | pass | exit 0 | + +`listOfAllowedLocations` has no default in the definition. The value comes from the assignment. diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/allowed-locations/policy.json b/.claude/skills/tirith-migrate/examples/azure-policy/allowed-locations/policy.json new file mode 100644 index 00000000..65dd4e15 --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/azure-policy/allowed-locations/policy.json @@ -0,0 +1,27 @@ +{ + "meta": { + "version": "v1", + "required_provider": "stackguardian/terraform_plan", + "id": "e56962a6-4747-49cd-b67b-bf8b01975c4c", + "name": "Allowed locations", + "description": "Every resource that has a location is deployed in one of listOfAllowedLocations. Azure exempts location 'global'; keep it in the list.", + "enforcement": "deny" + }, + "evaluators": [ + { + "id": "location_allowed", + "description": "location is in listOfAllowedLocations. error_tolerance 2 is Azure's Indexed mode: a resource type without a location is out of scope", + "provider_args": { + "operation_type": "attribute", + "terraform_resource_type": "*", + "terraform_resource_attribute": "location" + }, + "condition": { + "type": "ContainedIn", + "value": "{{ var.listOfAllowedLocations }}", + "error_tolerance": 2 + } + } + ], + "eval_expression": "location_allowed" +} diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/allowed-locations/should-fail.json b/.claude/skills/tirith-migrate/examples/azure-policy/allowed-locations/should-fail.json new file mode 100644 index 00000000..99cdd59e --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/azure-policy/allowed-locations/should-fail.json @@ -0,0 +1,99 @@ +{ + "format_version": "1.2", + "terraform_version": "1.9.5", + "resource_changes": [ + { + "address": "azurerm_resource_group.rg", + "mode": "managed", + "type": "azurerm_resource_group", + "name": "rg", + "provider_name": "registry.terraform.io/hashicorp/azurerm", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "name": "rg-app", + "location": "westeurope", + "tags": null + }, + "after_unknown": { + "id": true + } + } + }, + { + "address": "azurerm_storage_account.sa", + "mode": "managed", + "type": "azurerm_storage_account", + "name": "sa", + "provider_name": "registry.terraform.io/hashicorp/azurerm", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "name": "stapp001", + "location": "westeurope", + "resource_group_name": "rg-app", + "account_tier": "Standard", + "account_replication_type": "LRS", + "https_traffic_only_enabled": true, + "min_tls_version": "TLS1_2", + "tags": null + }, + "after_unknown": { + "id": true + } + } + }, + { + "address": "azurerm_linux_virtual_machine.vm", + "mode": "managed", + "type": "azurerm_linux_virtual_machine", + "name": "vm", + "provider_name": "registry.terraform.io/hashicorp/azurerm", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "name": "vm-app", + "location": "eastus", + "size": "Standard_B2s", + "resource_group_name": "rg-app", + "admin_username": "azureuser", + "tags": null, + "disable_password_authentication": true + }, + "after_unknown": { + "id": true + } + } + }, + { + "address": "azurerm_role_assignment.reader", + "mode": "managed", + "type": "azurerm_role_assignment", + "name": "reader", + "provider_name": "registry.terraform.io/hashicorp/azurerm", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "scope": "/subscriptions/x/resourceGroups/rg-app", + "role_definition_name": "Reader", + "principal_id": "00000000-0000-0000-0000-000000000000" + }, + "after_unknown": { + "id": true + } + } + } + ] +} diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/allowed-locations/should-pass.json b/.claude/skills/tirith-migrate/examples/azure-policy/allowed-locations/should-pass.json new file mode 100644 index 00000000..ac466296 --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/azure-policy/allowed-locations/should-pass.json @@ -0,0 +1,99 @@ +{ + "format_version": "1.2", + "terraform_version": "1.9.5", + "resource_changes": [ + { + "address": "azurerm_resource_group.rg", + "mode": "managed", + "type": "azurerm_resource_group", + "name": "rg", + "provider_name": "registry.terraform.io/hashicorp/azurerm", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "name": "rg-app", + "location": "westeurope", + "tags": null + }, + "after_unknown": { + "id": true + } + } + }, + { + "address": "azurerm_storage_account.sa", + "mode": "managed", + "type": "azurerm_storage_account", + "name": "sa", + "provider_name": "registry.terraform.io/hashicorp/azurerm", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "name": "stapp001", + "location": "westeurope", + "resource_group_name": "rg-app", + "account_tier": "Standard", + "account_replication_type": "LRS", + "https_traffic_only_enabled": true, + "min_tls_version": "TLS1_2", + "tags": null + }, + "after_unknown": { + "id": true + } + } + }, + { + "address": "azurerm_linux_virtual_machine.vm", + "mode": "managed", + "type": "azurerm_linux_virtual_machine", + "name": "vm", + "provider_name": "registry.terraform.io/hashicorp/azurerm", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "name": "vm-app", + "location": "westeurope", + "size": "Standard_B2s", + "resource_group_name": "rg-app", + "admin_username": "azureuser", + "tags": null, + "disable_password_authentication": true + }, + "after_unknown": { + "id": true + } + } + }, + { + "address": "azurerm_role_assignment.reader", + "mode": "managed", + "type": "azurerm_role_assignment", + "name": "reader", + "provider_name": "registry.terraform.io/hashicorp/azurerm", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "scope": "/subscriptions/x/resourceGroups/rg-app", + "role_definition_name": "Reader", + "principal_id": "00000000-0000-0000-0000-000000000000" + }, + "after_unknown": { + "id": true + } + } + } + ] +} diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/allowed-locations/source.json b/.claude/skills/tirith-migrate/examples/azure-policy/allowed-locations/source.json new file mode 100644 index 00000000..d5c08fe5 --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/azure-policy/allowed-locations/source.json @@ -0,0 +1,63 @@ +{ + "properties": { + "displayName": "Allowed locations", + "policyType": "BuiltIn", + "mode": "Indexed", + "description": "This policy enables you to restrict the locations your organization can specify when deploying resources. Use to enforce your geo-compliance requirements. Excludes resource groups, Microsoft.AzureActiveDirectory/b2cDirectories, and resources that use the 'global' region.", + "metadata": { + "version": "1.1.0", + "category": "General" + }, + "version": "1.1.0", + "parameters": { + "listOfAllowedLocations": { + "type": "Array", + "metadata": { + "description": "The list of locations that can be specified when deploying resources.", + "strongType": "location", + "displayName": "Allowed locations" + } + }, + "effect": { + "type": "String", + "defaultValue": "Deny", + "allowedValues": [ + "Audit", + "Deny", + "Disabled" + ], + "metadata": { + "displayName": "Effect", + "description": "Enable or disable the execution of the policy" + } + } + }, + "policyRule": { + "if": { + "allOf": [ + { + "field": "location", + "notIn": "[parameters('listOfAllowedLocations')]" + }, + { + "field": "location", + "notEquals": "global" + }, + { + "field": "type", + "notEquals": "Microsoft.AzureActiveDirectory/b2cDirectories" + } + ] + }, + "then": { + "effect": "[parameters('effect')]" + } + }, + "versions": [ + "1.1.0", + "1.0.0" + ] + }, + "id": "/providers/Microsoft.Authorization/policyDefinitions/e56962a6-4747-49cd-b67b-bf8b01975c4c", + "name": "e56962a6-4747-49cd-b67b-bf8b01975c4c" +} \ No newline at end of file diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/allowed-locations/variables.json b/.claude/skills/tirith-migrate/examples/azure-policy/allowed-locations/variables.json new file mode 100644 index 00000000..6a80e1de --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/azure-policy/allowed-locations/variables.json @@ -0,0 +1,7 @@ +{ + "listOfAllowedLocations": [ + "westeurope", + "northeurope", + "global" + ] +} diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/allowed-resource-types/notes.md b/.claude/skills/tirith-migrate/examples/azure-policy/allowed-resource-types/notes.md new file mode 100644 index 00000000..973e1bcc --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/azure-policy/allowed-resource-types/notes.md @@ -0,0 +1,17 @@ +# Allowed resource types: approximate + +`if: not {type in P}`, mode Indexed. Violation when a resource's type is outside the allow-list. + +No operation yields a resource's type as a value, so the test is inverted onto `count`: over `*` +with `exclude_resource_types` set to the allow-list, the count of everything else must be `0` +(verified: `exclude_resource_types` is honoured by `count`). + +Approximate for two reasons. Azure sees only Azure resources; a Terraform plan also holds +`random_id`, `null_resource`, data sources and other providers' resources, and each counts as a +violation unless excluded by hand, as `random_id` is here. And the allow-list must name azurerm +types, so one Azure type expands to several rows. + +| Plan | Azure | Tirith | +| --- | --- | --- | +| `should-fail.json` (a VM outside the list) | deny | exit 3 | +| `should-pass.json` (with a `random_id`, excluded) | pass | exit 0 | diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/allowed-resource-types/policy.json b/.claude/skills/tirith-migrate/examples/azure-policy/allowed-resource-types/policy.json new file mode 100644 index 00000000..94ecf7e0 --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/azure-policy/allowed-resource-types/policy.json @@ -0,0 +1,32 @@ +{ + "meta": { + "version": "v1", + "required_provider": "stackguardian/terraform_plan", + "id": "a08ec900-254a-4555-9bf5-e42af04b5c5c", + "name": "Allowed resource types", + "description": "Only the listed azurerm types appear in the plan. count over * with the allowed types excluded must be 0. Non-Azure resources (random_*, null_resource, data sources) count too; exclude them explicitly, Azure never sees them.", + "enforcement": "deny" + }, + "evaluators": [ + { + "id": "only_allowed_types", + "description": "Number of resources whose type is not in the allow-list", + "provider_args": { + "operation_type": "count", + "terraform_resource_type": "*", + "exclude_resource_types": [ + "azurerm_resource_group", + "azurerm_storage_account", + "azurerm_storage_container", + "azurerm_role_assignment", + "random_id" + ] + }, + "condition": { + "type": "Equals", + "value": 0 + } + } + ], + "eval_expression": "only_allowed_types" +} diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/allowed-resource-types/should-fail.json b/.claude/skills/tirith-migrate/examples/azure-policy/allowed-resource-types/should-fail.json new file mode 100644 index 00000000..c06a411e --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/azure-policy/allowed-resource-types/should-fail.json @@ -0,0 +1,78 @@ +{ + "format_version": "1.2", + "terraform_version": "1.9.5", + "resource_changes": [ + { + "address": "azurerm_resource_group.rg", + "mode": "managed", + "type": "azurerm_resource_group", + "name": "rg", + "provider_name": "registry.terraform.io/hashicorp/azurerm", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "name": "rg-app", + "location": "westeurope", + "tags": null + }, + "after_unknown": { + "id": true + } + } + }, + { + "address": "azurerm_storage_account.sa", + "mode": "managed", + "type": "azurerm_storage_account", + "name": "sa", + "provider_name": "registry.terraform.io/hashicorp/azurerm", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "name": "stapp001", + "location": "westeurope", + "resource_group_name": "rg-app", + "account_tier": "Standard", + "account_replication_type": "LRS", + "https_traffic_only_enabled": true, + "min_tls_version": "TLS1_2", + "tags": null + }, + "after_unknown": { + "id": true + } + } + }, + { + "address": "azurerm_linux_virtual_machine.vm", + "mode": "managed", + "type": "azurerm_linux_virtual_machine", + "name": "vm", + "provider_name": "registry.terraform.io/hashicorp/azurerm", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "name": "vm-app", + "location": "westeurope", + "size": "Standard_B2s", + "resource_group_name": "rg-app", + "admin_username": "azureuser", + "tags": null, + "disable_password_authentication": true + }, + "after_unknown": { + "id": true + } + } + } + ] +} diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/allowed-resource-types/should-pass.json b/.claude/skills/tirith-migrate/examples/azure-policy/allowed-resource-types/should-pass.json new file mode 100644 index 00000000..ce500977 --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/azure-policy/allowed-resource-types/should-pass.json @@ -0,0 +1,93 @@ +{ + "format_version": "1.2", + "terraform_version": "1.9.5", + "resource_changes": [ + { + "address": "azurerm_resource_group.rg", + "mode": "managed", + "type": "azurerm_resource_group", + "name": "rg", + "provider_name": "registry.terraform.io/hashicorp/azurerm", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "name": "rg-app", + "location": "westeurope", + "tags": null + }, + "after_unknown": { + "id": true + } + } + }, + { + "address": "azurerm_storage_account.sa", + "mode": "managed", + "type": "azurerm_storage_account", + "name": "sa", + "provider_name": "registry.terraform.io/hashicorp/azurerm", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "name": "stapp001", + "location": "westeurope", + "resource_group_name": "rg-app", + "account_tier": "Standard", + "account_replication_type": "LRS", + "https_traffic_only_enabled": true, + "min_tls_version": "TLS1_2", + "tags": null + }, + "after_unknown": { + "id": true + } + } + }, + { + "address": "azurerm_role_assignment.reader", + "mode": "managed", + "type": "azurerm_role_assignment", + "name": "reader", + "provider_name": "registry.terraform.io/hashicorp/azurerm", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "scope": "/subscriptions/x/resourceGroups/rg-app", + "role_definition_name": "Reader", + "principal_id": "00000000-0000-0000-0000-000000000000" + }, + "after_unknown": { + "id": true + } + } + }, + { + "address": "random_id.suffix", + "mode": "managed", + "type": "random_id", + "name": "suffix", + "provider_name": "registry.terraform.io/hashicorp/azurerm", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "byte_length": 4 + }, + "after_unknown": { + "id": true + } + } + } + ] +} diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/allowed-resource-types/source.json b/.claude/skills/tirith-migrate/examples/azure-policy/allowed-resource-types/source.json new file mode 100644 index 00000000..8662208a --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/azure-policy/allowed-resource-types/source.json @@ -0,0 +1,53 @@ +{ + "properties": { + "displayName": "Allowed resource types", + "policyType": "BuiltIn", + "mode": "Indexed", + "description": "This policy enables you to specify the resource types that your organization can deploy. Only resource types that support 'tags' and 'location' will be affected by this policy. To restrict all resources please duplicate this policy and change the 'mode' to 'All'.", + "metadata": { + "version": "1.1.0", + "category": "General" + }, + "version": "1.1.0", + "parameters": { + "listOfResourceTypesAllowed": { + "type": "Array", + "metadata": { + "description": "The list of resource types that can be deployed.", + "displayName": "Allowed resource types", + "strongType": "resourceTypes" + } + }, + "effect": { + "type": "String", + "defaultValue": "Deny", + "allowedValues": [ + "Audit", + "Deny", + "Disabled" + ], + "metadata": { + "displayName": "Effect", + "description": "Enable or disable the execution of the policy" + } + } + }, + "policyRule": { + "if": { + "not": { + "field": "type", + "in": "[parameters('listOfResourceTypesAllowed')]" + } + }, + "then": { + "effect": "[parameters('effect')]" + } + }, + "versions": [ + "1.1.0", + "1.0.0" + ] + }, + "id": "/providers/Microsoft.Authorization/policyDefinitions/a08ec900-254a-4555-9bf5-e42af04b5c5c", + "name": "a08ec900-254a-4555-9bf5-e42af04b5c5c" +} \ No newline at end of file diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/allowed-vm-skus/notes.md b/.claude/skills/tirith-migrate/examples/azure-policy/allowed-vm-skus/notes.md new file mode 100644 index 00000000..1a8f2930 --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/azure-policy/allowed-vm-skus/notes.md @@ -0,0 +1,13 @@ +# Allowed virtual machine size SKUs: exact + +`if: allOf [type equals Microsoft.Compute/virtualMachines, not {sku.name in P}]`, effect Deny. + +The type leaf is scope; the `not in` becomes `ContainedIn`. `sku.name` is `size` on +`azurerm_linux_virtual_machine` and `azurerm_windows_virtual_machine` and `vm_size` on the legacy +`azurerm_virtual_machine`: three evaluators joined with `&&`, each at `error_tolerance: 1` so a plan +without one of the three types is not refused for it. + +| Plan | Azure | Tirith | +| --- | --- | --- | +| `should-fail.json` (Standard_E64s_v5) | deny | exit 3 | +| `should-pass.json` (a Linux and a Windows VM, both allowed) | pass | exit 0 | diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/allowed-vm-skus/policy.json b/.claude/skills/tirith-migrate/examples/azure-policy/allowed-vm-skus/policy.json new file mode 100644 index 00000000..dd41ce46 --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/azure-policy/allowed-vm-skus/policy.json @@ -0,0 +1,52 @@ +{ + "meta": { + "version": "v1", + "required_provider": "stackguardian/terraform_plan", + "id": "cccc23c7-8427-4f53-ad12-b6a63eb452b3", + "name": "Allowed virtual machine size SKUs", + "description": "Every virtual machine uses a size from listOfAllowedSKUs. sku.name is `size` on the linux and windows resources and `vm_size` on the legacy one.", + "enforcement": "deny" + }, + "evaluators": [ + { + "id": "linux_vm_size_allowed", + "provider_args": { + "operation_type": "attribute", + "terraform_resource_type": "azurerm_linux_virtual_machine", + "terraform_resource_attribute": "size" + }, + "condition": { + "type": "ContainedIn", + "value": "{{ var.listOfAllowedSKUs }}", + "error_tolerance": 1 + } + }, + { + "id": "windows_vm_size_allowed", + "provider_args": { + "operation_type": "attribute", + "terraform_resource_type": "azurerm_windows_virtual_machine", + "terraform_resource_attribute": "size" + }, + "condition": { + "type": "ContainedIn", + "value": "{{ var.listOfAllowedSKUs }}", + "error_tolerance": 1 + } + }, + { + "id": "legacy_vm_size_allowed", + "provider_args": { + "operation_type": "attribute", + "terraform_resource_type": "azurerm_virtual_machine", + "terraform_resource_attribute": "vm_size" + }, + "condition": { + "type": "ContainedIn", + "value": "{{ var.listOfAllowedSKUs }}", + "error_tolerance": 1 + } + } + ], + "eval_expression": "linux_vm_size_allowed && windows_vm_size_allowed && legacy_vm_size_allowed" +} diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/allowed-vm-skus/should-fail.json b/.claude/skills/tirith-migrate/examples/azure-policy/allowed-vm-skus/should-fail.json new file mode 100644 index 00000000..1cf4e725 --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/azure-policy/allowed-vm-skus/should-fail.json @@ -0,0 +1,52 @@ +{ + "format_version": "1.2", + "terraform_version": "1.9.5", + "resource_changes": [ + { + "address": "azurerm_resource_group.rg", + "mode": "managed", + "type": "azurerm_resource_group", + "name": "rg", + "provider_name": "registry.terraform.io/hashicorp/azurerm", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "name": "rg-app", + "location": "westeurope", + "tags": null + }, + "after_unknown": { + "id": true + } + } + }, + { + "address": "azurerm_linux_virtual_machine.vm", + "mode": "managed", + "type": "azurerm_linux_virtual_machine", + "name": "vm", + "provider_name": "registry.terraform.io/hashicorp/azurerm", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "name": "vm-app", + "location": "westeurope", + "size": "Standard_E64s_v5", + "resource_group_name": "rg-app", + "admin_username": "azureuser", + "tags": null, + "disable_password_authentication": true + }, + "after_unknown": { + "id": true + } + } + } + ] +} diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/allowed-vm-skus/should-pass.json b/.claude/skills/tirith-migrate/examples/azure-policy/allowed-vm-skus/should-pass.json new file mode 100644 index 00000000..7d61efe1 --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/azure-policy/allowed-vm-skus/should-pass.json @@ -0,0 +1,74 @@ +{ + "format_version": "1.2", + "terraform_version": "1.9.5", + "resource_changes": [ + { + "address": "azurerm_resource_group.rg", + "mode": "managed", + "type": "azurerm_resource_group", + "name": "rg", + "provider_name": "registry.terraform.io/hashicorp/azurerm", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "name": "rg-app", + "location": "westeurope", + "tags": null + }, + "after_unknown": { + "id": true + } + } + }, + { + "address": "azurerm_linux_virtual_machine.vm", + "mode": "managed", + "type": "azurerm_linux_virtual_machine", + "name": "vm", + "provider_name": "registry.terraform.io/hashicorp/azurerm", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "name": "vm-app", + "location": "westeurope", + "size": "Standard_D2s_v5", + "resource_group_name": "rg-app", + "admin_username": "azureuser", + "tags": null, + "disable_password_authentication": true + }, + "after_unknown": { + "id": true + } + } + }, + { + "address": "azurerm_windows_virtual_machine.win", + "mode": "managed", + "type": "azurerm_windows_virtual_machine", + "name": "win", + "provider_name": "registry.terraform.io/hashicorp/azurerm", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "name": "vm-win", + "location": "westeurope", + "size": "Standard_B2s", + "admin_username": "azureuser" + }, + "after_unknown": { + "id": true + } + } + } + ] +} diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/allowed-vm-skus/source.json b/.claude/skills/tirith-migrate/examples/azure-policy/allowed-vm-skus/source.json new file mode 100644 index 00000000..a0de0170 --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/azure-policy/allowed-vm-skus/source.json @@ -0,0 +1,47 @@ +{ + "properties": { + "displayName": "Allowed virtual machine size SKUs", + "policyType": "BuiltIn", + "mode": "Indexed", + "description": "This policy enables you to specify a set of virtual machine size SKUs that your organization can deploy.", + "metadata": { + "version": "1.0.1", + "category": "Compute" + }, + "version": "1.0.1", + "parameters": { + "listOfAllowedSKUs": { + "type": "Array", + "metadata": { + "description": "The list of size SKUs that can be specified for virtual machines.", + "displayName": "Allowed Size SKUs", + "strongType": "VMSKUs" + } + } + }, + "policyRule": { + "if": { + "allOf": [ + { + "field": "type", + "equals": "Microsoft.Compute/virtualMachines" + }, + { + "not": { + "field": "Microsoft.Compute/virtualMachines/sku.name", + "in": "[parameters('listOfAllowedSKUs')]" + } + } + ] + }, + "then": { + "effect": "Deny" + } + }, + "versions": [ + "1.0.1" + ] + }, + "id": "/providers/Microsoft.Authorization/policyDefinitions/cccc23c7-8427-4f53-ad12-b6a63eb452b3", + "name": "cccc23c7-8427-4f53-ad12-b6a63eb452b3" +} \ No newline at end of file diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/allowed-vm-skus/variables.json b/.claude/skills/tirith-migrate/examples/azure-policy/allowed-vm-skus/variables.json new file mode 100644 index 00000000..b0cec81a --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/azure-policy/allowed-vm-skus/variables.json @@ -0,0 +1,7 @@ +{ + "listOfAllowedSKUs": [ + "Standard_B2s", + "Standard_D2s_v5", + "Standard_D4s_v5" + ] +} diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/inherit-tag-from-resource-group/notes.md b/.claude/skills/tirith-migrate/examples/azure-policy/inherit-tag-from-resource-group/notes.md new file mode 100644 index 00000000..1a49711b --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/azure-policy/inherit-tag-from-resource-group/notes.md @@ -0,0 +1,15 @@ +# Inherit a tag from the resource group if missing: not expressible + +**What it does.** Effect `modify`: when a resource lacks `tags[tagName]` and its resource group has +that tag, Azure adds the tag to the resource at deployment time. + +**What Tirith cannot do.** Two things. It does not modify anything; it evaluates a document and +returns a verdict. And the `if` reads `resourceGroup().tags[...]`, the tags of a *different* +resource resolved at request time, which no attribute of the resource under evaluation carries. + +**What to say to the customer.** The evaluating half of the intent is `require-tag`: refuse a +resource without the tag. The remediation half stays in Azure Policy, or moves into the Terraform +module as a `default_tags`-style local merged into every resource. `modify`, `append`, +`deployIfNotExists` and `denyAction` all land here. + +No `policy.json` is shipped on purpose. diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/inherit-tag-from-resource-group/source.json b/.claude/skills/tirith-migrate/examples/azure-policy/inherit-tag-from-resource-group/source.json new file mode 100644 index 00000000..24940247 --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/azure-policy/inherit-tag-from-resource-group/source.json @@ -0,0 +1,56 @@ +{ + "properties": { + "displayName": "Inherit a tag from the resource group if missing", + "policyType": "BuiltIn", + "mode": "Indexed", + "description": "Adds the specified tag with its value from the parent resource group when any resource missing this tag is created or updated. Existing resources can be remediated by triggering a remediation task. If the tag exists with a different value it will not be changed.", + "metadata": { + "version": "1.0.0", + "category": "Tags" + }, + "version": "1.0.0", + "parameters": { + "tagName": { + "type": "String", + "metadata": { + "displayName": "Tag Name", + "description": "Name of the tag, such as 'environment'" + } + } + }, + "policyRule": { + "if": { + "allOf": [ + { + "field": "[concat('tags[', parameters('tagName'), ']')]", + "exists": "false" + }, + { + "value": "[resourceGroup().tags[parameters('tagName')]]", + "notEquals": "" + } + ] + }, + "then": { + "effect": "modify", + "details": { + "roleDefinitionIds": [ + "/providers/microsoft.authorization/roleDefinitions/b24988ac-6180-42a0-ab88-20f7382dd24c" + ], + "operations": [ + { + "operation": "add", + "field": "[concat('tags[', parameters('tagName'), ']')]", + "value": "[resourceGroup().tags[parameters('tagName')]]" + } + ] + } + } + }, + "versions": [ + "1.0.0" + ] + }, + "id": "/providers/Microsoft.Authorization/policyDefinitions/ea3f2387-9b95-492a-a190-fcdc54f7b070", + "name": "ea3f2387-9b95-492a-a190-fcdc54f7b070" +} \ No newline at end of file diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/not-allowed-resource-types/notes.md b/.claude/skills/tirith-migrate/examples/azure-policy/not-allowed-resource-types/notes.md new file mode 100644 index 00000000..eb7681d8 --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/azure-policy/not-allowed-resource-types/notes.md @@ -0,0 +1,15 @@ +# Not allowed resource types: exact + +`if: allOf [type in P, value "[field('type')]" exists true]`, mode All. The second leaf is an ARM +expression that is always true; it exists to make the definition well-formed and is dropped. + +Compliance is "no resource of any forbidden type": operation `count` per type, `Equals 0`. `count` +returns `0` for a type absent from the plan (verified), so no tolerance is needed. One Azure type is +often several azurerm types: `Microsoft.Compute/virtualMachines` is `azurerm_virtual_machine`, +`azurerm_linux_virtual_machine` and `azurerm_windows_virtual_machine`. A `-var` cannot feed +`terraform_resource_type`, so the assignment's list is written out. + +| Plan | Azure | Tirith | +| --- | --- | --- | +| `should-fail.json` (a public IP) | deny | exit 3 | +| `should-pass.json` | pass | exit 0 | diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/not-allowed-resource-types/policy.json b/.claude/skills/tirith-migrate/examples/azure-policy/not-allowed-resource-types/policy.json new file mode 100644 index 00000000..d28df568 --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/azure-policy/not-allowed-resource-types/policy.json @@ -0,0 +1,57 @@ +{ + "meta": { + "version": "v1", + "required_provider": "stackguardian/terraform_plan", + "id": "6c112d4e-5bc7-47ae-a041-ea2d9dccd749", + "name": "Not allowed resource types", + "description": "No resource of a forbidden type is in the plan. Microsoft.Network/publicIPAddresses is azurerm_public_ip; Microsoft.Compute/virtualMachines is three azurerm types, one evaluator each.", + "enforcement": "deny" + }, + "evaluators": [ + { + "id": "no_public_ip", + "provider_args": { + "operation_type": "count", + "terraform_resource_type": "azurerm_public_ip" + }, + "condition": { + "type": "Equals", + "value": 0 + } + }, + { + "id": "no_vm_legacy", + "provider_args": { + "operation_type": "count", + "terraform_resource_type": "azurerm_virtual_machine" + }, + "condition": { + "type": "Equals", + "value": 0 + } + }, + { + "id": "no_vm_linux", + "provider_args": { + "operation_type": "count", + "terraform_resource_type": "azurerm_linux_virtual_machine" + }, + "condition": { + "type": "Equals", + "value": 0 + } + }, + { + "id": "no_vm_windows", + "provider_args": { + "operation_type": "count", + "terraform_resource_type": "azurerm_windows_virtual_machine" + }, + "condition": { + "type": "Equals", + "value": 0 + } + } + ], + "eval_expression": "no_public_ip && no_vm_legacy && no_vm_linux && no_vm_windows" +} diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/not-allowed-resource-types/should-fail.json b/.claude/skills/tirith-migrate/examples/azure-policy/not-allowed-resource-types/should-fail.json new file mode 100644 index 00000000..e215112d --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/azure-policy/not-allowed-resource-types/should-fail.json @@ -0,0 +1,74 @@ +{ + "format_version": "1.2", + "terraform_version": "1.9.5", + "resource_changes": [ + { + "address": "azurerm_resource_group.rg", + "mode": "managed", + "type": "azurerm_resource_group", + "name": "rg", + "provider_name": "registry.terraform.io/hashicorp/azurerm", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "name": "rg-app", + "location": "westeurope", + "tags": null + }, + "after_unknown": { + "id": true + } + } + }, + { + "address": "azurerm_storage_account.sa", + "mode": "managed", + "type": "azurerm_storage_account", + "name": "sa", + "provider_name": "registry.terraform.io/hashicorp/azurerm", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "name": "stapp001", + "location": "westeurope", + "resource_group_name": "rg-app", + "account_tier": "Standard", + "account_replication_type": "LRS", + "https_traffic_only_enabled": true, + "min_tls_version": "TLS1_2", + "tags": null + }, + "after_unknown": { + "id": true + } + } + }, + { + "address": "azurerm_public_ip.pip", + "mode": "managed", + "type": "azurerm_public_ip", + "name": "pip", + "provider_name": "registry.terraform.io/hashicorp/azurerm", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "name": "pip-app", + "location": "westeurope", + "allocation_method": "Static" + }, + "after_unknown": { + "id": true + } + } + } + ] +} diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/not-allowed-resource-types/should-pass.json b/.claude/skills/tirith-migrate/examples/azure-policy/not-allowed-resource-types/should-pass.json new file mode 100644 index 00000000..8664bfe4 --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/azure-policy/not-allowed-resource-types/should-pass.json @@ -0,0 +1,74 @@ +{ + "format_version": "1.2", + "terraform_version": "1.9.5", + "resource_changes": [ + { + "address": "azurerm_resource_group.rg", + "mode": "managed", + "type": "azurerm_resource_group", + "name": "rg", + "provider_name": "registry.terraform.io/hashicorp/azurerm", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "name": "rg-app", + "location": "westeurope", + "tags": null + }, + "after_unknown": { + "id": true + } + } + }, + { + "address": "azurerm_storage_account.sa", + "mode": "managed", + "type": "azurerm_storage_account", + "name": "sa", + "provider_name": "registry.terraform.io/hashicorp/azurerm", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "name": "stapp001", + "location": "westeurope", + "resource_group_name": "rg-app", + "account_tier": "Standard", + "account_replication_type": "LRS", + "https_traffic_only_enabled": true, + "min_tls_version": "TLS1_2", + "tags": null + }, + "after_unknown": { + "id": true + } + } + }, + { + "address": "azurerm_role_assignment.reader", + "mode": "managed", + "type": "azurerm_role_assignment", + "name": "reader", + "provider_name": "registry.terraform.io/hashicorp/azurerm", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "scope": "/subscriptions/x/resourceGroups/rg-app", + "role_definition_name": "Reader", + "principal_id": "00000000-0000-0000-0000-000000000000" + }, + "after_unknown": { + "id": true + } + } + } + ] +} diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/not-allowed-resource-types/source.json b/.claude/skills/tirith-migrate/examples/azure-policy/not-allowed-resource-types/source.json new file mode 100644 index 00000000..fb0cb64e --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/azure-policy/not-allowed-resource-types/source.json @@ -0,0 +1,58 @@ +{ + "properties": { + "displayName": "Not allowed resource types", + "policyType": "BuiltIn", + "mode": "All", + "description": "Restrict which resource types can be deployed in your environment. Limiting resource types can reduce the complexity and attack surface of your environment while also helping to manage costs. Compliance results are only shown for non-compliant resources.", + "metadata": { + "version": "2.0.0", + "category": "General" + }, + "version": "2.0.0", + "parameters": { + "listOfResourceTypesNotAllowed": { + "type": "Array", + "metadata": { + "description": "The list of resource types that cannot be deployed.", + "displayName": "Not allowed resource types", + "strongType": "resourceTypes" + } + }, + "effect": { + "type": "string", + "defaultValue": "Deny", + "allowedValues": [ + "Audit", + "Deny", + "Disabled" + ], + "metadata": { + "displayName": "Effect", + "description": "Enable or disable the execution of the policy" + } + } + }, + "policyRule": { + "if": { + "allOf": [ + { + "field": "type", + "in": "[parameters('listOfResourceTypesNotAllowed')]" + }, + { + "value": "[field('type')]", + "exists": true + } + ] + }, + "then": { + "effect": "[parameters('effect')]" + } + }, + "versions": [ + "2.0.0" + ] + }, + "id": "/providers/Microsoft.Authorization/policyDefinitions/6c112d4e-5bc7-47ae-a041-ea2d9dccd749", + "name": "6c112d4e-5bc7-47ae-a041-ea2d9dccd749" +} \ No newline at end of file diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/nsg-rdp-from-internet/diverges.json b/.claude/skills/tirith-migrate/examples/azure-policy/nsg-rdp-from-internet/diverges.json new file mode 100644 index 00000000..f4e93c71 --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/azure-policy/nsg-rdp-from-internet/diverges.json @@ -0,0 +1,64 @@ +{ + "format_version": "1.2", + "terraform_version": "1.9.5", + "resource_changes": [ + { + "address": "azurerm_network_security_rule.allow-https-world", + "mode": "managed", + "type": "azurerm_network_security_rule", + "name": "allow-https-world", + "provider_name": "registry.terraform.io/hashicorp/azurerm", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "name": "allow-https-world", + "priority": 100, + "direction": "Inbound", + "access": "Allow", + "protocol": "Tcp", + "source_port_range": "*", + "destination_port_range": "443", + "source_address_prefix": "Internet", + "destination_address_prefix": "*", + "resource_group_name": "rg-app", + "network_security_group_name": "nsg-app" + }, + "after_unknown": { + "id": true + } + } + }, + { + "address": "azurerm_network_security_rule.allow-rdp-vpn", + "mode": "managed", + "type": "azurerm_network_security_rule", + "name": "allow-rdp-vpn", + "provider_name": "registry.terraform.io/hashicorp/azurerm", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "name": "allow-rdp-vpn", + "priority": 100, + "direction": "Inbound", + "access": "Allow", + "protocol": "Tcp", + "source_port_range": "*", + "destination_port_range": "3389", + "source_address_prefix": "10.0.0.0/8", + "destination_address_prefix": "*", + "resource_group_name": "rg-app", + "network_security_group_name": "nsg-app" + }, + "after_unknown": { + "id": true + } + } + } + ] +} diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/nsg-rdp-from-internet/notes.md b/.claude/skills/tirith-migrate/examples/azure-policy/nsg-rdp-from-internet/notes.md new file mode 100644 index 00000000..4212be9c --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/azure-policy/nsg-rdp-from-internet/notes.md @@ -0,0 +1,20 @@ +# RDP access from the Internet should be blocked: approximate, stricter + +The definition (deprecated upstream, but the shape is the one every NSG policy has) fires when a +`securityRules` resource has `access Allow` **and** `direction Inbound` **and** a destination port +covering 3389 **and** a source that is the Internet, all on the same rule. The port test includes +ARM expressions splitting `3000-4000` ranges and a `count`/`where` over `destinationPortRanges[*]`. + +Tirith evaluators are per resource and `eval_expression` combines whole-plan verdicts, so four +tests cannot be bound to one rule (issue #316), and port-range arithmetic is not expressible +(issue #338). The translation keeps the test that carries the intent: no rule's source is the +Internet. Stricter: a rule opening 443 to the world is refused, where Azure passes it. + +| Plan | Azure | Tirith | +| --- | --- | --- | +| `should-fail.json` (RDP from Internet) | audit | exit 3 | +| `should-pass.json` (RDP and HTTPS from the VPC only) | pass | exit 0 | +| `diverges.json` (HTTPS from Internet, RDP internal) | **pass** | **exit 3** | + +If the team accepts "no inbound rule from the Internet at all" as the house rule, say so in the +report and this becomes the policy. If they need the port-specific version, it waits on #316. diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/nsg-rdp-from-internet/policy.json b/.claude/skills/tirith-migrate/examples/azure-policy/nsg-rdp-from-internet/policy.json new file mode 100644 index 00000000..ba77d34d --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/azure-policy/nsg-rdp-from-internet/policy.json @@ -0,0 +1,31 @@ +{ + "meta": { + "version": "v1", + "required_provider": "stackguardian/terraform_plan", + "id": "e372f825-a257-4fb8-9175-797a8a8627d6", + "name": "RDP access from the Internet should be blocked", + "description": "Stricter than the source: Azure refuses a rule only when access, direction, port and source all match at once; Tirith cannot bind four tests to one rule, so any inbound allow rule from the Internet is refused whatever its port. Port ranges like 3000-4000 are not expressible.", + "enforcement": "audit" + }, + "evaluators": [ + { + "id": "rule_is_inbound_allow_from_internet", + "description": "Detects rules whose source is the Internet. With direction and access folded into the plan under review, this is the test that carries the intent", + "provider_args": { + "operation_type": "attribute", + "terraform_resource_type": "azurerm_network_security_rule", + "terraform_resource_attribute": "source_address_prefix" + }, + "condition": { + "type": "NotContainedIn", + "value": [ + "*", + "Internet", + "0.0.0.0/0" + ], + "error_tolerance": 1 + } + } + ], + "eval_expression": "rule_is_inbound_allow_from_internet" +} diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/nsg-rdp-from-internet/should-fail.json b/.claude/skills/tirith-migrate/examples/azure-policy/nsg-rdp-from-internet/should-fail.json new file mode 100644 index 00000000..c621a48a --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/azure-policy/nsg-rdp-from-internet/should-fail.json @@ -0,0 +1,35 @@ +{ + "format_version": "1.2", + "terraform_version": "1.9.5", + "resource_changes": [ + { + "address": "azurerm_network_security_rule.allow-rdp", + "mode": "managed", + "type": "azurerm_network_security_rule", + "name": "allow-rdp", + "provider_name": "registry.terraform.io/hashicorp/azurerm", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "name": "allow-rdp", + "priority": 100, + "direction": "Inbound", + "access": "Allow", + "protocol": "Tcp", + "source_port_range": "*", + "destination_port_range": "3389", + "source_address_prefix": "Internet", + "destination_address_prefix": "*", + "resource_group_name": "rg-app", + "network_security_group_name": "nsg-app" + }, + "after_unknown": { + "id": true + } + } + } + ] +} diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/nsg-rdp-from-internet/should-pass.json b/.claude/skills/tirith-migrate/examples/azure-policy/nsg-rdp-from-internet/should-pass.json new file mode 100644 index 00000000..56bf1cc4 --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/azure-policy/nsg-rdp-from-internet/should-pass.json @@ -0,0 +1,64 @@ +{ + "format_version": "1.2", + "terraform_version": "1.9.5", + "resource_changes": [ + { + "address": "azurerm_network_security_rule.allow-rdp-vpn", + "mode": "managed", + "type": "azurerm_network_security_rule", + "name": "allow-rdp-vpn", + "provider_name": "registry.terraform.io/hashicorp/azurerm", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "name": "allow-rdp-vpn", + "priority": 100, + "direction": "Inbound", + "access": "Allow", + "protocol": "Tcp", + "source_port_range": "*", + "destination_port_range": "3389", + "source_address_prefix": "10.0.0.0/8", + "destination_address_prefix": "*", + "resource_group_name": "rg-app", + "network_security_group_name": "nsg-app" + }, + "after_unknown": { + "id": true + } + } + }, + { + "address": "azurerm_network_security_rule.allow-https", + "mode": "managed", + "type": "azurerm_network_security_rule", + "name": "allow-https", + "provider_name": "registry.terraform.io/hashicorp/azurerm", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "name": "allow-https", + "priority": 100, + "direction": "Inbound", + "access": "Allow", + "protocol": "Tcp", + "source_port_range": "*", + "destination_port_range": "443", + "source_address_prefix": "10.0.0.0/8", + "destination_address_prefix": "*", + "resource_group_name": "rg-app", + "network_security_group_name": "nsg-app" + }, + "after_unknown": { + "id": true + } + } + } + ] +} diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/nsg-rdp-from-internet/source.json b/.claude/skills/tirith-migrate/examples/azure-policy/nsg-rdp-from-internet/source.json new file mode 100644 index 00000000..84247487 --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/azure-policy/nsg-rdp-from-internet/source.json @@ -0,0 +1,120 @@ +{ + "properties": { + "displayName": "[Deprecated]: RDP access from the Internet should be blocked", + "policyType": "BuiltIn", + "mode": "All", + "description": "This policy is deprecated. This policy audits any network security rule that allows RDP access from Internet", + "metadata": { + "version": "2.0.0-deprecated", + "category": "Network", + "deprecated": true + }, + "version": "2.0.0", + "parameters": { + "effect": { + "type": "string", + "defaultValue": "Audit", + "allowedValues": [ + "Audit", + "Disabled" + ], + "metadata": { + "displayName": "Effect", + "description": "Enable or disable the execution of the policy" + } + } + }, + "policyRule": { + "if": { + "allOf": [ + { + "field": "type", + "equals": "Microsoft.Network/networkSecurityGroups/securityRules" + }, + { + "allOf": [ + { + "field": "Microsoft.Network/networkSecurityGroups/securityRules/access", + "equals": "Allow" + }, + { + "field": "Microsoft.Network/networkSecurityGroups/securityRules/direction", + "equals": "Inbound" + }, + { + "anyOf": [ + { + "field": "Microsoft.Network/networkSecurityGroups/securityRules/destinationPortRange", + "equals": "*" + }, + { + "field": "Microsoft.Network/networkSecurityGroups/securityRules/destinationPortRange", + "equals": "3389" + }, + { + "value": "[if(and(not(empty(field('Microsoft.Network/networkSecurityGroups/securityRules/destinationPortRange'))), contains(field('Microsoft.Network/networkSecurityGroups/securityRules/destinationPortRange'),'-')), and(lessOrEquals(int(first(split(field('Microsoft.Network/networkSecurityGroups/securityRules/destinationPortRange'), '-'))),3389),greaterOrEquals(int(last(split(field('Microsoft.Network/networkSecurityGroups/securityRules/destinationPortRange'), '-'))),3389)), 'false')]", + "equals": "true" + }, + { + "count": { + "field": "Microsoft.Network/networkSecurityGroups/securityRules/destinationPortRanges[*]", + "where": { + "value": "[if(and(not(empty(first(field('Microsoft.Network/networkSecurityGroups/securityRules/destinationPortRanges[*]')))), contains(first(field('Microsoft.Network/networkSecurityGroups/securityRules/destinationPortRanges[*]')),'-')), and(lessOrEquals(int(first(split(first(field('Microsoft.Network/networkSecurityGroups/securityRules/destinationPortRanges[*]')), '-'))),3389),greaterOrEquals(int(last(split(first(field('Microsoft.Network/networkSecurityGroups/securityRules/destinationPortRanges[*]')), '-'))),3389)) , 'false')]", + "equals": "true" + } + }, + "greater": 0 + }, + { + "not": { + "field": "Microsoft.Network/networkSecurityGroups/securityRules/destinationPortRanges[*]", + "notEquals": "*" + } + }, + { + "not": { + "field": "Microsoft.Network/networkSecurityGroups/securityRules/destinationPortRanges[*]", + "notEquals": "3389" + } + } + ] + }, + { + "anyOf": [ + { + "field": "Microsoft.Network/networkSecurityGroups/securityRules/sourceAddressPrefix", + "equals": "*" + }, + { + "field": "Microsoft.Network/networkSecurityGroups/securityRules/sourceAddressPrefix", + "equals": "Internet" + }, + { + "not": { + "field": "Microsoft.Network/networkSecurityGroups/securityRules/sourceAddressPrefixes[*]", + "notEquals": "*" + } + }, + { + "not": { + "field": "Microsoft.Network/networkSecurityGroups/securityRules/sourceAddressPrefixes[*]", + "notEquals": "Internet" + } + } + ] + } + ] + } + ] + }, + "then": { + "effect": "[parameters('effect')]" + } + }, + "versions": [ + "2.0.0" + ] + }, + "id": "/providers/Microsoft.Authorization/policyDefinitions/e372f825-a257-4fb8-9175-797a8a8627d6", + "name": "e372f825-a257-4fb8-9175-797a8a8627d6" +} \ No newline at end of file diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/require-tag/notes.md b/.claude/skills/tirith-migrate/examples/azure-policy/require-tag/notes.md new file mode 100644 index 00000000..0a52c821 --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/azure-policy/require-tag/notes.md @@ -0,0 +1,17 @@ +# Require a tag on resources: exact + +`if: field "[concat('tags[', parameters('tagName'), ']')]" exists false`, effect deny, mode Indexed. + +Compliance is the key's presence in the tags map: `Contains {{ var.tagName }}` on `tags`, which +tests keys on a map (verified). Indexed mode is `error_tolerance: 2`: a type with no `tags` +attribute at all (the role assignment) is skipped. + +A resource that supports tags and sets none has `"tags": null` in the plan, present but null. +`Contains` on null fails as an unsupported type, so the untagged resource is refused, which is +Azure's verdict too. `should-fail-null-tags.json` proves it. + +| Plan | Azure | Tirith | +| --- | --- | --- | +| `should-fail.json` (storage account lacks CostCenter) | deny | exit 3 | +| `should-fail-null-tags.json` (storage account has no tags at all) | deny | exit 3 | +| `should-pass.json` | pass | exit 0 | diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/require-tag/policy.json b/.claude/skills/tirith-migrate/examples/azure-policy/require-tag/policy.json new file mode 100644 index 00000000..de3115b7 --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/azure-policy/require-tag/policy.json @@ -0,0 +1,27 @@ +{ + "meta": { + "version": "v1", + "required_provider": "stackguardian/terraform_plan", + "id": "871b6d14-10aa-478d-b590-94f262ecfa99", + "name": "Require a tag on resources", + "description": "Every resource that supports tags carries the tag named by tagName. Azure evaluates tags[''] exists; Tirith tests the key of the tags map.", + "enforcement": "deny" + }, + "evaluators": [ + { + "id": "tag_present", + "description": "tags map contains the key tagName. error_tolerance 2 is Indexed mode: a type with no tags attribute is out of scope. A resource that supports tags but sets none has tags: null in the plan, which Contains fails, so the untagged resource is refused as Azure would refuse it", + "provider_args": { + "operation_type": "attribute", + "terraform_resource_type": "*", + "terraform_resource_attribute": "tags" + }, + "condition": { + "type": "Contains", + "value": "{{ var.tagName }}", + "error_tolerance": 2 + } + } + ], + "eval_expression": "tag_present" +} diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/require-tag/should-fail-null-tags.json b/.claude/skills/tirith-migrate/examples/azure-policy/require-tag/should-fail-null-tags.json new file mode 100644 index 00000000..08503de7 --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/azure-policy/require-tag/should-fail-null-tags.json @@ -0,0 +1,55 @@ +{ + "format_version": "1.2", + "terraform_version": "1.9.5", + "resource_changes": [ + { + "address": "azurerm_resource_group.rg", + "mode": "managed", + "type": "azurerm_resource_group", + "name": "rg", + "provider_name": "registry.terraform.io/hashicorp/azurerm", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "name": "rg-app", + "location": "westeurope", + "tags": { + "CostCenter": "cc-42" + } + }, + "after_unknown": { + "id": true + } + } + }, + { + "address": "azurerm_storage_account.sa", + "mode": "managed", + "type": "azurerm_storage_account", + "name": "sa", + "provider_name": "registry.terraform.io/hashicorp/azurerm", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "name": "stapp001", + "location": "westeurope", + "resource_group_name": "rg-app", + "account_tier": "Standard", + "account_replication_type": "LRS", + "https_traffic_only_enabled": true, + "min_tls_version": "TLS1_2", + "tags": null + }, + "after_unknown": { + "id": true + } + } + } + ] +} diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/require-tag/should-fail.json b/.claude/skills/tirith-migrate/examples/azure-policy/require-tag/should-fail.json new file mode 100644 index 00000000..10382d4f --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/azure-policy/require-tag/should-fail.json @@ -0,0 +1,79 @@ +{ + "format_version": "1.2", + "terraform_version": "1.9.5", + "resource_changes": [ + { + "address": "azurerm_resource_group.rg", + "mode": "managed", + "type": "azurerm_resource_group", + "name": "rg", + "provider_name": "registry.terraform.io/hashicorp/azurerm", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "name": "rg-app", + "location": "westeurope", + "tags": { + "CostCenter": "cc-42", + "Owner": "platform" + } + }, + "after_unknown": { + "id": true + } + } + }, + { + "address": "azurerm_storage_account.sa", + "mode": "managed", + "type": "azurerm_storage_account", + "name": "sa", + "provider_name": "registry.terraform.io/hashicorp/azurerm", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "name": "stapp001", + "location": "westeurope", + "resource_group_name": "rg-app", + "account_tier": "Standard", + "account_replication_type": "LRS", + "https_traffic_only_enabled": true, + "min_tls_version": "TLS1_2", + "tags": { + "Owner": "platform" + } + }, + "after_unknown": { + "id": true + } + } + }, + { + "address": "azurerm_role_assignment.reader", + "mode": "managed", + "type": "azurerm_role_assignment", + "name": "reader", + "provider_name": "registry.terraform.io/hashicorp/azurerm", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "scope": "/subscriptions/x/resourceGroups/rg-app", + "role_definition_name": "Reader", + "principal_id": "00000000-0000-0000-0000-000000000000" + }, + "after_unknown": { + "id": true + } + } + } + ] +} diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/require-tag/should-pass.json b/.claude/skills/tirith-migrate/examples/azure-policy/require-tag/should-pass.json new file mode 100644 index 00000000..178a5663 --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/azure-policy/require-tag/should-pass.json @@ -0,0 +1,79 @@ +{ + "format_version": "1.2", + "terraform_version": "1.9.5", + "resource_changes": [ + { + "address": "azurerm_resource_group.rg", + "mode": "managed", + "type": "azurerm_resource_group", + "name": "rg", + "provider_name": "registry.terraform.io/hashicorp/azurerm", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "name": "rg-app", + "location": "westeurope", + "tags": { + "CostCenter": "cc-42" + } + }, + "after_unknown": { + "id": true + } + } + }, + { + "address": "azurerm_storage_account.sa", + "mode": "managed", + "type": "azurerm_storage_account", + "name": "sa", + "provider_name": "registry.terraform.io/hashicorp/azurerm", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "name": "stapp001", + "location": "westeurope", + "resource_group_name": "rg-app", + "account_tier": "Standard", + "account_replication_type": "LRS", + "https_traffic_only_enabled": true, + "min_tls_version": "TLS1_2", + "tags": { + "CostCenter": "cc-42", + "Owner": "platform" + } + }, + "after_unknown": { + "id": true + } + } + }, + { + "address": "azurerm_role_assignment.reader", + "mode": "managed", + "type": "azurerm_role_assignment", + "name": "reader", + "provider_name": "registry.terraform.io/hashicorp/azurerm", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "scope": "/subscriptions/x/resourceGroups/rg-app", + "role_definition_name": "Reader", + "principal_id": "00000000-0000-0000-0000-000000000000" + }, + "after_unknown": { + "id": true + } + } + } + ] +} diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/require-tag/source.json b/.claude/skills/tirith-migrate/examples/azure-policy/require-tag/source.json new file mode 100644 index 00000000..e61c0afa --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/azure-policy/require-tag/source.json @@ -0,0 +1,36 @@ +{ + "properties": { + "displayName": "Require a tag on resources", + "policyType": "BuiltIn", + "mode": "Indexed", + "description": "Enforces existence of a tag. Does not apply to resource groups.", + "metadata": { + "version": "1.0.1", + "category": "Tags" + }, + "version": "1.0.1", + "parameters": { + "tagName": { + "type": "String", + "metadata": { + "displayName": "Tag Name", + "description": "Name of the tag, such as 'environment'" + } + } + }, + "policyRule": { + "if": { + "field": "[concat('tags[', parameters('tagName'), ']')]", + "exists": "false" + }, + "then": { + "effect": "deny" + } + }, + "versions": [ + "1.0.1" + ] + }, + "id": "/providers/Microsoft.Authorization/policyDefinitions/871b6d14-10aa-478d-b590-94f262ecfa99", + "name": "871b6d14-10aa-478d-b590-94f262ecfa99" +} \ No newline at end of file diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/require-tag/variables.json b/.claude/skills/tirith-migrate/examples/azure-policy/require-tag/variables.json new file mode 100644 index 00000000..5dfb8338 --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/azure-policy/require-tag/variables.json @@ -0,0 +1,3 @@ +{ + "tagName": "CostCenter" +} diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/storage-secure-transfer/arm/policy.json b/.claude/skills/tirith-migrate/examples/azure-policy/storage-secure-transfer/arm/policy.json new file mode 100644 index 00000000..8061da63 --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/azure-policy/storage-secure-transfer/arm/policy.json @@ -0,0 +1,46 @@ +{ + "meta": { + "version": "v1", + "required_provider": "stackguardian/json", + "id": "404c3081-a854-4457-ae30-26a93ef643f9", + "name": "Secure transfer to storage accounts should be enabled (ARM template)", + "description": "The json provider reads the whole ARM template. The guard passes a template with no storage account; otherwise every storage account's supportsHttpsTrafficOnly must be true and an absent property fails, as Azure's `notEquals true` does.", + "enforcement": "audit" + }, + "evaluators": [ + { + "id": "no_storage_account", + "description": "Document-scope guard: no Microsoft.Storage/storageAccounts in the template. ARM type strings are case-insensitive in Azure and compared exactly here, so common spellings are listed", + "provider_args": { + "operation_type": "get_value", + "key_path": "resources.*.type" + }, + "condition": { + "type": "NotContainedIn", + "value": [ + "Microsoft.Storage/storageAccounts", + "microsoft.storage/storageaccounts", + "Microsoft.Storage/StorageAccounts" + ], + "error_tolerance": 2 + } + }, + { + "id": "https_only", + "description": "Every storage account's supportsHttpsTrafficOnly is true; absent fails (error_tolerance 1)", + "provider_args": { + "operation_type": "get_value", + "key_path": "resources.*.properties.supportsHttpsTrafficOnly" + }, + "condition": { + "type": "ContainedIn", + "value": [ + true, + "true" + ], + "error_tolerance": 1 + } + } + ], + "eval_expression": "no_storage_account || https_only" +} diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/storage-secure-transfer/arm/should-fail.json b/.claude/skills/tirith-migrate/examples/azure-policy/storage-secure-transfer/arm/should-fail.json new file mode 100644 index 00000000..aae1f194 --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/azure-policy/storage-secure-transfer/arm/should-fail.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "resources": [ + { + "type": "Microsoft.Storage/storageAccounts", + "apiVersion": "2023-01-01", + "name": "stapp001", + "location": "westeurope", + "sku": { + "name": "Standard_LRS" + }, + "kind": "StorageV2", + "properties": { + "supportsHttpsTrafficOnly": false, + "minimumTlsVersion": "TLS1_2" + } + } + ] +} diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/storage-secure-transfer/arm/should-pass-no-storage.json b/.claude/skills/tirith-migrate/examples/azure-policy/storage-secure-transfer/arm/should-pass-no-storage.json new file mode 100644 index 00000000..89676cc0 --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/azure-policy/storage-secure-transfer/arm/should-pass-no-storage.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "resources": [ + { + "type": "Microsoft.KeyVault/vaults", + "apiVersion": "2023-02-01", + "name": "kv-app", + "location": "westeurope", + "properties": { + "enableSoftDelete": true + } + } + ] +} diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/storage-secure-transfer/arm/should-pass.json b/.claude/skills/tirith-migrate/examples/azure-policy/storage-secure-transfer/arm/should-pass.json new file mode 100644 index 00000000..c168733f --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/azure-policy/storage-secure-transfer/arm/should-pass.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "resources": [ + { + "type": "Microsoft.Storage/storageAccounts", + "apiVersion": "2023-01-01", + "name": "stapp001", + "location": "westeurope", + "sku": { + "name": "Standard_LRS" + }, + "kind": "StorageV2", + "properties": { + "supportsHttpsTrafficOnly": true, + "minimumTlsVersion": "TLS1_2" + } + } + ] +} diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/storage-secure-transfer/notes.md b/.claude/skills/tirith-migrate/examples/azure-policy/storage-secure-transfer/notes.md new file mode 100644 index 00000000..bce0c7f9 --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/azure-policy/storage-secure-transfer/notes.md @@ -0,0 +1,24 @@ +# Secure transfer to storage accounts should be enabled: exact + +`if: allOf [type equals storageAccounts, anyOf [allOf [apiVersion less 2019-04-01, supportsHttpsTrafficOnly exists false], supportsHttpsTrafficOnly equals false]]`. + +The API-version branch is a request-time expression with no plan counterpart and is dropped; on +the current API the property always exists, so compliance is `supportsHttpsTrafficOnly Equals true`. + +azurerm renamed the attribute in 4.0: `https_traffic_only_enabled`, previously +`enable_https_traffic_only`. Two evaluators, one per name, each at `error_tolerance: 2` so the +absent name is skipped, joined with `||`. Whichever name the plan carries decides. + +`arm/` holds the same policy for an ARM template on the json provider, in the corpus's type-guard +idiom: `no_storage_account || https_only`. A template without a storage account passes +(`should-pass-no-storage.json`); one with the property false, or absent, fails. + +| Plan | Azure | Tirith | +| --- | --- | --- | +| `should-fail.json` (4.x name, false) | audit | exit 3 | +| `should-fail-azurerm3.json` (3.x name, false) | audit | exit 3 | +| `should-pass.json` | pass | exit 0 | +| `arm/should-fail.json`, `arm/should-pass.json`, `arm/should-pass-no-storage.json` | | 3, 0, 0 | + +Effect is `audit`: `meta.enforcement` says so, and the CLI cannot make it advisory. Run audit +policies in their own directory and decide what exit 3 means for that run. diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/storage-secure-transfer/policy.json b/.claude/skills/tirith-migrate/examples/azure-policy/storage-secure-transfer/policy.json new file mode 100644 index 00000000..9e176a68 --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/azure-policy/storage-secure-transfer/policy.json @@ -0,0 +1,41 @@ +{ + "meta": { + "version": "v1", + "required_provider": "stackguardian/terraform_plan", + "id": "404c3081-a854-4457-ae30-26a93ef643f9", + "name": "Secure transfer to storage accounts should be enabled", + "description": "supportsHttpsTrafficOnly is true on every storage account. azurerm 4.x names it https_traffic_only_enabled, 3.x enable_https_traffic_only; one evaluator per name, OR-ed, each skipping when its name is absent.", + "enforcement": "audit" + }, + "evaluators": [ + { + "id": "https_only_v4", + "description": "azurerm >= 4.0 attribute name", + "provider_args": { + "operation_type": "attribute", + "terraform_resource_type": "azurerm_storage_account", + "terraform_resource_attribute": "https_traffic_only_enabled" + }, + "condition": { + "type": "Equals", + "value": true, + "error_tolerance": 2 + } + }, + { + "id": "https_only_v3", + "description": "azurerm 3.x attribute name", + "provider_args": { + "operation_type": "attribute", + "terraform_resource_type": "azurerm_storage_account", + "terraform_resource_attribute": "enable_https_traffic_only" + }, + "condition": { + "type": "Equals", + "value": true, + "error_tolerance": 2 + } + } + ], + "eval_expression": "https_only_v4 || https_only_v3" +} diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/storage-secure-transfer/should-fail-azurerm3.json b/.claude/skills/tirith-migrate/examples/azure-policy/storage-secure-transfer/should-fail-azurerm3.json new file mode 100644 index 00000000..baace7cd --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/azure-policy/storage-secure-transfer/should-fail-azurerm3.json @@ -0,0 +1,53 @@ +{ + "format_version": "1.2", + "terraform_version": "1.9.5", + "resource_changes": [ + { + "address": "azurerm_resource_group.rg", + "mode": "managed", + "type": "azurerm_resource_group", + "name": "rg", + "provider_name": "registry.terraform.io/hashicorp/azurerm", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "name": "rg-app", + "location": "westeurope", + "tags": null + }, + "after_unknown": { + "id": true + } + } + }, + { + "address": "azurerm_storage_account.sa", + "mode": "managed", + "type": "azurerm_storage_account", + "name": "sa", + "provider_name": "registry.terraform.io/hashicorp/azurerm", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "name": "stapp001", + "location": "westeurope", + "resource_group_name": "rg-app", + "account_tier": "Standard", + "account_replication_type": "LRS", + "min_tls_version": "TLS1_2", + "tags": null, + "enable_https_traffic_only": false + }, + "after_unknown": { + "id": true + } + } + } + ] +} diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/storage-secure-transfer/should-fail.json b/.claude/skills/tirith-migrate/examples/azure-policy/storage-secure-transfer/should-fail.json new file mode 100644 index 00000000..f1f8ea3f --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/azure-policy/storage-secure-transfer/should-fail.json @@ -0,0 +1,53 @@ +{ + "format_version": "1.2", + "terraform_version": "1.9.5", + "resource_changes": [ + { + "address": "azurerm_resource_group.rg", + "mode": "managed", + "type": "azurerm_resource_group", + "name": "rg", + "provider_name": "registry.terraform.io/hashicorp/azurerm", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "name": "rg-app", + "location": "westeurope", + "tags": null + }, + "after_unknown": { + "id": true + } + } + }, + { + "address": "azurerm_storage_account.sa", + "mode": "managed", + "type": "azurerm_storage_account", + "name": "sa", + "provider_name": "registry.terraform.io/hashicorp/azurerm", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "name": "stapp001", + "location": "westeurope", + "resource_group_name": "rg-app", + "account_tier": "Standard", + "account_replication_type": "LRS", + "https_traffic_only_enabled": false, + "min_tls_version": "TLS1_2", + "tags": null + }, + "after_unknown": { + "id": true + } + } + } + ] +} diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/storage-secure-transfer/should-pass.json b/.claude/skills/tirith-migrate/examples/azure-policy/storage-secure-transfer/should-pass.json new file mode 100644 index 00000000..751ccc02 --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/azure-policy/storage-secure-transfer/should-pass.json @@ -0,0 +1,53 @@ +{ + "format_version": "1.2", + "terraform_version": "1.9.5", + "resource_changes": [ + { + "address": "azurerm_resource_group.rg", + "mode": "managed", + "type": "azurerm_resource_group", + "name": "rg", + "provider_name": "registry.terraform.io/hashicorp/azurerm", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "name": "rg-app", + "location": "westeurope", + "tags": null + }, + "after_unknown": { + "id": true + } + } + }, + { + "address": "azurerm_storage_account.sa", + "mode": "managed", + "type": "azurerm_storage_account", + "name": "sa", + "provider_name": "registry.terraform.io/hashicorp/azurerm", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "name": "stapp001", + "location": "westeurope", + "resource_group_name": "rg-app", + "account_tier": "Standard", + "account_replication_type": "LRS", + "https_traffic_only_enabled": true, + "min_tls_version": "TLS1_2", + "tags": null + }, + "after_unknown": { + "id": true + } + } + } + ] +} diff --git a/.claude/skills/tirith-migrate/examples/azure-policy/storage-secure-transfer/source.json b/.claude/skills/tirith-migrate/examples/azure-policy/storage-secure-transfer/source.json new file mode 100644 index 00000000..51778218 --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/azure-policy/storage-secure-transfer/source.json @@ -0,0 +1,66 @@ +{ + "properties": { + "displayName": "Secure transfer to storage accounts should be enabled", + "policyType": "BuiltIn", + "mode": "Indexed", + "description": "Audit requirement of Secure transfer in your storage account. Secure transfer is an option that forces your storage account to accept requests only from secure connections (HTTPS). Use of HTTPS ensures authentication between the server and the service and protects data in transit from network layer attacks such as man-in-the-middle, eavesdropping, and session-hijacking", + "metadata": { + "version": "2.0.0", + "category": "Storage" + }, + "version": "2.0.0", + "parameters": { + "effect": { + "type": "string", + "defaultValue": "Audit", + "allowedValues": [ + "Audit", + "Deny", + "Disabled" + ], + "metadata": { + "displayName": "Effect", + "description": "The effect determines what happens when the policy rule is evaluated to match" + } + } + }, + "policyRule": { + "if": { + "allOf": [ + { + "field": "type", + "equals": "Microsoft.Storage/storageAccounts" + }, + { + "anyOf": [ + { + "allOf": [ + { + "value": "[requestContext().apiVersion]", + "less": "2019-04-01" + }, + { + "field": "Microsoft.Storage/storageAccounts/supportsHttpsTrafficOnly", + "exists": "false" + } + ] + }, + { + "field": "Microsoft.Storage/storageAccounts/supportsHttpsTrafficOnly", + "equals": "false" + } + ] + } + ] + }, + "then": { + "effect": "[parameters('effect')]" + } + }, + "versions": [ + "2.0.0" + ] + }, + "id": "/providers/Microsoft.Authorization/policyDefinitions/404c3081-a854-4457-ae30-26a93ef643f9", + "name": "404c3081-a854-4457-ae30-26a93ef643f9" +} \ No newline at end of file diff --git a/.claude/skills/tirith-migrate/examples/sentinel/README.md b/.claude/skills/tirith-migrate/examples/sentinel/README.md new file mode 100644 index 00000000..b9326180 --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/sentinel/README.md @@ -0,0 +1,13 @@ +# Sentinel migrations + +Five translations, one per fidelity story. Each directory holds `source.sentinel`, `notes.md`, +and where a translation exists, `policy.json` with `should-fail.json` and `should-pass.json`. +Approximate translations add `diverges.json`, a plan where Sentinel and Tirith disagree. + +```bash +cd restrict-instance-type +tirith -policy-path policy.json -input-path should-fail.json --fail-on-error -var-path variables.json; echo $? # 3 +tirith -policy-path policy.json -input-path should-pass.json --fail-on-error -var-path variables.json; echo $? # 0 +``` + +The Sentinel sources are short originals written in the idioms of `hashicorp/terraform-sentinel-policies`. diff --git a/.claude/skills/tirith-migrate/examples/sentinel/mandatory-tags/notes.md b/.claude/skills/tirith-migrate/examples/sentinel/mandatory-tags/notes.md new file mode 100644 index 00000000..dcde17d7 --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/sentinel/mandatory-tags/notes.md @@ -0,0 +1,14 @@ +# mandatory-tags: exact + +The Sentinel loops over two lists: resource types and tag keys. Tirith has no loops, so the product +is written out: one evaluator per (type, key), six in all, joined with `&&`. Verbose, but exact: +each evaluator ranges over every resource of its type, and `&&` over independently quantified +evaluators is the Sentinel `all`. + +`Contains "Owner"` on the `tags` attribute tests the map's keys. Verified against the engine. +`error_tolerance: 1` skips a type that is absent from the plan. + +| Plan | Sentinel | Tirith | +| --- | --- | --- | +| `should-fail.json` (instance lacks CostCenter) | fail | exit 3 | +| `should-pass.json` | pass | exit 0 | diff --git a/.claude/skills/tirith-migrate/examples/sentinel/mandatory-tags/policy.json b/.claude/skills/tirith-migrate/examples/sentinel/mandatory-tags/policy.json new file mode 100644 index 00000000..65033f04 --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/sentinel/mandatory-tags/policy.json @@ -0,0 +1,18 @@ +{ + "meta": { + "version": "v1", + "required_provider": "stackguardian/terraform_plan", + "name": "mandatory-tags", + "description": "aws_instance and aws_s3_bucket carry Name, Owner and CostCenter tags", + "enforcement": "hard-mandatory" + }, + "evaluators": [ + {"id": "instance_name", "provider_args": {"operation_type": "attribute", "terraform_resource_type": "aws_instance", "terraform_resource_attribute": "tags"}, "condition": {"type": "Contains", "value": "Name", "error_tolerance": 1}}, + {"id": "instance_owner", "provider_args": {"operation_type": "attribute", "terraform_resource_type": "aws_instance", "terraform_resource_attribute": "tags"}, "condition": {"type": "Contains", "value": "Owner", "error_tolerance": 1}}, + {"id": "instance_costcenter", "provider_args": {"operation_type": "attribute", "terraform_resource_type": "aws_instance", "terraform_resource_attribute": "tags"}, "condition": {"type": "Contains", "value": "CostCenter", "error_tolerance": 1}}, + {"id": "bucket_name", "provider_args": {"operation_type": "attribute", "terraform_resource_type": "aws_s3_bucket", "terraform_resource_attribute": "tags"}, "condition": {"type": "Contains", "value": "Name", "error_tolerance": 1}}, + {"id": "bucket_owner", "provider_args": {"operation_type": "attribute", "terraform_resource_type": "aws_s3_bucket", "terraform_resource_attribute": "tags"}, "condition": {"type": "Contains", "value": "Owner", "error_tolerance": 1}}, + {"id": "bucket_costcenter", "provider_args": {"operation_type": "attribute", "terraform_resource_type": "aws_s3_bucket", "terraform_resource_attribute": "tags"}, "condition": {"type": "Contains", "value": "CostCenter", "error_tolerance": 1}} + ], + "eval_expression": "instance_name && instance_owner && instance_costcenter && bucket_name && bucket_owner && bucket_costcenter" +} diff --git a/.claude/skills/tirith-migrate/examples/sentinel/mandatory-tags/should-fail.json b/.claude/skills/tirith-migrate/examples/sentinel/mandatory-tags/should-fail.json new file mode 100644 index 00000000..ee951b1c --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/sentinel/mandatory-tags/should-fail.json @@ -0,0 +1,49 @@ +{ + "format_version": "1.2", + "terraform_version": "1.5.7", + "resource_changes": [ + { + "address": "aws_instance.web", + "mode": "managed", + "type": "aws_instance", + "name": "web", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "instance_type": "t3.micro", + "tags": { + "Name": "web", + "Owner": "platform" + } + }, + "after_unknown": {} + } + }, + { + "address": "aws_s3_bucket.logs", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "logs", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "bucket": "logs", + "tags": { + "Name": "logs", + "Owner": "platform", + "CostCenter": "cc-42" + } + }, + "after_unknown": {} + } + } + ] +} diff --git a/.claude/skills/tirith-migrate/examples/sentinel/mandatory-tags/should-pass.json b/.claude/skills/tirith-migrate/examples/sentinel/mandatory-tags/should-pass.json new file mode 100644 index 00000000..0a9aeaf9 --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/sentinel/mandatory-tags/should-pass.json @@ -0,0 +1,50 @@ +{ + "format_version": "1.2", + "terraform_version": "1.5.7", + "resource_changes": [ + { + "address": "aws_instance.web", + "mode": "managed", + "type": "aws_instance", + "name": "web", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "instance_type": "t3.micro", + "tags": { + "Name": "web", + "Owner": "platform", + "CostCenter": "cc-42" + } + }, + "after_unknown": {} + } + }, + { + "address": "aws_s3_bucket.logs", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "logs", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "bucket": "logs", + "tags": { + "Name": "logs", + "Owner": "platform", + "CostCenter": "cc-42" + } + }, + "after_unknown": {} + } + } + ] +} diff --git a/.claude/skills/tirith-migrate/examples/sentinel/mandatory-tags/source.sentinel b/.claude/skills/tirith-migrate/examples/sentinel/mandatory-tags/source.sentinel new file mode 100644 index 00000000..e1bedae0 --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/sentinel/mandatory-tags/source.sentinel @@ -0,0 +1,19 @@ +# Every resource of the listed types must carry every mandatory tag key. tfplan/v2. +import "tfplan-functions" as plan + +param mandatory_tags default ["Name", "Owner", "CostCenter"] +param resource_types default ["aws_instance", "aws_s3_bucket"] + +violations = {} +for resource_types as type { + resources = plan.find_resources(type) + for resources as address, r { + tags = r.change.after.tags else {} + missing = filter mandatory_tags as t { t not in keys(tags) } + if length(missing) > 0 { + violations[address] = missing + } + } +} + +main = rule { length(violations) is 0 } diff --git a/.claude/skills/tirith-migrate/examples/sentinel/prevent-database-destroy/notes.md b/.claude/skills/tirith-migrate/examples/sentinel/prevent-database-destroy/notes.md new file mode 100644 index 00000000..dbca7546 --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/sentinel/prevent-database-destroy/notes.md @@ -0,0 +1,19 @@ +# prevent-database-destroy: exact + +`find_resources_being_destroyed()` selects resources whose actions contain `"delete"`, which +includes a replacement (`["delete", "create"]`). Tirith's `action` operation emits one result per +action in the list, so the universal form is what matches: `NotEquals "delete"` with no negation. +Every action must be something other than delete, and a replacement's `delete` element fails it. + +The tempting form, `ContainedIn ["delete"]` with `!` in the expression, is a different policy: on a +replacement it yields one pass and one fail, the evaluator fails, and `!` flips that to a pass. Use +it only when the source policy deliberately allows replacements. + +`error_tolerance: 1` skips a plan with no `aws_db_instance`, which Sentinel's empty filter also +passed. Without it the guard exits `3` on every plan that has no database. + +| Plan | Sentinel | Tirith | +| --- | --- | --- | +| `should-fail.json` (pure delete) | fail | exit 3 | +| `should-fail-replacement.json` (delete and create) | fail | exit 3 | +| `should-pass.json` (in-place update) | pass | exit 0 | diff --git a/.claude/skills/tirith-migrate/examples/sentinel/prevent-database-destroy/policy.json b/.claude/skills/tirith-migrate/examples/sentinel/prevent-database-destroy/policy.json new file mode 100644 index 00000000..d074ac4a --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/sentinel/prevent-database-destroy/policy.json @@ -0,0 +1,25 @@ +{ + "meta": { + "version": "v1", + "required_provider": "stackguardian/terraform_plan", + "name": "prevent-database-destroy", + "description": "No aws_db_instance is deleted by this plan", + "enforcement": "hard-mandatory" + }, + "evaluators": [ + { + "id": "no_database_delete", + "description": "Every action on every aws_db_instance is something other than delete", + "provider_args": { + "operation_type": "action", + "terraform_resource_type": "aws_db_instance" + }, + "condition": { + "type": "NotEquals", + "value": "delete", + "error_tolerance": 1 + } + } + ], + "eval_expression": "no_database_delete" +} diff --git a/.claude/skills/tirith-migrate/examples/sentinel/prevent-database-destroy/should-fail-replacement.json b/.claude/skills/tirith-migrate/examples/sentinel/prevent-database-destroy/should-fail-replacement.json new file mode 100644 index 00000000..789d9940 --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/sentinel/prevent-database-destroy/should-fail-replacement.json @@ -0,0 +1,28 @@ +{ + "format_version": "1.2", + "terraform_version": "1.5.7", + "resource_changes": [ + { + "address": "aws_db_instance.main", + "mode": "managed", + "type": "aws_db_instance", + "name": "main", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "delete", + "create" + ], + "before": { + "identifier": "main", + "engine": "postgres" + }, + "after": { + "identifier": "main", + "engine": "postgres" + }, + "after_unknown": {} + } + } + ] +} diff --git a/.claude/skills/tirith-migrate/examples/sentinel/prevent-database-destroy/should-fail.json b/.claude/skills/tirith-migrate/examples/sentinel/prevent-database-destroy/should-fail.json new file mode 100644 index 00000000..c4df5650 --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/sentinel/prevent-database-destroy/should-fail.json @@ -0,0 +1,24 @@ +{ + "format_version": "1.2", + "terraform_version": "1.5.7", + "resource_changes": [ + { + "address": "aws_db_instance.main", + "mode": "managed", + "type": "aws_db_instance", + "name": "main", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "delete" + ], + "before": { + "identifier": "main", + "engine": "postgres" + }, + "after": null, + "after_unknown": {} + } + } + ] +} diff --git a/.claude/skills/tirith-migrate/examples/sentinel/prevent-database-destroy/should-pass.json b/.claude/skills/tirith-migrate/examples/sentinel/prevent-database-destroy/should-pass.json new file mode 100644 index 00000000..5e06a5b2 --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/sentinel/prevent-database-destroy/should-pass.json @@ -0,0 +1,29 @@ +{ + "format_version": "1.2", + "terraform_version": "1.5.7", + "resource_changes": [ + { + "address": "aws_db_instance.main", + "mode": "managed", + "type": "aws_db_instance", + "name": "main", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "update" + ], + "before": { + "identifier": "main", + "engine": "postgres", + "allocated_storage": 50 + }, + "after": { + "identifier": "main", + "engine": "postgres", + "allocated_storage": 100 + }, + "after_unknown": {} + } + } + ] +} diff --git a/.claude/skills/tirith-migrate/examples/sentinel/prevent-database-destroy/source.sentinel b/.claude/skills/tirith-migrate/examples/sentinel/prevent-database-destroy/source.sentinel new file mode 100644 index 00000000..c8bf70be --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/sentinel/prevent-database-destroy/source.sentinel @@ -0,0 +1,7 @@ +# No aws_db_instance may be destroyed. tfplan/v2, common-functions idiom. +import "tfplan-functions" as plan + +destroyed = plan.find_resources_being_destroyed() +databases = filter destroyed as address, rc { rc.type is "aws_db_instance" } + +main = rule { length(databases) is 0 } diff --git a/.claude/skills/tirith-migrate/examples/sentinel/require-private-registry-modules/notes.md b/.claude/skills/tirith-migrate/examples/sentinel/require-private-registry-modules/notes.md new file mode 100644 index 00000000..6f27b6cf --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/sentinel/require-private-registry-modules/notes.md @@ -0,0 +1,14 @@ +# require-private-registry-modules: not expressible + +**What it enforces.** Every `module` call's `source` starts with `app.terraform.io/acme/`. + +**What Tirith cannot see.** Module calls are configuration, read by Sentinel through `tfconfig/v2`. +A Terraform plan's `resource_changes` records resources, not the modules that declared them, and +Tirith's plan provider reads only `resource_changes` and `configuration.provider_config`. There is +no attribute, on any resource, that carries the module source. + +**What would change that.** Issue #348 proposes a `terraform_code` provider that reads HCL. Until +it exists, keep this policy in Sentinel or enforce it where modules are resolved. + +No `policy.json` is shipped for this example, on purpose. A policy that checked something adjacent, +say that every resource address contains `module.`, would pass CI and enforce nothing. diff --git a/.claude/skills/tirith-migrate/examples/sentinel/require-private-registry-modules/source.sentinel b/.claude/skills/tirith-migrate/examples/sentinel/require-private-registry-modules/source.sentinel new file mode 100644 index 00000000..8263932d --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/sentinel/require-private-registry-modules/source.sentinel @@ -0,0 +1,10 @@ +# Every module call must come from the organisation's private registry. tfconfig/v2. +import "tfconfig-functions" as config +import "strings" + +allModules = config.find_all_module_calls() +violations = filter allModules as address, m { + not strings.has_prefix(m.source, "app.terraform.io/acme/") +} + +main = rule { length(violations) is 0 } diff --git a/.claude/skills/tirith-migrate/examples/sentinel/restrict-instance-type/notes.md b/.claude/skills/tirith-migrate/examples/sentinel/restrict-instance-type/notes.md new file mode 100644 index 00000000..4929caca --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/sentinel/restrict-instance-type/notes.md @@ -0,0 +1,13 @@ +# restrict-instance-type: exact + +`filter_attribute_not_in_list(resources, "instance_type", allowed_types)` returns the violators. +The Tirith condition is the desired state: `ContainedIn allowed_types`. + +`param allowed_types` becomes `{{ var.allowed_types }}` and lives in `variables.json`, so the same +policy serves every environment. `error_tolerance: 1` skips a plan with no `aws_instance`, which is +what the Sentinel `length(...) is 0` did on an empty set. + +| Plan | Sentinel | Tirith | +| --- | --- | --- | +| `should-fail.json` (an `m5.24xlarge`) | fail | exit 3 | +| `should-pass.json` | pass | exit 0 | diff --git a/.claude/skills/tirith-migrate/examples/sentinel/restrict-instance-type/policy.json b/.claude/skills/tirith-migrate/examples/sentinel/restrict-instance-type/policy.json new file mode 100644 index 00000000..aac062fc --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/sentinel/restrict-instance-type/policy.json @@ -0,0 +1,20 @@ +{ + "meta": { + "version": "v1", + "required_provider": "stackguardian/terraform_plan", + "name": "restrict-instance-type", + "description": "aws_instance.instance_type must be in the allow-list", + "enforcement": "hard-mandatory" + }, + "evaluators": [{ + "id": "instance_type_allowed", + "description": "instance_type is one of allowed_types", + "provider_args": { + "operation_type": "attribute", + "terraform_resource_type": "aws_instance", + "terraform_resource_attribute": "instance_type" + }, + "condition": {"type": "ContainedIn", "value": "{{ var.allowed_types }}", "error_tolerance": 1} + }], + "eval_expression": "instance_type_allowed" +} diff --git a/.claude/skills/tirith-migrate/examples/sentinel/restrict-instance-type/should-fail.json b/.claude/skills/tirith-migrate/examples/sentinel/restrict-instance-type/should-fail.json new file mode 100644 index 00000000..a2651651 --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/sentinel/restrict-instance-type/should-fail.json @@ -0,0 +1,46 @@ +{ + "format_version": "1.2", + "terraform_version": "1.5.7", + "resource_changes": [ + { + "address": "aws_instance.web", + "mode": "managed", + "type": "aws_instance", + "name": "web", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "instance_type": "t3.micro", + "tags": { + "Name": "web" + } + }, + "after_unknown": {} + } + }, + { + "address": "aws_instance.batch", + "mode": "managed", + "type": "aws_instance", + "name": "batch", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "instance_type": "m5.24xlarge", + "tags": { + "Name": "batch" + } + }, + "after_unknown": {} + } + } + ] +} diff --git a/.claude/skills/tirith-migrate/examples/sentinel/restrict-instance-type/should-pass.json b/.claude/skills/tirith-migrate/examples/sentinel/restrict-instance-type/should-pass.json new file mode 100644 index 00000000..02272182 --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/sentinel/restrict-instance-type/should-pass.json @@ -0,0 +1,46 @@ +{ + "format_version": "1.2", + "terraform_version": "1.5.7", + "resource_changes": [ + { + "address": "aws_instance.web", + "mode": "managed", + "type": "aws_instance", + "name": "web", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "instance_type": "t3.micro", + "tags": { + "Name": "web" + } + }, + "after_unknown": {} + } + }, + { + "address": "aws_instance.api", + "mode": "managed", + "type": "aws_instance", + "name": "api", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "instance_type": "t3.small", + "tags": { + "Name": "api" + } + }, + "after_unknown": {} + } + } + ] +} diff --git a/.claude/skills/tirith-migrate/examples/sentinel/restrict-instance-type/source.sentinel b/.claude/skills/tirith-migrate/examples/sentinel/restrict-instance-type/source.sentinel new file mode 100644 index 00000000..581a583a --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/sentinel/restrict-instance-type/source.sentinel @@ -0,0 +1,12 @@ +# Restrict aws_instance.instance_type to an allow-list. tfplan/v2, common-functions idiom. +import "tfplan-functions" as plan + +param allowed_types default ["t3.micro", "t3.small", "t3.medium"] + +allInstances = plan.find_resources("aws_instance") +violatingInstances = plan.filter_attribute_not_in_list(allInstances, + "instance_type", allowed_types, true) + +main = rule { + length(violatingInstances["messages"]) is 0 +} diff --git a/.claude/skills/tirith-migrate/examples/sentinel/restrict-instance-type/variables.json b/.claude/skills/tirith-migrate/examples/sentinel/restrict-instance-type/variables.json new file mode 100644 index 00000000..48af14c0 --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/sentinel/restrict-instance-type/variables.json @@ -0,0 +1 @@ +{"allowed_types": ["t3.micro", "t3.small", "t3.medium"]} diff --git a/.claude/skills/tirith-migrate/examples/sentinel/restrict-ssh-ingress/diverges.json b/.claude/skills/tirith-migrate/examples/sentinel/restrict-ssh-ingress/diverges.json new file mode 100644 index 00000000..c71ea8bc --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/sentinel/restrict-ssh-ingress/diverges.json @@ -0,0 +1,44 @@ +{ + "format_version": "1.2", + "terraform_version": "1.5.7", + "resource_changes": [ + { + "address": "aws_security_group.web", + "mode": "managed", + "type": "aws_security_group", + "name": "web", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "name": "web", + "ingress": [ + { + "from_port": 443, + "to_port": 443, + "protocol": "tcp", + "cidr_blocks": [ + "0.0.0.0/0" + ], + "ipv6_cidr_blocks": [] + }, + { + "from_port": 22, + "to_port": 22, + "protocol": "tcp", + "cidr_blocks": [ + "10.0.0.0/8" + ], + "ipv6_cidr_blocks": [] + } + ], + "egress": [] + }, + "after_unknown": {} + } + } + ] +} diff --git a/.claude/skills/tirith-migrate/examples/sentinel/restrict-ssh-ingress/notes.md b/.claude/skills/tirith-migrate/examples/sentinel/restrict-ssh-ingress/notes.md new file mode 100644 index 00000000..5139705f --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/sentinel/restrict-ssh-ingress/notes.md @@ -0,0 +1,18 @@ +# restrict-ssh-ingress: approximate, stricter + +The Sentinel binds three tests to the same ingress block: port range covers 22, and cidr is +`0.0.0.0/0`. Tirith evaluators are per resource and `eval_expression` combines verdicts already +collapsed across all resources, so nothing can say "the block where both hold". The translation +keeps the test that carries the intent, `0.0.0.0/0` in any ingress block, and drops the port. + +The result is stricter: a group that opens 443 to the world and 22 to the VPC passes Sentinel +and fails Tirith. Issue #316 (`resource_filter`) would make this exact. + +| Plan | Sentinel | Tirith | +| --- | --- | --- | +| `should-fail.json` (22 from anywhere) | fail | exit 3 | +| `should-pass.json` (everything internal) | pass | exit 0 | +| `diverges.json` (443 from anywhere, 22 internal) | **pass** | **exit 3** | + +`error_tolerance: 2` skips a group with no ingress blocks, which Sentinel's `any` also passed. A +skipped group next to the bastion leaves the bastion's failure standing: exit `3`. diff --git a/.claude/skills/tirith-migrate/examples/sentinel/restrict-ssh-ingress/policy.json b/.claude/skills/tirith-migrate/examples/sentinel/restrict-ssh-ingress/policy.json new file mode 100644 index 00000000..bfdad998 --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/sentinel/restrict-ssh-ingress/policy.json @@ -0,0 +1,20 @@ +{ + "meta": { + "version": "v1", + "required_provider": "stackguardian/terraform_plan", + "name": "restrict-ssh-ingress", + "description": "No security group ingress block admits 0.0.0.0/0 (stricter than the Sentinel original, which only refused it on port 22)", + "enforcement": "hard-mandatory" + }, + "evaluators": [{ + "id": "ingress_open_to_world", + "description": "Detects 0.0.0.0/0 in any ingress block's cidr_blocks", + "provider_args": { + "operation_type": "attribute", + "terraform_resource_type": "aws_security_group", + "terraform_resource_attribute": "ingress.*.cidr_blocks" + }, + "condition": {"type": "NotContains", "value": "0.0.0.0/0", "error_tolerance": 2} + }], + "eval_expression": "ingress_open_to_world" +} diff --git a/.claude/skills/tirith-migrate/examples/sentinel/restrict-ssh-ingress/should-fail.json b/.claude/skills/tirith-migrate/examples/sentinel/restrict-ssh-ingress/should-fail.json new file mode 100644 index 00000000..675b2fcd --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/sentinel/restrict-ssh-ingress/should-fail.json @@ -0,0 +1,35 @@ +{ + "format_version": "1.2", + "terraform_version": "1.5.7", + "resource_changes": [ + { + "address": "aws_security_group.bastion", + "mode": "managed", + "type": "aws_security_group", + "name": "bastion", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "name": "bastion", + "ingress": [ + { + "from_port": 22, + "to_port": 22, + "protocol": "tcp", + "cidr_blocks": [ + "0.0.0.0/0" + ], + "ipv6_cidr_blocks": [] + } + ], + "egress": [] + }, + "after_unknown": {} + } + } + ] +} diff --git a/.claude/skills/tirith-migrate/examples/sentinel/restrict-ssh-ingress/should-pass.json b/.claude/skills/tirith-migrate/examples/sentinel/restrict-ssh-ingress/should-pass.json new file mode 100644 index 00000000..6dee0faa --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/sentinel/restrict-ssh-ingress/should-pass.json @@ -0,0 +1,44 @@ +{ + "format_version": "1.2", + "terraform_version": "1.5.7", + "resource_changes": [ + { + "address": "aws_security_group.app", + "mode": "managed", + "type": "aws_security_group", + "name": "app", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "name": "app", + "ingress": [ + { + "from_port": 22, + "to_port": 22, + "protocol": "tcp", + "cidr_blocks": [ + "10.0.0.0/8" + ], + "ipv6_cidr_blocks": [] + }, + { + "from_port": 443, + "to_port": 443, + "protocol": "tcp", + "cidr_blocks": [ + "10.0.0.0/8" + ], + "ipv6_cidr_blocks": [] + } + ], + "egress": [] + }, + "after_unknown": {} + } + } + ] +} diff --git a/.claude/skills/tirith-migrate/examples/sentinel/restrict-ssh-ingress/source.sentinel b/.claude/skills/tirith-migrate/examples/sentinel/restrict-ssh-ingress/source.sentinel new file mode 100644 index 00000000..3b7e5c4c --- /dev/null +++ b/.claude/skills/tirith-migrate/examples/sentinel/restrict-ssh-ingress/source.sentinel @@ -0,0 +1,13 @@ +# No security group ingress rule may open port 22 to 0.0.0.0/0. tfplan/v2. +import "tfplan-functions" as plan + +groups = plan.find_resources("aws_security_group") + +violations = filter groups as address, sg { + any sg.change.after.ingress as rule { + rule.from_port <= 22 and rule.to_port >= 22 and + "0.0.0.0/0" in rule.cidr_blocks + } +} + +main = rule { length(violations) is 0 } diff --git a/.claude/skills/tirith-migrate/reference/azure-policy-corpus.md b/.claude/skills/tirith-migrate/reference/azure-policy-corpus.md new file mode 100644 index 00000000..2eae9cfa --- /dev/null +++ b/.claude/skills/tirith-migrate/reference/azure-policy-corpus.md @@ -0,0 +1,504 @@ +# Azure Policy built-ins, as the corpus classified them + +From `StackGuardian/tirith-policy-corpus`: 376 definitions translated to Tirith for ARM templates (json provider, +all approximate because the json provider scopes the document, not the resource) and 110 blocked with a taxonomy +code. Look a built-in up by GUID or display name before translating it. Paths are relative to the corpus repository. + +Taxonomy: T5 per-resource disjunction across attributes · T9 filtered count · T10 value transforms · T14 Kubernetes.Data mode · +T18 per-resource scoping predicate (issue #316) · T19 assignment-time parameter with no default · T0 vacuous upstream check. + +## Translated + +| GUID | Display name | Corpus path | +| --- | --- | --- | +| `028282f7-374f-43c4-ac82-334b82c1ad80` | Webapp Slots should use App Service Environment. | `policies/azure-policy/arm/AZPOL_028282f7-374f-43c4-ac82-334b82c1ad80.json` | +| `040732e8-d947-40b8-95d6-854c95024bf8` | Azure Kubernetes Service Private Clusters should be enabled | `policies/azure-policy/arm/AZPOL_040732e8-d947-40b8-95d6-854c95024bf8.json` | +| `051cba44-2429-45b9-9649-46cec11c7119` | Azure API for FHIR should use a customer-managed key to encrypt data at rest | `policies/azure-policy/arm/AZPOL_051cba44-2429-45b9-9649-46cec11c7119.json` | +| `055aa869-bc98-4af8-bafc-23f1ab6ffe2c` | Azure Web Application Firewall should be enabled for Azure Front Door entry-points | `policies/azure-policy/arm/AZPOL_055aa869-bc98-4af8-bafc-23f1ab6ffe2c.json` | +| `05f397c0-4504-4e68-ae17-80ef283f6a5d` | Public network access on App Configuration should be SecuredByPerimeter | `policies/azure-policy/arm/AZPOL_05f397c0-4504-4e68-ae17-80ef283f6a5d.json` | +| `0602787f-9896-402a-a6e1-39ee63ee435e` | Event Hub Namespaces should disable public network access | `policies/azure-policy/arm/AZPOL_0602787f-9896-402a-a6e1-39ee63ee435e.json` | +| `0656cf40-485c-427b-b992-703a4ecf4f88` | Azure Managed Grafana workspaces should disable service account | `policies/azure-policy/arm/AZPOL_0656cf40-485c-427b-b992-703a4ecf4f88.json` | +| `06a78e20-9358-41c9-923c-fb736d382a4d` | Audit VMs that do not use managed disks | `policies/azure-policy/arm/AZPOL_06a78e20-9358-41c9-923c-fb736d382a4d.json` | +| `0725b4dd-7e76-479c-a735-68e7ee23d5ca` | [Deprecated]: Cognitive Services accounts should disable public network access | `policies/azure-policy/arm/AZPOL_0725b4dd-7e76-479c-a735-68e7ee23d5ca.json` | +| `09aa11bb-87ec-409f-bf0b-49b7c1561a87` | Azure Cache for Redis Enterprise should use customer-managed keys for encrypting disk data | `policies/azure-policy/arm/AZPOL_09aa11bb-87ec-409f-bf0b-49b7c1561a87.json` | +| `0a15ec92-a229-4763-bb14-0ea34a568f8d` | Azure Policy Add-on for Kubernetes service (AKS) should be installed and enabled on your clusters | `policies/azure-policy/arm/AZPOL_0a15ec92-a229-4763-bb14-0ea34a568f8d.json` | +| `0aa61e00-0a01-4a3c-9945-e93cffedf0e6` | Azure Container Instance container group should use customer-managed key for encryption | `policies/azure-policy/arm/AZPOL_0aa61e00-0a01-4a3c-9945-e93cffedf0e6.json` | +| `0b60c0b2-2dc2-4e1c-b5c9-abbed971de53` | Key vaults should have deletion protection enabled | `policies/azure-policy/arm/AZPOL_0b60c0b2-2dc2-4e1c-b5c9-abbed971de53.json` | +| `0c192fe8-9cbb-4516-85b3-0ade8bd03886` | [Deprecated]: API apps should have 'Client Certificates (Incoming client certificates)' enabled | `policies/azure-policy/arm/AZPOL_0c192fe8-9cbb-4516-85b3-0ade8bd03886.json` | +| `0c28c3fb-c244-42d5-a9bf-f35f2999577b` | Azure SQL Managed Instance should have Microsoft Entra-only authentication enabled | `policies/azure-policy/arm/AZPOL_0c28c3fb-c244-42d5-a9bf-f35f2999577b.json` | +| `0c4bd2e8-8872-4f37-a654-03f6f38ddc76` | Application Insights components with Private Link enabled should use Bring Your Own Storage accounts for profiler and debugger. | `policies/azure-policy/arm/AZPOL_0c4bd2e8-8872-4f37-a654-03f6f38ddc76.json` | +| `0d6d79a8-8406-4e87-814d-2dcd83b2c355` | [Preview]: Microsoft Managed DevOps Pools should be provided with valid subnet resource in order to configure with own virtual network. | `policies/azure-policy/arm/AZPOL_0d6d79a8-8406-4e87-814d-2dcd83b2c355.json` | +| `0e3ccb86-ad68-43a5-8cd0-42035638b199` | App Service app slots should disable public network access. | `policies/azure-policy/arm/AZPOL_0e3ccb86-ad68-43a5-8cd0-42035638b199.json` | +| `0e7849de-b939-4c50-ab48-fc6b0f5eeba2` | Azure Databricks Workspaces should disable public network access | `policies/azure-policy/arm/AZPOL_0e7849de-b939-4c50-ab48-fc6b0f5eeba2.json` | +| `0e80e269-43a4-4ae9-b5bc-178126b8a5cb` | Container Apps should only be accessible over HTTPS | `policies/azure-policy/arm/AZPOL_0e80e269-43a4-4ae9-b5bc-178126b8a5cb.json` | +| `0ec47710-77ff-4a3d-9181-6aa50af424d0` | Geo-redundant backup should be enabled for Azure Database for MariaDB | `policies/azure-policy/arm/AZPOL_0ec47710-77ff-4a3d-9181-6aa50af424d0.json` | +| `0f2d8593-4667-4932-acca-6a9f187af109` | [Preview]: Audit Azure Spring Cloud instances where distributed tracing is not enabled | `policies/azure-policy/arm/AZPOL_0f2d8593-4667-4932-acca-6a9f187af109.json` | +| `0fc92280-604b-4f23-9e04-5ef98d1a28df` | [Preview]: SQL Managed Instances should be Zone Redundant | `policies/azure-policy/arm/AZPOL_0fc92280-604b-4f23-9e04-5ef98d1a28df.json` | +| `0fd9915e-cab3-4f24-b200-6e20e1aa276a` | Lab Services should require non-admin user for labs | `policies/azure-policy/arm/AZPOL_0fd9915e-cab3-4f24-b200-6e20e1aa276a.json` | +| `0fdf0491-d080-4575-b627-ad0e843cba0f` | Public network access should be disabled for Container registries | `policies/azure-policy/arm/AZPOL_0fdf0491-d080-4575-b627-ad0e843cba0f.json` | +| `0fea8f8a-4169-495d-8307-30ec335f387d` | CORS should not allow every domain to access your API for FHIR | `policies/azure-policy/arm/AZPOL_0fea8f8a-4169-495d-8307-30ec335f387d.json` | +| `11c82d0c-db9f-4d7b-97c5-f3f9aa957da2` | Function app slots should disable public network access | `policies/azure-policy/arm/AZPOL_11c82d0c-db9f-4d7b-97c5-f3f9aa957da2.json` | +| `12339a85-a25c-4f17-9f82-4766f13f5c4c` | Azure Cosmos DB accounts should not allow traffic from all Azure data centers | `policies/azure-policy/arm/AZPOL_12339a85-a25c-4f17-9f82-4766f13f5c4c.json` | +| `123aed70-491a-4f07-a569-e1f3a8dd651e` | App Service app slots should enable end to end encryption | `policies/azure-policy/arm/AZPOL_123aed70-491a-4f07-a569-e1f3a8dd651e.json` | +| `12430be1-6cc8-4527-a9a8-e3d38f250096` | Web Application Firewall (WAF) should use the specified mode for Application Gateway | `policies/azure-policy/arm/AZPOL_12430be1-6cc8-4527-a9a8-e3d38f250096.json` | +| `12c74c95-0efd-48da-b8d9-2a7d68470c92` | PostgreSQL flexible servers should use customer-managed keys to encrypt data at rest | `policies/azure-policy/arm/AZPOL_12c74c95-0efd-48da-b8d9-2a7d68470c92.json` | +| `12d4fa5e-1f9f-4c21-97a9-b99b3c6611b5` | Azure Key Vault should use RBAC permission model | `policies/azure-policy/arm/AZPOL_12d4fa5e-1f9f-4c21-97a9-b99b3c6611b5.json` | +| `12e7176a-4919-47ef-922b-34eda4c7f0ce` | Azure Arc-enabled kubernetes clusters should be configured with an Azure Arc Private Link Scope | `policies/azure-policy/arm/AZPOL_12e7176a-4919-47ef-922b-34eda4c7f0ce.json` | +| `13bcff5d-f0eb-4ce7-913e-83ad6300376b` | Function app slots should use an Azure file share for its content directory | `policies/azure-policy/arm/AZPOL_13bcff5d-f0eb-4ce7-913e-83ad6300376b.json` | +| `14961b63-a1eb-4378-8725-7e84ca8db0e6` | DICOM Service should use a customer-managed key to encrypt data at rest | `policies/azure-policy/arm/AZPOL_14961b63-a1eb-4378-8725-7e84ca8db0e6.json` | +| `152b15f7-8e1f-4c1f-ab71-8c010ba5dbc0` | Key Vault keys should have an expiration date | `policies/azure-policy/arm/AZPOL_152b15f7-8e1f-4c1f-ab71-8c010ba5dbc0.json` | +| `16fabb5c-7379-4433-8009-042066fa3a16` | Exclude Usage Costs Resources | `policies/azure-policy/arm/AZPOL_16fabb5c-7379-4433-8009-042066fa3a16.json` | +| `1760f9d4-7206-436e-a28f-d9f3a5c8a227` | Azure Batch pools should have disk encryption enabled | `policies/azure-policy/arm/AZPOL_1760f9d4-7206-436e-a28f-d9f3a5c8a227.json` | +| `176b7c36-ac64-4f15-a296-50bd7fafab12` | Do Not Allow M365 resources | `policies/azure-policy/arm/AZPOL_176b7c36-ac64-4f15-a296-50bd7fafab12.json` | +| `199d5677-e4d9-4264-9465-efe1839c06bd` | Application Insights components should block non-Azure Active Directory based ingestion. | `policies/azure-policy/arm/AZPOL_199d5677-e4d9-4264-9465-efe1839c06bd.json` | +| `19ea9d63-adee-4431-a95e-1913c6c1c75f` | [Preview]: Azure Key Vault Managed HSM should disable public network access | `policies/azure-policy/arm/AZPOL_19ea9d63-adee-4431-a95e-1913c6c1c75f.json` | +| `1adadefe-5f21-44f7-b931-a59b54ccdb45` | Azure Event Grid topics should disable public network access | `policies/azure-policy/arm/AZPOL_1adadefe-5f21-44f7-b931-a59b54ccdb45.json` | +| `1b5ef780-c53c-4a64-87f3-bb9c8c8094ba` | App Service apps should disable public network access | `policies/azure-policy/arm/AZPOL_1b5ef780-c53c-4a64-87f3-bb9c8c8094ba.json` | +| `1b8ca024-1d5c-4dec-8995-b1a932b41780` | Public network access on Azure SQL Database should be disabled | `policies/azure-policy/arm/AZPOL_1b8ca024-1d5c-4dec-8995-b1a932b41780.json` | +| `1bc02227-0cb6-4e11-8f53-eb0b22eab7e8` | Application Insights components should block log ingestion and querying from public networks | `policies/azure-policy/arm/AZPOL_1bc02227-0cb6-4e11-8f53-eb0b22eab7e8.json` | +| `1c30f9cd-b84c-49cc-aa2c-9288447cc3b3` | [Preview]: vTPM should be enabled on supported virtual machines | `policies/azure-policy/arm/AZPOL_1c30f9cd-b84c-49cc-aa2c-9288447cc3b3.json` | +| `1cf164be-6819-4a50-b8fa-4bcaa4f98fb6` | Public network access on Azure Data Factory should be disabled | `policies/azure-policy/arm/AZPOL_1cf164be-6819-4a50-b8fa-4bcaa4f98fb6.json` | +| `1dc2fc00-2245-4143-99f4-874c937f13ef` | Azure API Management platform version should be stv2 | `policies/azure-policy/arm/AZPOL_1dc2fc00-2245-4143-99f4-874c937f13ef.json` | +| `1e66c121-a66a-4b1f-9b83-0fd99bf0fc2d` | Key vaults should have soft delete enabled | `policies/azure-policy/arm/AZPOL_1e66c121-a66a-4b1f-9b83-0fd99bf0fc2d.json` | +| `1f68a601-6e6d-4e42-babf-3f643a047ea2` | Azure Monitor Logs clusters should be encrypted with customer-managed key | `policies/azure-policy/arm/AZPOL_1f68a601-6e6d-4e42-babf-3f643a047ea2.json` | +| `1f905d99-2ab7-462c-a6b0-f709acca6c8f` | Azure Cosmos DB accounts should use customer-managed keys to encrypt data at rest | `policies/azure-policy/arm/AZPOL_1f905d99-2ab7-462c-a6b0-f709acca6c8f.json` | +| `1fafeaf6-7927-4059-a50a-8eb2a7a6f2b5` | Logic Apps Integration Service Environment should be encrypted with customer-managed keys | `policies/azure-policy/arm/AZPOL_1fafeaf6-7927-4059-a50a-8eb2a7a6f2b5.json` | +| `1fd32ebd-e4c3-4e13-a54a-d7422d4d95f6` | Azure HDInsight clusters should use encryption at host to encrypt data at rest | `policies/azure-policy/arm/AZPOL_1fd32ebd-e4c3-4e13-a54a-d7422d4d95f6.json` | +| `1fec9658-933f-4b3e-bc95-913ed22d012b` | Azure Data Explorer should use a SKU that supports private link | `policies/azure-policy/arm/AZPOL_1fec9658-933f-4b3e-bc95-913ed22d012b.json` | +| `2154edb9-244f-4741-9970-660785bccdaa` | VM Image Builder templates should use private link | `policies/azure-policy/arm/AZPOL_2154edb9-244f-4741-9970-660785bccdaa.json` | +| `21a8cd35-125e-4d13-b82d-2e19b7208bb7` | Public network access should be disabled for Azure File Sync | `policies/azure-policy/arm/AZPOL_21a8cd35-125e-4d13-b82d-2e19b7208bb7.json` | +| `21c2b371-0891-40a0-82e6-629defc1f36a` | App Service app slot configs should restrict default network access in Mission Platform products and services | `policies/azure-policy/arm/AZPOL_21c2b371-0891-40a0-82e6-629defc1f36a.json` | +| `22888755-d824-4e43-8e0b-42d481836554` | [Preview]: App Service Plans should be Zone Redundant | `policies/azure-policy/arm/AZPOL_22888755-d824-4e43-8e0b-42d481836554.json` | +| `22bee202-a82f-4305-9a2a-6d7f44d4dedb` | Only secure connections to your Azure Cache for Redis should be enabled | `policies/azure-policy/arm/AZPOL_22bee202-a82f-4305-9a2a-6d7f44d4dedb.json` | +| `24b7a1c6-44fe-40cc-a2e6-242d2ef70e98` | App Service app slots should be injected into a virtual network | `policies/azure-policy/arm/AZPOL_24b7a1c6-44fe-40cc-a2e6-242d2ef70e98.json` | +| `24fba194-95d6-48c0-aea7-f65bf859c598` | Infrastructure encryption should be enabled for Azure Database for PostgreSQL servers | `policies/azure-policy/arm/AZPOL_24fba194-95d6-48c0-aea7-f65bf859c598.json` | +| `2514263b-bc0d-4b06-ac3e-f262c0979018` | Immutability must be enabled for backup vaults | `policies/azure-policy/arm/AZPOL_2514263b-bc0d-4b06-ac3e-f262c0979018.json` | +| `25255ddf-ef4f-4283-975a-5590ad111bba` | App Service apps should disable SSH | `policies/azure-policy/arm/AZPOL_25255ddf-ef4f-4283-975a-5590ad111bba.json` | +| `27960feb-a23c-4577-8d36-ef8b5f35e0be` | All flow log resources should be in enabled state | `policies/azure-policy/arm/AZPOL_27960feb-a23c-4577-8d36-ef8b5f35e0be.json` | +| `295fc8b1-dc9f-4f53-9c61-3f313ceab40a` | Service Bus Premium namespaces should use a customer-managed key for encryption | `policies/azure-policy/arm/AZPOL_295fc8b1-dc9f-4f53-9c61-3f313ceab40a.json` | +| `2bdd0062-9d75-436e-89df-487dd8e4b3c7` | [Deprecated]: Cognitive Services accounts should enable data encryption | `policies/azure-policy/arm/AZPOL_2bdd0062-9d75-436e-89df-487dd8e4b3c7.json` | +| `2cc2c3b5-c2f8-45aa-a9e6-f90d85ae8352` | Azure Databricks workspaces should be Premium SKU that supports features like private link, customer-managed key for encryption | `policies/azure-policy/arm/AZPOL_2cc2c3b5-c2f8-45aa-a9e6-f90d85ae8352.json` | +| `2cc2e023-0dac-4046-875b-178f683929d5` | Azure Kubernetes Service Clusters should enable workload identity | `policies/azure-policy/arm/AZPOL_2cc2e023-0dac-4046-875b-178f683929d5.json` | +| `2d048aca-6479-4923-88f5-e2ac295d9af3` | App Service Environment apps should not be reachable over public internet | `policies/azure-policy/arm/AZPOL_2d048aca-6479-4923-88f5-e2ac295d9af3.json` | +| `2d6830fb-07eb-48e7-8c4d-2a442b35f0fb` | Public network access on Azure IoT Hub should be disabled | `policies/azure-policy/arm/AZPOL_2d6830fb-07eb-48e7-8c4d-2a442b35f0fb.json` | +| `2d9dbfa3-927b-4cf0-9d0f-08747f971650` | Managed workspace virtual network on Azure Synapse workspaces should be enabled | `policies/azure-policy/arm/AZPOL_2d9dbfa3-927b-4cf0-9d0f-08747f971650.json` | +| `2e94d99a-8a36-4563-bc77-810d8893b671` | Azure Recovery Services vaults should use customer-managed keys for encrypting backup data | `policies/azure-policy/arm/AZPOL_2e94d99a-8a36-4563-bc77-810d8893b671.json` | +| `2f080164-9f4d-497e-9db6-416dc9f7b48a` | Network Watcher flow logs should have traffic analytics enabled | `policies/azure-policy/arm/AZPOL_2f080164-9f4d-497e-9db6-416dc9f7b48a.json` | +| `2f7c08c2-f671-4282-9fdb-597b6ef2c10d` | [Deprecated]: App Service app slots should have 'Client Certificates (Incoming client certificates)' enabled | `policies/azure-policy/arm/AZPOL_2f7c08c2-f671-4282-9fdb-597b6ef2c10d.json` | +| `31b8092a-36b8-434b-9af7-5ec844364148` | Soft delete must be enabled for Recovery Services Vaults. | `policies/azure-policy/arm/AZPOL_31b8092a-36b8-434b-9af7-5ec844364148.json` | +| `3235e3fc-9982-4886-9346-2dc3535b1496` | App Service app slots should enable outbound non-RFC 1918 traffic to Azure Virtual Network. | `policies/azure-policy/arm/AZPOL_3235e3fc-9982-4886-9346-2dc3535b1496.json` | +| `324c7761-08db-4474-9661-d1039abc92ee` | [Deprecated]: API apps should use an Azure file share for its content directory | `policies/azure-policy/arm/AZPOL_324c7761-08db-4474-9661-d1039abc92ee.json` | +| `32e6bbec-16b6-44c2-be37-c5b672d103cf` | Azure SQL Database should be running TLS version 1.2 or newer | `policies/azure-policy/arm/AZPOL_32e6bbec-16b6-44c2-be37-c5b672d103cf.json` | +| `335d919a-dc24-4a94-b7cb-9f81b1a8156f` | Do Not Allow MCPP resources | `policies/azure-policy/arm/AZPOL_335d919a-dc24-4a94-b7cb-9f81b1a8156f.json` | +| `3484ce98-c0c5-4c83-994b-c5ac24785218` | Azure Synapse workspaces should allow outbound data traffic only to approved targets | `policies/azure-policy/arm/AZPOL_3484ce98-c0c5-4c83-994b-c5ac24785218.json` | +| `356da939-f20a-4bb9-86f8-5db445b0e354` | Configure Azure AI Search services to enforce customer-managed keys to encrypt data at rest | `policies/azure-policy/arm/AZPOL_356da939-f20a-4bb9-86f8-5db445b0e354.json` | +| `3657f5a0-770e-44a3-b44e-9431ba1e9735` | Automation account variables should be encrypted | `policies/azure-policy/arm/AZPOL_3657f5a0-770e-44a3-b44e-9431ba1e9735.json` | +| `3827af20-8f80-4b15-8300-6db0873ec901` | Azure Cache for Redis should not use access keys for authentication | `policies/azure-policy/arm/AZPOL_3827af20-8f80-4b15-8300-6db0873ec901.json` | +| `387140f1-6da9-4741-bcee-3b5edcdfd9ec` | Function apps should enable end to end encryption | `policies/azure-policy/arm/AZPOL_387140f1-6da9-4741-bcee-3b5edcdfd9ec.json` | +| `38d8df46-cf4e-4073-8e03-48c24b29de0d` | Azure Synapse workspaces should disable public network access | `policies/azure-policy/arm/AZPOL_38d8df46-cf4e-4073-8e03-48c24b29de0d.json` | +| `3a58212a-c829-4f13-9872-6371df2fd0b4` | Infrastructure encryption should be enabled for Azure Database for MySQL servers | `policies/azure-policy/arm/AZPOL_3a58212a-c829-4f13-9872-6371df2fd0b4.json` | +| `3aa03346-d8c5-4994-a5bc-7652c2a2aef1` | API Management subscriptions should not be scoped to all APIs | `policies/azure-policy/arm/AZPOL_3aa03346-d8c5-4994-a5bc-7652c2a2aef1.json` | +| `3aa87b5a-7813-4b57-8a43-42dd9df5aaa7` | Azure Active Directory Domain Services managed domains should use TLS 1.2 only mode | `policies/azure-policy/arm/AZPOL_3aa87b5a-7813-4b57-8a43-42dd9df5aaa7.json` | +| `3abeb944-26af-43ee-b83d-32aaf060fb94` | [Deprecated]: Pod Security Policies should be defined on Kubernetes Services | `policies/azure-policy/arm/AZPOL_3abeb944-26af-43ee-b83d-32aaf060fb94.json` | +| `3d9f5e4c-9947-4579-9539-2a7695fbc187` | App Configuration should disable public network access | `policies/azure-policy/arm/AZPOL_3d9f5e4c-9947-4579-9539-2a7695fbc187.json` | +| `3e13d504-9083-4912-b935-39a085db2249` | Lab Services should restrict allowed virtual machine SKU sizes | `policies/azure-policy/arm/AZPOL_3e13d504-9083-4912-b935-39a085db2249.json` | +| `3e1f521a-d037-4709-bdd6-1f532f271a75` | Azure Firewall should be deployed to span multiple Availability Zones | `policies/azure-policy/arm/AZPOL_3e1f521a-d037-4709-bdd6-1f532f271a75.json` | +| `3e90a76f-8f87-4753-9f18-b40f206c2653` | Function app slots should be injected into a virtual network | `policies/azure-policy/arm/AZPOL_3e90a76f-8f87-4753-9f18-b40f206c2653.json` | +| `3f84c9b0-8b64-4208-98d4-6ada96bb49c3` | Azure Firewall Policy should have DNS Proxy Enabled | `policies/azure-policy/arm/AZPOL_3f84c9b0-8b64-4208-98d4-6ada96bb49c3.json` | +| `413923f0-ff16-41ae-8583-90c5c5d9fa8f` | Customer managed key encryption must be used as part of CMK Encryption for Arc SQL managed instances. | `policies/azure-policy/arm/AZPOL_413923f0-ff16-41ae-8583-90c5c5d9fa8f.json` | +| `41c4164d-a848-48d3-b10f-c3076787f0f5` | App Service app slots should enable configuration routing to Azure Virtual Network. | `policies/azure-policy/arm/AZPOL_41c4164d-a848-48d3-b10f-c3076787f0f5.json` | +| `425bea59-a659-4cbb-8d31-34499bd030b8` | Web Application Firewall (WAF) should use the specified mode for Azure Front Door Service | `policies/azure-policy/arm/AZPOL_425bea59-a659-4cbb-8d31-34499bd030b8.json` | +| `42781ec6-6127-4c30-bdfa-fb423a0047d3` | Container registries should have ARM audience token authentication disabled. | `policies/azure-policy/arm/AZPOL_42781ec6-6127-4c30-bdfa-fb423a0047d3.json` | +| `42daa901-5969-47ef-92cb-b75df946195a` | [Preview]: Load Balancers should be Zone Resilient | `policies/azure-policy/arm/AZPOL_42daa901-5969-47ef-92cb-b75df946195a.json` | +| `42daa904-5969-47ef-92fb-b75df946195a` | [Preview]: Container App should be Zone Redundant | `policies/azure-policy/arm/AZPOL_42daa904-5969-47ef-92fb-b75df946195a.json` | +| `438c38d2-3772-465a-a9cc-7a6666a275ce` | Azure Machine Learning Workspaces should disable public network access | `policies/azure-policy/arm/AZPOL_438c38d2-3772-465a-a9cc-7a6666a275ce.json` | +| `43bc7be6-5e69-4b0d-a2bb-e815557ca673` | Public network access on Azure Data Explorer should be disabled | `policies/azure-policy/arm/AZPOL_43bc7be6-5e69-4b0d-a2bb-e815557ca673.json` | +| `43c323f6-0329-4f7c-a19a-6e5a5690d042` | Azure Device Update accounts should use customer-managed key to encrypt data at rest | `policies/azure-policy/arm/AZPOL_43c323f6-0329-4f7c-a19a-6e5a5690d042.json` | +| `450d2877-ebea-41e8-b00c-e286317d21bf` | Azure Kubernetes Service Clusters should enable Microsoft Entra ID integration | `policies/azure-policy/arm/AZPOL_450d2877-ebea-41e8-b00c-e286317d21bf.json` | +| `4598f028-de1f-4694-8751-84dceb5f86b9` | Azure Web Application Firewall on Azure Front Door should have request body inspection enabled | `policies/azure-policy/arm/AZPOL_4598f028-de1f-4694-8751-84dceb5f86b9.json` | +| `46238e2f-3f6f-4589-9f3f-77bed4116e67` | Azure Kubernetes Clusters should use Azure CNI | `policies/azure-policy/arm/AZPOL_46238e2f-3f6f-4589-9f3f-77bed4116e67.json` | +| `46388f67-373c-4018-98d3-2b83172dd13a` | Fluid Relay should use customer-managed keys to encrypt data at rest | `policies/azure-policy/arm/AZPOL_46388f67-373c-4018-98d3-2b83172dd13a.json` | +| `464dbb85-3d5f-4a1d-bb09-95a9b5dd19cf` | [Deprecated]: Require SQL Server version 12.0 | `policies/azure-policy/arm/AZPOL_464dbb85-3d5f-4a1d-bb09-95a9b5dd19cf.json` | +| `465f0161-0087-490a-9ad9-ad6217f4f43a` | Require automatic OS image patching on Virtual Machine Scale Sets | `policies/azure-policy/arm/AZPOL_465f0161-0087-490a-9ad9-ad6217f4f43a.json` | +| `470baccb-7e51-4549-8b1a-3e5be069f663` | Azure Cache for Redis should disable public network access | `policies/azure-policy/arm/AZPOL_470baccb-7e51-4549-8b1a-3e5be069f663.json` | +| `48af4db5-9b8b-401c-8e74-076be876a430` | Geo-redundant backup should be enabled for Azure Database for PostgreSQL | `policies/azure-policy/arm/AZPOL_48af4db5-9b8b-401c-8e74-076be876a430.json` | +| `48c5f1cb-14ad-4797-8e3b-f78ab3f8d700` | Azure Automation account should have local authentication method disabled | `policies/azure-policy/arm/AZPOL_48c5f1cb-14ad-4797-8e3b-f78ab3f8d700.json` | +| `4bd1f3c0-9443-49ad-b8bc-7c17a92b5924` | [Preview]: Backup Vaults should be Zone Redundant | `policies/azure-policy/arm/AZPOL_4bd1f3c0-9443-49ad-b8bc-7c17a92b5924.json` | +| `4c660f31-eafb-408d-a2b3-6ed2260bd26c` | [Preview]: Deny Extended Security Updates (ESUs) license creation or modification. | `policies/azure-policy/arm/AZPOL_4c660f31-eafb-408d-a2b3-6ed2260bd26c.json` | +| `4d080fa5-a6d2-4f98-ba9c-f482d0d335c0` | Azure Health Bots should use customer-managed keys to encrypt data at rest | `policies/azure-policy/arm/AZPOL_4d080fa5-a6d2-4f98-ba9c-f482d0d335c0.json` | +| `4d0bc837-6eff-477e-9ecd-33bf8d4212a5` | Function apps should use an Azure file share for its content directory | `policies/azure-policy/arm/AZPOL_4d0bc837-6eff-477e-9ecd-33bf8d4212a5.json` | +| `4ec52d6d-beb7-40c4-9a9e-fe753254690e` | Azure data factories should be encrypted with a customer-managed key | `policies/azure-policy/arm/AZPOL_4ec52d6d-beb7-40c4-9a9e-fe753254690e.json` | +| `50b83b09-03da-41c1-b656-c293c914862b` | A custom IPsec/IKE policy must be applied to all Azure virtual network gateway connections | `policies/azure-policy/arm/AZPOL_50b83b09-03da-41c1-b656-c293c914862b.json` | +| `50d7880f-06c8-402f-9236-0417e5b1559c` | App Service Environment should have Control and Data Ports bound to ILB | `policies/azure-policy/arm/AZPOL_50d7880f-06c8-402f-9236-0417e5b1559c.json` | +| `510ec8b2-cb9e-461d-b7f3-6b8678c31182` | Public network access for Azure Device Update for IoT Hub accounts should be disabled | `policies/azure-policy/arm/AZPOL_510ec8b2-cb9e-461d-b7f3-6b8678c31182.json` | +| `51522a96-0869-4791-82f3-981000c2c67f` | Bot Service should be encrypted with a customer-managed key | `policies/azure-policy/arm/AZPOL_51522a96-0869-4791-82f3-981000c2c67f.json` | +| `51c1490f-3319-459c-bbbc-7f391bbed753` | Azure Databricks Clusters should disable public IP | `policies/azure-policy/arm/AZPOL_51c1490f-3319-459c-bbbc-7f391bbed753.json` | +| `51cf6de4-d195-4172-abd3-ec28ff386558` | App Service Plans should use App Service Environment. | `policies/azure-policy/arm/AZPOL_51cf6de4-d195-4172-abd3-ec28ff386558.json` | +| `52152f42-0dda-40d9-976e-abb1acdd611e` | Bot Service should have isolated mode enabled | `policies/azure-policy/arm/AZPOL_52152f42-0dda-40d9-976e-abb1acdd611e.json` | +| `524b0254-c285-4903-bee6-bb8126cde579` | Container registries should have exports disabled | `policies/azure-policy/arm/AZPOL_524b0254-c285-4903-bee6-bb8126cde579.json` | +| `5450f5bd-9c72-4390-a9c4-a7aba4edfdd2` | Cosmos DB database accounts should have local authentication methods disabled | `policies/azure-policy/arm/AZPOL_5450f5bd-9c72-4390-a9c4-a7aba4edfdd2.json` | +| `546fe8d2-368d-4029-a418-6af48a7f61e5` | App Service apps should use a SKU that supports private link | `policies/azure-policy/arm/AZPOL_546fe8d2-368d-4029-a418-6af48a7f61e5.json` | +| `56a5ee18-2ae6-4810-86f7-18e39ce5629b` | Azure Automation accounts should use customer-managed keys to encrypt data at rest | `policies/azure-policy/arm/AZPOL_56a5ee18-2ae6-4810-86f7-18e39ce5629b.json` | +| `56fd377d-098c-4f02-8406-81eb055902b8` | IP firewall rules on Azure Synapse workspaces should be removed | `policies/azure-policy/arm/AZPOL_56fd377d-098c-4f02-8406-81eb055902b8.json` | +| `587c79fe-dd04-4a5e-9d0b-f89598c7261b` | Keys should be backed by a hardware security module (HSM) | `policies/azure-policy/arm/AZPOL_587c79fe-dd04-4a5e-9d0b-f89598c7261b.json` | +| `5b9159ae-1701-4a6f-9a7a-aa9c8ddd0580` | Container registries should be encrypted with a customer-managed key | `policies/azure-policy/arm/AZPOL_5b9159ae-1701-4a6f-9a7a-aa9c8ddd0580.json` | +| `5bb220d9-2698-4ee4-8404-b9c30c9df609` | [Deprecated]: App Service apps should have 'Client Certificates (Incoming client certificates)' enabled | `policies/azure-policy/arm/AZPOL_5bb220d9-2698-4ee4-8404-b9c30c9df609.json` | +| `5c345cdf-2049-47e0-b8fe-b0e96bc2df35` | Azure Kubernetes Service Clusters should enable cluster auto-upgrade | `policies/azure-policy/arm/AZPOL_5c345cdf-2049-47e0-b8fe-b0e96bc2df35.json` | +| `5cc7aeef-357e-433d-9627-05354b40f8e2` | Azure SQL Database should restrict or disable Public network access | `policies/azure-policy/arm/AZPOL_5cc7aeef-357e-433d-9627-05354b40f8e2.json` | +| `5d4e3c65-4873-47be-94f3-6f8b953a3598` | Azure Event Hub namespaces should have local authentication methods disabled | `policies/azure-policy/arm/AZPOL_5d4e3c65-4873-47be-94f3-6f8b953a3598.json` | +| `5e5dbe3f-2702-4ffc-8b1e-0cae008a5c71` | Function app slots should only be accessible over HTTPS | `policies/azure-policy/arm/AZPOL_5e5dbe3f-2702-4ffc-8b1e-0cae008a5c71.json` | +| `5e7e928c-8693-4a23-9bf3-1c77b9a8fe97` | Azure Attestation providers should disable public network access | `policies/azure-policy/arm/AZPOL_5e7e928c-8693-4a23-9bf3-1c77b9a8fe97.json` | +| `5e8168db-69e3-4beb-9822-57cb59202a9d` | Bot Service should have public network access disabled | `policies/azure-policy/arm/AZPOL_5e8168db-69e3-4beb-9822-57cb59202a9d.json` | +| `5ee85ce5-e7eb-44d6-b4a2-32a24be1ca54` | [Deprecated]: Allow resource creation only in India data centers | `policies/azure-policy/arm/AZPOL_5ee85ce5-e7eb-44d6-b4a2-32a24be1ca54.json` | +| `5f0c7d88-c7de-45b8-ac49-db49e72eaa78` | Azure Machine Learning workspaces should use user-assigned managed identity | `policies/azure-policy/arm/AZPOL_5f0c7d88-c7de-45b8-ac49-db49e72eaa78.json` | +| `5f78d1de-663e-4a6b-8dd8-791621f3b6d6` | Function apps should provide an auto-generated domain name label scope | `policies/azure-policy/arm/AZPOL_5f78d1de-663e-4a6b-8dd8-791621f3b6d6.json` | +| `60ada4fc-e576-433c-923b-1f7bd61af33d` | Webapps should use App Service Environment. | `policies/azure-policy/arm/AZPOL_60ada4fc-e576-433c-923b-1f7bd61af33d.json` | +| `60d21c4f-21a3-4d94-85f4-b924e6aeeda4` | Storage Accounts should use a virtual network service endpoint | `policies/azure-policy/arm/AZPOL_60d21c4f-21a3-4d94-85f4-b924e6aeeda4.json` | +| `6164527b-e1ee-4882-8673-572f425f5e0a` | Bot Service endpoint should be a valid HTTPS URI | `policies/azure-policy/arm/AZPOL_6164527b-e1ee-4882-8673-572f425f5e0a.json` | +| `6221cac0-bb8d-40f4-9535-5d03f713f054` | [Preview]: SQL Databases should be Zone Redundant | `policies/azure-policy/arm/AZPOL_6221cac0-bb8d-40f4-9535-5d03f713f054.json` | +| `6300012e-e9a4-4649-b41f-a85f5c43be91` | Azure AI Search services should have local authentication methods disabled | `policies/azure-policy/arm/AZPOL_6300012e-e9a4-4649-b41f-a85f5c43be91.json` | +| `6484db87-a62d-4327-9f07-80a2cbdf333a` | [Deprecated]: Firewall Policy Premium should enable the Intrusion Detection and Prevention System (IDPS) | `policies/azure-policy/arm/AZPOL_6484db87-a62d-4327-9f07-80a2cbdf333a.json` | +| `64d314f6-6062-4780-a861-c23e8951bee5` | Azure HDInsight clusters should use customer-managed keys to encrypt data at rest | `policies/azure-policy/arm/AZPOL_64d314f6-6062-4780-a861-c23e8951bee5.json` | +| `655cb504-bcee-4362-bd4c-402e6aa38759` | [Deprecated]: [Deprecated]: Audit missing blob encryption for storage accounts | `policies/azure-policy/arm/AZPOL_655cb504-bcee-4362-bd4c-402e6aa38759.json` | +| `6599ab01-29bc-4852-a6f5-de9e2151714a` | Transparent Data Encryption must be enabled for Arc SQL managed instances. | `policies/azure-policy/arm/AZPOL_6599ab01-29bc-4852-a6f5-de9e2151714a.json` | +| `65c4f833-1f2e-426c-8780-f6d7593bed7a` | Azure load testing resource should use customer-managed keys to encrypt data at rest | `policies/azure-policy/arm/AZPOL_65c4f833-1f2e-426c-8780-f6d7593bed7a.json` | +| `672d56b3-23a7-4a3c-a233-b77ed7777518` | Azure IoT Hub should have local authentication methods disabled for Service Apis | `policies/azure-policy/arm/AZPOL_672d56b3-23a7-4a3c-a233-b77ed7777518.json` | +| `679da822-78a7-4eff-8fff-a899454a9970` | Azure Front Door Standard and Premium should be running minimum TLS version of 1.2 | `policies/azure-policy/arm/AZPOL_679da822-78a7-4eff-8fff-a899454a9970.json` | +| `67da6a60-29e0-471d-b856-4cd2dee72fc0` | Function app slots should provide an auto-generated domain name label scope | `policies/azure-policy/arm/AZPOL_67da6a60-29e0-471d-b856-4cd2dee72fc0.json` | +| `67dcad1a-ec60-45df-8fd0-14c9d29eeaa2` | Azure Event Grid namespaces should disable public network access | `policies/azure-policy/arm/AZPOL_67dcad1a-ec60-45df-8fd0-14c9d29eeaa2.json` | +| `6a92fe1f-0b86-44ae-843d-2db3d2b571ae` | ElasticSan should disable public network access | `policies/azure-policy/arm/AZPOL_6a92fe1f-0b86-44ae-843d-2db3d2b571ae.json` | +| `6c53d030-cc64-46f0-906d-2bc061cd1334` | Log Analytics workspaces should block log ingestion and querying from public networks | `policies/azure-policy/arm/AZPOL_6c53d030-cc64-46f0-906d-2bc061cd1334.json` | +| `6d02d2f7-e38b-4bdc-96f3-adc0a8726abc` | Hotpatch should be enabled for Windows Server Azure Edition VMs | `policies/azure-policy/arm/AZPOL_6d02d2f7-e38b-4bdc-96f3-adc0a8726abc.json` | +| `6d555dd1-86f2-4f1c-8ed7-5abae7c6cbab` | Function apps should only be accessible over HTTPS | `policies/azure-policy/arm/AZPOL_6d555dd1-86f2-4f1c-8ed7-5abae7c6cbab.json` | +| `6ddb1705-c8cf-450e-aa4b-19ad6703c440` | Azure Machine Learning and Ai Studio should use Allow Only Approved Outbound Managed Vnet mode | `policies/azure-policy/arm/AZPOL_6ddb1705-c8cf-450e-aa4b-19ad6703c440.json` | +| `6ea81a52-5ca7-4575-9669-eaa910b7edf8` | Synapse Workspaces should have Microsoft Entra-only authentication enabled | `policies/azure-policy/arm/AZPOL_6ea81a52-5ca7-4575-9669-eaa910b7edf8.json` | +| `6f68b69f-05fe-49cd-b361-777ee9ca7e35` | Batch accounts should have local authentication methods disabled | `policies/azure-policy/arm/AZPOL_6f68b69f-05fe-49cd-b361-777ee9ca7e35.json` | +| `6fc8115b-2008-441f-8c61-9b722c1e537f` | Workbooks should be saved to storage accounts that you control | `policies/azure-policy/arm/AZPOL_6fc8115b-2008-441f-8c61-9b722c1e537f.json` | +| `701a595d-38fb-4a66-ae6d-fb3735217622` | App Service app slots should disable public network access | `policies/azure-policy/arm/AZPOL_701a595d-38fb-4a66-ae6d-fb3735217622.json` | +| `71ef260a-8f18-47b7-abcb-62d0673d94dc` | Azure AI Services resources should have key access disabled (disable local authentication) | `policies/azure-policy/arm/AZPOL_71ef260a-8f18-47b7-abcb-62d0673d94dc.json` | +| `72923a3a-e567-46d3-b3f9-ffb2462a1c3a` | Virtual Hubs should be protected with Azure Firewall | `policies/azure-policy/arm/AZPOL_72923a3a-e567-46d3-b3f9-ffb2462a1c3a.json` | +| `72d04c29-f87d-4575-9731-419ff16a2757` | App Service apps should be injected into a virtual network | `policies/azure-policy/arm/AZPOL_72d04c29-f87d-4575-9731-419ff16a2757.json` | +| `73aa9838-1906-4490-aa32-f7b89a8f4554` | App Service apps should enable outbound non-RFC 1918 traffic to Azure Virtual Network. | `policies/azure-policy/arm/AZPOL_73aa9838-1906-4490-aa32-f7b89a8f4554.json` | +| `73ef9241-5d81-4cd4-b483-8443d1730fe5` | API Management service should use a SKU that supports virtual networks | `policies/azure-policy/arm/AZPOL_73ef9241-5d81-4cd4-b483-8443d1730fe5.json` | +| `74c5a0ae-5e48-4738-b093-65e23a060488` | Public network access should be disabled for Batch accounts | `policies/azure-policy/arm/AZPOL_74c5a0ae-5e48-4738-b093-65e23a060488.json` | +| `75262d3e-ba4a-4f43-85f8-9f72c090e5e3` | Secrets should have content type set | `policies/azure-policy/arm/AZPOL_75262d3e-ba4a-4f43-85f8-9f72c090e5e3.json` | +| `75c4f823-d65c-4f29-a733-01d0077fdbcb` | Keys should be the specified cryptographic type RSA or EC | `policies/azure-policy/arm/AZPOL_75c4f823-d65c-4f29-a733-01d0077fdbcb.json` | +| `7698f4ed-80ce-4e13-b408-ee135fa400a5` | ElasticSan Volume Group should use customer-managed keys to encrypt data at rest | `policies/azure-policy/arm/AZPOL_7698f4ed-80ce-4e13-b408-ee135fa400a5.json` | +| `77d40665-3120-4348-b539-3192ec808307` | Azure Data Factory should use a Git repository for source control | `policies/azure-policy/arm/AZPOL_77d40665-3120-4348-b539-3192ec808307.json` | +| `78215662-041e-49ed-a9dd-5385911b3a1f` | Azure SQL Managed Instances should have Microsoft Entra-only authentication enabled during creation | `policies/azure-policy/arm/AZPOL_78215662-041e-49ed-a9dd-5385911b3a1f.json` | +| `783ea2a8-b8fd-46be-896a-9ae79643a0b1` | Container Apps should disable external network access | `policies/azure-policy/arm/AZPOL_783ea2a8-b8fd-46be-896a-9ae79643a0b1.json` | +| `794d77cc-fe65-4801-8514-230c0be387a8` | Azure Firewall Classic Rules should be migrated to Firewall Policy | `policies/azure-policy/arm/AZPOL_794d77cc-fe65-4801-8514-230c0be387a8.json` | +| `797b37f7-06b8-444c-b1ad-fc62867f335a` | Azure Cosmos DB should disable public network access | `policies/azure-policy/arm/AZPOL_797b37f7-06b8-444c-b1ad-fc62867f335a.json` | +| `7c591a93-c34c-464c-94ac-8f9f9a46e3d6` | Azure Firewall Standard - Classic Rules should enable Threat Intelligence | `policies/azure-policy/arm/AZPOL_7c591a93-c34c-464c-94ac-8f9f9a46e3d6.json` | +| `7c5a74bf-ae94-4a74-8fcf-644d1e0e6e6f` | [Deprecated]: [Deprecated]: Require blob encryption for storage accounts | `policies/azure-policy/arm/AZPOL_7c5a74bf-ae94-4a74-8fcf-644d1e0e6e6f.json` | +| `7d092e0a-7acd-40d2-a975-dca21cae48c4` | [Deprecated]: Azure Cache for Redis should reside within a virtual network | `policies/azure-policy/arm/AZPOL_7d092e0a-7acd-40d2-a975-dca21cae48c4.json` | +| `7d7be79c-23ba-4033-84dd-45e2a5ccdd67` | Both operating systems and data disks in Azure Kubernetes Service clusters should be encrypted by customer-managed keys | `policies/azure-policy/arm/AZPOL_7d7be79c-23ba-4033-84dd-45e2a5ccdd67.json` | +| `80a53f01-1f9a-49af-a5d5-0248e947dc8e` | App Service apps should provide an auto-generated domain name label scope | `policies/azure-policy/arm/AZPOL_80a53f01-1f9a-49af-a5d5-0248e947dc8e.json` | +| `81e74cea-30fd-40d5-802f-d72103c2aaaa` | Azure Data Explorer encryption at rest should use a customer-managed key | `policies/azure-policy/arm/AZPOL_81e74cea-30fd-40d5-802f-d72103c2aaaa.json` | +| `82339799-d096-41ae-8538-b108becf0970` | Geo-redundant backup should be enabled for Azure Database for MySQL | `policies/azure-policy/arm/AZPOL_82339799-d096-41ae-8538-b108becf0970.json` | +| `82909236-25f3-46a6-841c-fe1020f95ae1` | Azure Web PubSub Service should use a SKU that supports private link | `policies/azure-policy/arm/AZPOL_82909236-25f3-46a6-841c-fe1020f95ae1.json` | +| `83a86a26-fd1f-447c-b59d-e51f44264114` | Network interfaces should not have public IPs | `policies/azure-policy/arm/AZPOL_83a86a26-fd1f-447c-b59d-e51f44264114.json` | +| `84497762-32b6-4ab3-80b6-732ea48b85a2` | Container registries should prevent cache rule creation | `policies/azure-policy/arm/AZPOL_84497762-32b6-4ab3-80b6-732ea48b85a2.json` | +| `85b005b2-95fc-4953-b9cb-f9ee6427c754` | [Preview]: Storage Accounts should be Zone Redundant | `policies/azure-policy/arm/AZPOL_85b005b2-95fc-4953-b9cb-f9ee6427c754.json` | +| `85bb39b5-2f66-49f8-9306-77da3ac5130f` | Azure Data Factory integration runtime should have a limit for number of cores | `policies/azure-policy/arm/AZPOL_85bb39b5-2f66-49f8-9306-77da3ac5130f.json` | +| `8632b003-3545-4b29-85e6-b2b96773df1e` | Azure Event Grid partner namespaces should have local authentication methods disabled | `policies/azure-policy/arm/AZPOL_8632b003-3545-4b29-85e6-b2b96773df1e.json` | +| `86b1d4f9-358c-407c-9bb0-52bf287b222f` | App Service apps should enable configuration routing to Azure Virtual Network. | `policies/azure-policy/arm/AZPOL_86b1d4f9-358c-407c-9bb0-52bf287b222f.json` | +| `86efb160-8de7-451d-bc08-5d475b0aadae` | Azure Data Box jobs should use a customer-managed key to encrypt the device unlock password | `policies/azure-policy/arm/AZPOL_86efb160-8de7-451d-bc08-5d475b0aadae.json` | +| `87ac3038-c07a-4b92-860d-29e270a4f3cd` | Azure Virtual Desktop workspaces should disable public network access | `policies/azure-policy/arm/AZPOL_87ac3038-c07a-4b92-860d-29e270a4f3cd.json` | +| `87ba29ef-1ab3-4d82-b763-87fcd4f531f7` | Azure Stream Analytics jobs should use customer-managed keys to encrypt data | `policies/azure-policy/arm/AZPOL_87ba29ef-1ab3-4d82-b763-87fcd4f531f7.json` | +| `88c0b9da-ce96-4b03-9635-f29a937e2900` | Network interfaces should disable IP forwarding | `policies/azure-policy/arm/AZPOL_88c0b9da-ce96-4b03-9635-f29a937e2900.json` | +| `8945ba5e-918e-4a57-8117-fe615d12e3ba` | All Database Admin on Azure Data Explorer should be disabled | `policies/azure-policy/arm/AZPOL_8945ba5e-918e-4a57-8117-fe615d12e3ba.json` | +| `898f2439-3333-4713-af25-f1d78bc50556` | Azure Arc Private Link Scopes should disable public network access | `policies/azure-policy/arm/AZPOL_898f2439-3333-4713-af25-f1d78bc50556.json` | +| `89c8a434-18f0-402c-8147-630a8dea54e0` | App Configuration should use a SKU that supports private link | `policies/azure-policy/arm/AZPOL_89c8a434-18f0-402c-8147-630a8dea54e0.json` | +| `89f2d532-c53c-4f8f-9afa-4927b1114a0d` | Azure Kubernetes Service Clusters should disable Command Invoke | `policies/azure-policy/arm/AZPOL_89f2d532-c53c-4f8f-9afa-4927b1114a0d.json` | +| `8b346db6-85af-419b-8557-92cee2c0f9bb` | Container App environments should use network injection | `policies/azure-policy/arm/AZPOL_8b346db6-85af-419b-8557-92cee2c0f9bb.json` | +| `8bfadddb-ee1c-4639-8911-a38cb8e0b3bd` | Azure Event Grid domains should have local authentication methods disabled | `policies/azure-policy/arm/AZPOL_8bfadddb-ee1c-4639-8911-a38cb8e0b3bd.json` | +| `8bfe3603-0888-404a-87ff-5c1b6b4cc5e3` | [Deprecated]: Azure Media Services accounts should disable public network access | `policies/azure-policy/arm/AZPOL_8bfe3603-0888-404a-87ff-5c1b6b4cc5e3.json` | +| `8c19196d-7fd7-45b2-a9b4-7288f47c769a` | Azure Firewall Standard should be upgraded to Premium for next generation protection | `policies/azure-policy/arm/AZPOL_8c19196d-7fd7-45b2-a9b4-7288f47c769a.json` | +| `8c6a50c6-9ffd-4ae7-986f-5fa6111f9a54` | Storage accounts should prevent shared key access | `policies/azure-policy/arm/AZPOL_8c6a50c6-9ffd-4ae7-986f-5fa6111f9a54.json` | +| `8f09fda1-91a2-4e14-96a2-67c6281158f7` | Do not allow creation of Recovery Services vaults of chosen storage redundancy. | `policies/azure-policy/arm/AZPOL_8f09fda1-91a2-4e14-96a2-67c6281158f7.json` | +| `904152e4-4038-48ae-9f41-b67ff81eda26` | Function apps should be injected into a virtual network | `policies/azure-policy/arm/AZPOL_904152e4-4038-48ae-9f41-b67ff81eda26.json` | +| `90bc8109-d21a-4692-88fc-51419391da3d` | [Preview]: Azure AI Search Service should be Zone Redundant | `policies/azure-policy/arm/AZPOL_90bc8109-d21a-4692-88fc-51419391da3d.json` | +| `9285c3de-d5fd-4225-86d4-027894b0c442` | [Deprecated]: Azure Media Services should use customer-managed keys to encrypt data at rest | `policies/azure-policy/arm/AZPOL_9285c3de-d5fd-4225-86d4-027894b0c442.json` | +| `92a89a79-6c52-4a7e-a03f-61306fc49312` | Storage accounts should prevent cross tenant object replication | `policies/azure-policy/arm/AZPOL_92a89a79-6c52-4a7e-a03f-61306fc49312.json` | +| `92bb331d-ac71-416a-8c91-02f2cb734ce4` | API Management calls to API backends should not bypass certificate thumbprint or name validation | `policies/azure-policy/arm/AZPOL_92bb331d-ac71-416a-8c91-02f2cb734ce4.json` | +| `94c19f19-8192-48cd-a11b-e37099d3e36b` | [Deprecated]: Allow resource creation only in European data centers | `policies/azure-policy/arm/AZPOL_94c19f19-8192-48cd-a11b-e37099d3e36b.json` | +| `94c1f94d-33b0-4062-bd04-1cdc3e7eece2` | Azure Log Search Alerts over Log Analytics workspaces should use customer-managed keys | `policies/azure-policy/arm/AZPOL_94c1f94d-33b0-4062-bd04-1cdc3e7eece2.json` | +| `94de2ad3-e0c1-4caf-ad78-5d47bbc83d3d` | Virtual networks should be protected by Azure DDoS Protection | `policies/azure-policy/arm/AZPOL_94de2ad3-e0c1-4caf-ad78-5d47bbc83d3d.json` | +| `955a914f-bf86-4f0e-acd5-e0766b0efcb6` | Automation accounts should disable public network access | `policies/azure-policy/arm/AZPOL_955a914f-bf86-4f0e-acd5-e0766b0efcb6.json` | +| `96561daf-13ae-4008-9300-638df7e6a11a` | Azure Health Bots should use Azure RBAC as their access control method | `policies/azure-policy/arm/AZPOL_96561daf-13ae-4008-9300-638df7e6a11a.json` | +| `967a4b4b-2da9-43c1-b7d0-f98d0d74d0b1` | App Configuration should use a customer-managed key | `policies/azure-policy/arm/AZPOL_967a4b4b-2da9-43c1-b7d0-f98d0d74d0b1.json` | +| `969ac98b-88a8-449f-883c-2e9adb123127` | Function apps should disable public network access | `policies/azure-policy/arm/AZPOL_969ac98b-88a8-449f-883c-2e9adb123127.json` | +| `970f84d8-71b6-4091-9979-ace7e3fb6dbb` | HPC Cache accounts should use customer-managed key for encryption | `policies/azure-policy/arm/AZPOL_970f84d8-71b6-4091-9979-ace7e3fb6dbb.json` | +| `97566dd7-78ae-4997-8b36-1c7bfe0d8121` | [Preview]: Secure Boot should be enabled on supported Windows virtual machines | `policies/azure-policy/arm/AZPOL_97566dd7-78ae-4997-8b36-1c7bfe0d8121.json` | +| `9798d31d-6028-4dee-8643-46102185c016` | Soft delete should be enabled for Backup Vaults | `policies/azure-policy/arm/AZPOL_9798d31d-6028-4dee-8643-46102185c016.json` | +| `983211ba-f348-4758-983b-21fa29294869` | [Deprecated]: Allow resource creation only in United States data centers | `policies/azure-policy/arm/AZPOL_983211ba-f348-4758-983b-21fa29294869.json` | +| `98728c90-32c7-4049-8429-847dc0f4fe37` | Key Vault secrets should have an expiration date | `policies/azure-policy/arm/AZPOL_98728c90-32c7-4049-8429-847dc0f4fe37.json` | +| `993c2fcd-2b29-49d2-9eb0-df2c3a730c32` | Azure Kubernetes Service Clusters should have local authentication methods disabled | `policies/azure-policy/arm/AZPOL_993c2fcd-2b29-49d2-9eb0-df2c3a730c32.json` | +| `99e9ccd8-3db9-4592-b0d1-14b1715a4d8a` | Azure Batch account should use customer-managed keys to encrypt data | `policies/azure-policy/arm/AZPOL_99e9ccd8-3db9-4592-b0d1-14b1715a4d8a.json` | +| `9ad2fd1f-b25f-47a2-aa01-1a5a779e6413` | Virtual network injection should be enabled for Azure Data Explorer | `policies/azure-policy/arm/AZPOL_9ad2fd1f-b25f-47a2-aa01-1a5a779e6413.json` | +| `9c25c9e4-ee12-4882-afd2-11fb9d87893f` | Azure Databricks Workspaces should be in a virtual network | `policies/azure-policy/arm/AZPOL_9c25c9e4-ee12-4882-afd2-11fb9d87893f.json` | +| `9d2b0a20-57d6-474c-9d12-44a4a20999c6` | [Preview]: Container Registry should be Zone Redundant | `policies/azure-policy/arm/AZPOL_9d2b0a20-57d6-474c-9d12-44a4a20999c6.json` | +| `9db7917b-1607-4e7d-a689-bca978dd0633` | Application definition for Managed Application should use customer provided storage account | `policies/azure-policy/arm/AZPOL_9db7917b-1607-4e7d-a689-bca978dd0633.json` | +| `9dfea752-dd46-4766-aed1-c355fa93fb91` | Azure SQL Managed Instances should disable public network access | `policies/azure-policy/arm/AZPOL_9dfea752-dd46-4766-aed1-c355fa93fb91.json` | +| `9ebbbba3-4d65-4da9-bb67-b22cfaaff090` | Azure Recovery Services vaults should disable public network access | `policies/azure-policy/arm/AZPOL_9ebbbba3-4d65-4da9-bb67-b22cfaaff090.json` | +| `9f2dea28-e834-476c-99c5-3507b4728395` | Container registries should have anonymous authentication disabled. | `policies/azure-policy/arm/AZPOL_9f2dea28-e834-476c-99c5-3507b4728395.json` | +| `9fac9537-cba6-480a-97dc-21a93c1aa055` | Microsoft Planetary Computer Pro GeoCatalogs should use a managed identity | `policies/azure-policy/arm/AZPOL_9fac9537-cba6-480a-97dc-21a93c1aa055.json` | +| `a049bf77-880b-470f-ba6d-9f21c530cf83` | Azure AI Search service should use a SKU that supports private link | `policies/azure-policy/arm/AZPOL_a049bf77-880b-470f-ba6d-9f21c530cf83.json` | +| `a08f2347-fe9c-482b-a944-f6a0e05124c0` | Azure Managed Grafana workspaces should disable Grafana Enterprise upgrade | `policies/azure-policy/arm/AZPOL_a08f2347-fe9c-482b-a944-f6a0e05124c0.json` | +| `a1181c5f-672a-477a-979a-7d58aa086233` | Security Center standard pricing tier should be selected | `policies/azure-policy/arm/AZPOL_a1181c5f-672a-477a-979a-7d58aa086233.json` | +| `a1840de2-8088-4ea8-b153-b4c723e9cb01` | Azure Kubernetes Service clusters should have Defender profile enabled | `policies/azure-policy/arm/AZPOL_a1840de2-8088-4ea8-b153-b4c723e9cb01.json` | +| `a22065a3-3b04-46ff-b84c-2d30e5c300d0` | Azure Virtual Desktop hostpools should disable public network access only on session hosts | `policies/azure-policy/arm/AZPOL_a22065a3-3b04-46ff-b84c-2d30e5c300d0.json` | +| `a451c1ef-c6ca-483d-87ed-f49761e3ffb5` | Audit usage of custom RBAC roles | `policies/azure-policy/arm/AZPOL_a451c1ef-c6ca-483d-87ed-f49761e3ffb5.json` | +| `a46d869a-f759-4404-8289-7737e7a1f79e` | Public network access should be disabled for Azure Maps Accounts. | `policies/azure-policy/arm/AZPOL_a46d869a-f759-4404-8289-7737e7a1f79e.json` | +| `a499fed8-bcc8-4195-b154-641f14743757` | Azure Monitor Private Link Scope should block access to non private link resources | `policies/azure-policy/arm/AZPOL_a499fed8-bcc8-4195-b154-641f14743757.json` | +| `a4af4a39-4135-47fb-b175-47fbdf85311d` | App Service apps should only be accessible over HTTPS | `policies/azure-policy/arm/AZPOL_a4af4a39-4135-47fb-b175-47fbdf85311d.json` | +| `a6e9cf2d-7d76-440e-b795-8da246bd3aab` | Lab Services should enable all options for auto shutdown | `policies/azure-policy/arm/AZPOL_a6e9cf2d-7d76-440e-b795-8da246bd3aab.json` | +| `a7ff3161-0087-490a-9ad9-ad6217f4f43a` | Require encryption on Data Lake Store accounts | `policies/azure-policy/arm/AZPOL_a7ff3161-0087-490a-9ad9-ad6217f4f43a.json` | +| `a8793640-60f7-487c-b5c3-1d37215905c4` | SQL Managed Instance should have the minimal TLS version of 1.2 | `policies/azure-policy/arm/AZPOL_a8793640-60f7-487c-b5c3-1d37215905c4.json` | +| `a88d589f-2b09-4b50-8998-9a4e71d7b746` | App Service app slots should disable SSH | `policies/azure-policy/arm/AZPOL_a88d589f-2b09-4b50-8998-9a4e71d7b746.json` | +| `ac01ad65-10e5-46df-bdd9-6b0cad13e1d2` | SQL managed instances should use customer-managed keys to encrypt data at rest | `policies/azure-policy/arm/AZPOL_ac01ad65-10e5-46df-bdd9-6b0cad13e1d2.json` | +| `ac4a19c2-fa67-49b4-8ae5-0b2e78c49457` | Role-Based Access Control (RBAC) should be used on Kubernetes Services | `policies/azure-policy/arm/AZPOL_ac4a19c2-fa67-49b4-8ae5-0b2e78c49457.json` | +| `ac7e5fc0-c029-4b12-91d4-a8500ce697f9` | [Deprecated]: Allow resource creation if 'environment' tag value in allowed values | `policies/azure-policy/arm/AZPOL_ac7e5fc0-c029-4b12-91d4-a8500ce697f9.json` | +| `ae1b9a8c-dfce-4605-bd91-69213b4a26fc` | App Service app slots should only be accessible over HTTPS | `policies/azure-policy/arm/AZPOL_ae1b9a8c-dfce-4605-bd91-69213b4a26fc.json` | +| `ae243d87-5cf3-4dce-90bd-6d62be328de3` | [Preview]: Backup and Site Recovery should be Zone Redundant | `policies/azure-policy/arm/AZPOL_ae243d87-5cf3-4dce-90bd-6d62be328de3.json` | +| `ae243d87-5cf3-4dce-90bd-6d62be328de9` | [Preview]: Event Hubs should be Zone Redundant | `policies/azure-policy/arm/AZPOL_ae243d87-5cf3-4dce-90bd-6d62be328de9.json` | +| `ae9fb87f-8a17-4428-94a4-8135d431055c` | Azure Event Grid topics should have local authentication methods disabled | `policies/azure-policy/arm/AZPOL_ae9fb87f-8a17-4428-94a4-8135d431055c.json` | +| `aec63c84-f9ea-46c7-9e66-ba567bae0f09` | [Deprecated]: Packet Core Control Plane diagnostic access should only use Microsoft EntraID authentication type | `policies/azure-policy/arm/AZPOL_aec63c84-f9ea-46c7-9e66-ba567bae0f09.json` | +| `af1d7e88-c1c8-4ea8-be1f-87bff0df9101` | App Service apps should enable end to end encryption | `policies/azure-policy/arm/AZPOL_af1d7e88-c1c8-4ea8-be1f-87bff0df9101.json` | +| `af35e2a4-ef96-44e7-a9ae-853dd97032c4` | Azure Spring Cloud should use network injection | `policies/azure-policy/arm/AZPOL_af35e2a4-ef96-44e7-a9ae-853dd97032c4.json` | +| `af3c26b2-6fad-493e-9236-9c68928516ab` | Azure Kubernetes Service Clusters should enable Image Cleaner | `policies/azure-policy/arm/AZPOL_af3c26b2-6fad-493e-9236-9c68928516ab.json` | +| `b03bb370-5249-4ea4-9fce-2552e87e45fa` | Disks and OS image should support TrustedLaunch | `policies/azure-policy/arm/AZPOL_b03bb370-5249-4ea4-9fce-2552e87e45fa.json` | +| `b0779e02-7159-4057-bcbf-bd6132847d2b` | App Service apps should disable public network access. | `policies/azure-policy/arm/AZPOL_b0779e02-7159-4057-bcbf-bd6132847d2b.json` | +| `b08ab3ca-1062-4db3-8803-eec9cae605d6` | App Configuration stores should have local authentication methods disabled | `policies/azure-policy/arm/AZPOL_b08ab3ca-1062-4db3-8803-eec9cae605d6.json` | +| `b2982f36-99f2-4db5-8eff-283140c09693` | Storage accounts should disable public network access | `policies/azure-policy/arm/AZPOL_b2982f36-99f2-4db5-8eff-283140c09693.json` | +| `b3ba835a-f47b-4b3a-8d90-f08b006f2fd0` | App Service apps should use a SKU that supports private link. | `policies/azure-policy/arm/AZPOL_b3ba835a-f47b-4b3a-8d90-f08b006f2fd0.json` | +| `b4ac1030-89c5-4697-8e00-28b5ba6a8811` | Azure Stack Edge devices should use double-encryption | `policies/azure-policy/arm/AZPOL_b4ac1030-89c5-4697-8e00-28b5ba6a8811.json` | +| `b52376f7-9612-48a1-81cd-1ffe4b61032c` | Public network access should be disabled for PostgreSQL servers | `policies/azure-policy/arm/AZPOL_b52376f7-9612-48a1-81cd-1ffe4b61032c.json` | +| `b546c248-6dee-41e6-82f7-978634aeb73d` | App Service app configs should restrict default network access in Mission Platform products and services | `policies/azure-policy/arm/AZPOL_b546c248-6dee-41e6-82f7-978634aeb73d.json` | +| `b54ed75b-3e1a-44ac-a333-05ba39b99ff0` | Service Fabric clusters should only use Azure Active Directory for client authentication | `policies/azure-policy/arm/AZPOL_b54ed75b-3e1a-44ac-a333-05ba39b99ff0.json` | +| `b5c4b32e-8699-4bfa-8868-9ed118e9982f` | App Service Environment apps should not be reachable over public internet. | `policies/azure-policy/arm/AZPOL_b5c4b32e-8699-4bfa-8868-9ed118e9982f.json` | +| `b5ec538c-daa0-4006-8596-35468b9148e8` | Storage account encryption scopes should use customer-managed keys to encrypt data at rest | `policies/azure-policy/arm/AZPOL_b5ec538c-daa0-4006-8596-35468b9148e8.json` | +| `b66ab71c-582d-4330-adfd-ac162e78691e` | Azure Web PubSub Service should have local authentication methods disabled | `policies/azure-policy/arm/AZPOL_b66ab71c-582d-4330-adfd-ac162e78691e.json` | +| `b6752a42-6fc3-46cb-8a15-33aa109407b1` | Azure Managed Grafana workspaces should disable email settings | `policies/azure-policy/arm/AZPOL_b6752a42-6fc3-46cb-8a15-33aa109407b1.json` | +| `b741306c-968e-4b67-b916-5675e5c709f4` | API Management direct management endpoint should not be enabled | `policies/azure-policy/arm/AZPOL_b741306c-968e-4b67-b916-5675e5c709f4.json` | +| `b7ddfbdc-1260-477d-91fd-98bd9be789a6` | [Deprecated]: API App should only be accessible over HTTPS | `policies/azure-policy/arm/AZPOL_b7ddfbdc-1260-477d-91fd-98bd9be789a6.json` | +| `b874ab2d-72dd-47f1-8cb5-4a306478a4e7` | Managed Identity should be enabled for Container Apps | `policies/azure-policy/arm/AZPOL_b874ab2d-72dd-47f1-8cb5-4a306478a4e7.json` | +| `ba769a63-b8cc-4b2d-abf6-ac33c7204be8` | Azure Machine Learning workspaces should be encrypted with a customer-managed key | `policies/azure-policy/arm/AZPOL_ba769a63-b8cc-4b2d-abf6-ac33c7204be8.json` | +| `bb3c7464-033e-41ee-81dc-480fde675b20` | TLS protocol 1.2 must be used for Arc SQL managed instances. | `policies/azure-policy/arm/AZPOL_bb3c7464-033e-41ee-81dc-480fde675b20.json` | +| `bc1b984e-ddae-40cc-801a-050a030e4fbe` | Storage accounts should have shared access signature (SAS) policies configured | `policies/azure-policy/arm/AZPOL_bc1b984e-ddae-40cc-801a-050a030e4fbe.json` | +| `bcff6755-335b-484d-b435-d1161db39cdc` | Communication service resource should use a managed identity | `policies/azure-policy/arm/AZPOL_bcff6755-335b-484d-b435-d1161db39cdc.json` | +| `bd560fc0-3c69-498a-ae9f-aa8eb7de0e13` | Container registries should have SKUs that support Private Links | `policies/azure-policy/arm/AZPOL_bd560fc0-3c69-498a-ae9f-aa8eb7de0e13.json` | +| `bd58d393-162c-4134-bcd6-a6a5484a37a1` | The legacy Log Analytics extension should not be installed on Azure Arc enabled Linux servers | `policies/azure-policy/arm/AZPOL_bd58d393-162c-4134-bcd6-a6a5484a37a1.json` | +| `bd876905-5b84-4f73-ab2d-2e7a7c4568d9` | Machines should be configured to periodically check for missing system updates | `policies/azure-policy/arm/AZPOL_bd876905-5b84-4f73-ab2d-2e7a7c4568d9.json` | +| `bdd8bbb2-1efd-48dc-a0fd-8ddcba2e96cd` | [Preview]: Azure Managed Grafana should be Zone Redundant | `policies/azure-policy/arm/AZPOL_bdd8bbb2-1efd-48dc-a0fd-8ddcba2e96cd.json` | +| `be7ed5c8-2660-4136-8216-e6f3412ba909` | [Deprecated]: Web Application Firewall should be enabled for Azure Front Door Service or Application Gateway | `policies/azure-policy/arm/AZPOL_be7ed5c8-2660-4136-8216-e6f3412ba909.json` | +| `bf045164-79ba-4215-8f95-f8048dc1780b` | Geo-redundant storage should be enabled for Storage Accounts | `policies/azure-policy/arm/AZPOL_bf045164-79ba-4215-8f95-f8048dc1780b.json` | +| `bf45113f-264e-4a87-88f9-29ac8a0aca6a` | Azure Web PubSub Service should disable public network access | `policies/azure-policy/arm/AZPOL_bf45113f-264e-4a87-88f9-29ac8a0aca6a.json` | +| `bfecdea6-31c4-4045-ad42-71b9dc87247d` | Storage account encryption scopes should use double encryption for data at rest | `policies/azure-policy/arm/AZPOL_bfecdea6-31c4-4045-ad42-71b9dc87247d.json` | +| `c1b9cbed-08e3-427d-b9ce-7c535b1e9b94` | [Deprecated]: Allow resource creation only in Asia data centers | `policies/azure-policy/arm/AZPOL_c1b9cbed-08e3-427d-b9ce-7c535b1e9b94.json` | +| `c25dcf31-878f-4eba-98eb-0818fdc6a334` | Azure Virtual Desktop hostpools should disable public network access | `policies/azure-policy/arm/AZPOL_c25dcf31-878f-4eba-98eb-0818fdc6a334.json` | +| `c349d81b-9985-44ae-a8da-ff98d108ede8` | Azure Data Box jobs should enable double encryption for data at rest on the device | `policies/azure-policy/arm/AZPOL_c349d81b-9985-44ae-a8da-ff98d108ede8.json` | +| `c39ba22d-4428-4149-b981-70acb31fc383` | Azure Key Vault Managed HSM should have purge protection enabled | `policies/azure-policy/arm/AZPOL_c39ba22d-4428-4149-b981-70acb31fc383.json` | +| `c42dee8c-0202-4a12-bd8e-3e171cbf64dd` | FHIR Service should use a customer-managed key to encrypt data at rest | `policies/azure-policy/arm/AZPOL_c42dee8c-0202-4a12-bd8e-3e171cbf64dd.json` | +| `c4857be7-912a-4c75-87e6-e30292bcdf78` | [Preview]: Container Registry should use a virtual network service endpoint | `policies/azure-policy/arm/AZPOL_c4857be7-912a-4c75-87e6-e30292bcdf78.json` | +| `c58e083e-7982-4e24-afdc-be14d312389e` | Multi-User Authorization (MUA) must be enabled for Backup Vaults. | `policies/azure-policy/arm/AZPOL_c58e083e-7982-4e24-afdc-be14d312389e.json` | +| `c5f34731-7ab9-42ff-922d-ef4920068b74` | Azure Health Data Services de-identification service should disable public network access | `policies/azure-policy/arm/AZPOL_c5f34731-7ab9-42ff-922d-ef4920068b74.json` | +| `c7031eab-0fc0-4cd9-acd0-4497bd66d91a` | Multi-User Authorization (MUA) must be enabled for Recovery Services Vaults. | `policies/azure-policy/arm/AZPOL_c7031eab-0fc0-4cd9-acd0-4497bd66d91a.json` | +| `c9299215-ae47-4f50-9c54-8a392f68a052` | Public network access should be disabled for MySQL flexible servers | `policies/azure-policy/arm/AZPOL_c9299215-ae47-4f50-9c54-8a392f68a052.json` | +| `c95b54ad-0614-4633-ab29-104b01235cbf` | Virtual Machine should have TrustedLaunch enabled | `policies/azure-policy/arm/AZPOL_c95b54ad-0614-4633-ab29-104b01235cbf.json` | +| `c9d007d0-c057-4772-b18c-01e546713bcd` | Storage accounts should allow access from trusted Microsoft services | `policies/azure-policy/arm/AZPOL_c9d007d0-c057-4772-b18c-01e546713bcd.json` | +| `ca85ef9a-741d-461d-8b7a-18c2da82c666` | Azure Web Application Firewall on Azure Application Gateway should have request body inspection enabled | `policies/azure-policy/arm/AZPOL_ca85ef9a-741d-461d-8b7a-18c2da82c666.json` | +| `ca91455f-eace-4f96-be59-e6e2c35b4816` | Managed disks should be double encrypted with both platform-managed and customer-managed keys | `policies/azure-policy/arm/AZPOL_ca91455f-eace-4f96-be59-e6e2c35b4816.json` | +| `cb3738a6-82a2-4a18-b87b-15217b9deff4` | Azure Synapse Workspace SQL Server should be running TLS version 1.2 or newer | `policies/azure-policy/arm/AZPOL_cb3738a6-82a2-4a18-b87b-15217b9deff4.json` | +| `cbd11fd3-3002-4907-b6c8-579f0e700e13` | Service Bus Namespaces should disable public network access | `policies/azure-policy/arm/AZPOL_cbd11fd3-3002-4907-b6c8-579f0e700e13.json` | +| `cbe0e5eb-fea9-491d-ab20-a62cf049c5ae` | Function app slots should enable end to end encryption | `policies/azure-policy/arm/AZPOL_cbe0e5eb-fea9-491d-ab20-a62cf049c5ae.json` | +| `cd870362-211d-4cad-9ad9-11e5ea4ebbc1` | Public network access should be disabled for IoT Central | `policies/azure-policy/arm/AZPOL_cd870362-211d-4cad-9ad9-11e5ea4ebbc1.json` | +| `cee2f9fd-3968-44be-a863-bd62c9884423` | Geo-redundant backup should be enabled for Azure Database for PostgreSQL flexible servers | `policies/azure-policy/arm/AZPOL_cee2f9fd-3968-44be-a863-bd62c9884423.json` | +| `cf9ca02d-383e-4506-a421-258cc1a5300d` | [Deprecated]: Function app slots should have 'Client Certificates (Incoming client certificates)' enabled | `policies/azure-policy/arm/AZPOL_cf9ca02d-383e-4506-a421-258cc1a5300d.json` | +| `cfb11c26-f069-4c14-8e36-56c394dae5af` | Azure Service Bus namespaces should have local authentication methods disabled | `policies/azure-policy/arm/AZPOL_cfb11c26-f069-4c14-8e36-56c394dae5af.json` | +| `d074ddf8-01a5-4b5e-a2b8-964aed452c0a` | Container Apps environment should disable public network access | `policies/azure-policy/arm/AZPOL_d074ddf8-01a5-4b5e-a2b8-964aed452c0a.json` | +| `d158790f-bfb0-486c-8631-2dc6b4e8e6af` | Enforce SSL connection should be enabled for PostgreSQL database servers | `policies/azure-policy/arm/AZPOL_d158790f-bfb0-486c-8631-2dc6b4e8e6af.json` | +| `d1a5414f-ada4-46bf-bea7-243d5100d981` | App Service app slots should provide an auto-generated domain name label scope | `policies/azure-policy/arm/AZPOL_d1a5414f-ada4-46bf-bea7-243d5100d981.json` | +| `d2185817-5b7e-473c-aadd-9de6ac114280` | The legacy Log Analytics extension should not be installed on virtual machines | `policies/azure-policy/arm/AZPOL_d2185817-5b7e-473c-aadd-9de6ac114280.json` | +| `d3ee5dcf-0c6d-49ab-aee4-f250583a7bdc` | [Preview]: Service Bus should be Zone Redundant | `policies/azure-policy/arm/AZPOL_d3ee5dcf-0c6d-49ab-aee4-f250583a7bdc.json` | +| `d4b065e2-fbda-4461-a42c-b0346aeb12a0` | The legacy Log Analytics extension should not be installed on Linux virtual machines | `policies/azure-policy/arm/AZPOL_d4b065e2-fbda-4461-a42c-b0346aeb12a0.json` | +| `d550e854-df1a-4de9-bf44-cd894b39a95e` | Azure Monitor Logs for Application Insights should be linked to a Log Analytics workspace | `policies/azure-policy/arm/AZPOL_d550e854-df1a-4de9-bf44-cd894b39a95e.json` | +| `d6588149-9f06-462c-a076-56aece45b5ba` | Azure Backup Vaults should use customer-managed keys for encrypting backup data. Also an option to enforce Infra Encryption. | `policies/azure-policy/arm/AZPOL_d6588149-9f06-462c-a076-56aece45b5ba.json` | +| `d6f6f560-14b7-49a4-9fc8-d2c3a9807868` | Immutability must be enabled for Recovery Services vaults | `policies/azure-policy/arm/AZPOL_d6f6f560-14b7-49a4-9fc8-d2c3a9807868.json` | +| `d79ab062-dffd-4318-8344-f70de714c0bc` | [Deprecated]: App Service should disable public network access | `policies/azure-policy/arm/AZPOL_d79ab062-dffd-4318-8344-f70de714c0bc.json` | +| `d82101f3-f3ce-4fc5-8708-4c09f4009546` | IoT Hub device provisioning service instances should disable public network access | `policies/azure-policy/arm/AZPOL_d82101f3-f3ce-4fc5-8708-4c09f4009546.json` | +| `d82527a7-91cd-409f-b96e-049600b16b9e` | Durable Task schedulers should not allow open IP allowlists | `policies/azure-policy/arm/AZPOL_d82527a7-91cd-409f-b96e-049600b16b9e.json` | +| `d855fd7a-9be5-4d84-8b75-28d41aadc158` | [Preview]: Load tests using Azure Load Testing should be run only against private endpoints from within a virtual network. | `policies/azure-policy/arm/AZPOL_d855fd7a-9be5-4d84-8b75-28d41aadc158.json` | +| `d9844e8a-1437-4aeb-a32c-0c992f056095` | Public network access should be disabled for MySQL servers | `policies/azure-policy/arm/AZPOL_d9844e8a-1437-4aeb-a32c-0c992f056095.json` | +| `d9da03a1-f3c3-412a-9709-947156872263` | Azure HDInsight clusters should use encryption in transit to encrypt communication between Azure HDInsight cluster nodes | `policies/azure-policy/arm/AZPOL_d9da03a1-f3c3-412a-9709-947156872263.json` | +| `da6e2401-19da-4532-9141-fb8fbde08431` | Azure Kubernetes Service Clusters should use managed identities | `policies/azure-policy/arm/AZPOL_da6e2401-19da-4532-9141-fb8fbde08431.json` | +| `da79a7e2-8aa1-45ed-af81-ba050c153564` | Azure Firewall Policy should enable Threat Intelligence | `policies/azure-policy/arm/AZPOL_da79a7e2-8aa1-45ed-af81-ba050c153564.json` | +| `da8a2248-6b4a-44a7-96bf-bf1c0dd208c3` | [Preview]: Virtual network gateways should be Zone Redundant | `policies/azure-policy/arm/AZPOL_da8a2248-6b4a-44a7-96bf-bf1c0dd208c3.json` | +| `dbbdc317-9734-4dd8-9074-993b29c69008` | Azure Kubernetes Clusters should enable Key Management Service (KMS) | `policies/azure-policy/arm/AZPOL_dbbdc317-9734-4dd8-9074-993b29c69008.json` | +| `dc595cb1-1cde-45f6-8faf-f88874e1c0e1` | Logic Apps should be deployed into Integration Service Environment | `policies/azure-policy/arm/AZPOL_dc595cb1-1cde-45f6-8faf-f88874e1c0e1.json` | +| `dc921057-6b28-4fbe-9b83-f7bec05db6c2` | Container registries should have local admin account disabled. | `policies/azure-policy/arm/AZPOL_dc921057-6b28-4fbe-9b83-f7bec05db6c2.json` | +| `dcbc65aa-59f3-4239-8978-3bb869d82604` | App Service apps should use an Azure file share for its content directory | `policies/azure-policy/arm/AZPOL_dcbc65aa-59f3-4239-8978-3bb869d82604.json` | +| `dea83a72-443c-4292-83d5-54a2f98749c0` | Automation Account should have Managed Identity | `policies/azure-policy/arm/AZPOL_dea83a72-443c-4292-83d5-54a2f98749c0.json` | +| `df42d971-b39b-43a4-a987-b29d1bfbffba` | App Service app slots should be injected into a virtual network. | `policies/azure-policy/arm/AZPOL_df42d971-b39b-43a4-a987-b29d1bfbffba.json` | +| `df441472-4dae-4e4e-87b9-9205ba46be16` | The legacy Log Analytics extension should not be installed on Azure Arc enabled Windows servers | `policies/azure-policy/arm/AZPOL_df441472-4dae-4e4e-87b9-9205ba46be16.json` | +| `dfb5ac92-ce74-4dbc-81fa-87243e62d5d3` | Azure Firewall Policy Analytics should be Enabled | `policies/azure-policy/arm/AZPOL_dfb5ac92-ce74-4dbc-81fa-87243e62d5d3.json` | +| `dfc212af-17ea-423a-9dcb-91e2cb2caa6b` | Azure Front Door profiles should use Premium tier that supports managed WAF rules and private link | `policies/azure-policy/arm/AZPOL_dfc212af-17ea-423a-9dcb-91e2cb2caa6b.json` | +| `e089ecb6-8b98-4b7b-b26b-9bbee7385395` | Storage accounts should use customer-managed key for encryption in Mission Platform products and services | `policies/azure-policy/arm/AZPOL_e089ecb6-8b98-4b7b-b26b-9bbee7385395.json` | +| `e0a2b1a3-f7f9-4569-807f-2a9edebdf4d9` | Cosmos DB should use a virtual network service endpoint | `policies/azure-policy/arm/AZPOL_e0a2b1a3-f7f9-4569-807f-2a9edebdf4d9.json` | +| `e15effd4-2278-4c65-a0da-4d6f6d1890e2` | Log Analytics Workspaces should block non-Azure Active Directory based ingestion. | `policies/azure-policy/arm/AZPOL_e15effd4-2278-4c65-a0da-4d6f6d1890e2.json` | +| `e345b6c3-24bd-4c93-9bbb-7e5e49a17b78` | Azure VPN gateways should not use 'basic' SKU | `policies/azure-policy/arm/AZPOL_e345b6c3-24bd-4c93-9bbb-7e5e49a17b78.json` | +| `e413671a-dd10-4cc1-a943-45b598596cb7` | Azure Machine Learning workspaces should enable V1LegacyMode to support network isolation backward compatibility | `policies/azure-policy/arm/AZPOL_e413671a-dd10-4cc1-a943-45b598596cb7.json` | +| `e4953962-5ae4-43eb-bb92-d66fd5563487` | [Preview]: A managed identity should be enabled on your machines | `policies/azure-policy/arm/AZPOL_e4953962-5ae4-43eb-bb92-d66fd5563487.json` | +| `e5612cea-18dc-40b0-9390-8bf77072d5df` | Encryption in transit should be required for Azure Files SMB and NFS protocols | `policies/azure-policy/arm/AZPOL_e5612cea-18dc-40b0-9390-8bf77072d5df.json` | +| `e802a67a-daf5-4436-9ea6-f6d821dd0c5d` | Enforce SSL connection should be enabled for MySQL database servers | `policies/azure-policy/arm/AZPOL_e802a67a-daf5-4436-9ea6-f6d821dd0c5d.json` | +| `e8775d5a-73b7-4977-a39b-833ef0114628` | Azure Managed Grafana workspaces should disable public network access | `policies/azure-policy/arm/AZPOL_e8775d5a-73b7-4977-a39b-833ef0114628.json` | +| `e8a5a3eb-1ab6-4657-a701-7ae432cf14e1` | Lab Services should not allow template virtual machines for labs | `policies/azure-policy/arm/AZPOL_e8a5a3eb-1ab6-4657-a701-7ae432cf14e1.json` | +| `e96a9a5f-07ca-471b-9bc5-6a0f33cbd68f` | Azure Machine Learning Computes should have local authentication methods disabled | `policies/azure-policy/arm/AZPOL_e96a9a5f-07ca-471b-9bc5-6a0f33cbd68f.json` | +| `ea0dfaed-95fb-448c-934e-d6e713ce393d` | Azure Monitor Logs clusters should be created with infrastructure-encryption enabled (double encryption) | `policies/azure-policy/arm/AZPOL_ea0dfaed-95fb-448c-934e-d6e713ce393d.json` | +| `ea4d6841-2173-4317-9747-ff522a45120f` | Key Vault should use a virtual network service endpoint | `policies/azure-policy/arm/AZPOL_ea4d6841-2173-4317-9747-ff522a45120f.json` | +| `ea6df8ed-090c-4fda-8379-cb1b428e8fdb` | App Service apps should be injected into a virtual network. | `policies/azure-policy/arm/AZPOL_ea6df8ed-090c-4fda-8379-cb1b428e8fdb.json` | +| `eaebaea7-8013-4ceb-9d14-7eb32271373c` | [Deprecated]: Function apps should have 'Client Certificates (Incoming client certificates)' enabled | `policies/azure-policy/arm/AZPOL_eaebaea7-8013-4ceb-9d14-7eb32271373c.json` | +| `eb4d34ab-0929-491c-bbf3-61e13da19f9a` | [Deprecated]: App Service Environment should be provisioned with latest versions | `policies/azure-policy/arm/AZPOL_eb4d34ab-0929-491c-bbf3-61e13da19f9a.json` | +| `ebaf4f25-a4e8-415f-86a8-42d9155bef0b` | Service Bus namespaces should have double encryption enabled | `policies/azure-policy/arm/AZPOL_ebaf4f25-a4e8-415f-86a8-42d9155bef0b.json` | +| `ec068d99-e9c7-401f-8cef-5bdde4e6ccf1` | Double encryption should be enabled on Azure Data Explorer | `policies/azure-policy/arm/AZPOL_ec068d99-e9c7-401f-8cef-5bdde4e6ccf1.json` | +| `ece3c79b-2caf-470d-a5f5-66470c4fc649` | [Preview]: Microsoft Dev Box Pools should not use Microsoft Hosted Networks. | `policies/azure-policy/arm/AZPOL_ece3c79b-2caf-470d-a5f5-66470c4fc649.json` | +| `ee7495e7-3ba7-40b6-bfee-c29e22cc75d4` | API Management APIs should use only encrypted protocols | `policies/azure-policy/arm/AZPOL_ee7495e7-3ba7-40b6-bfee-c29e22cc75d4.json` | +| `ee980b6d-0eca-4501-8d54-f6290fd512c3` | Azure AI Search services should disable public network access | `policies/azure-policy/arm/AZPOL_ee980b6d-0eca-4501-8d54-f6290fd512c3.json` | +| `efa3f296-ff2b-4f38-bc0d-5ef12c965b68` | Azure Arc-enabled servers should be configured with an Azure Arc Private Link Scope | `policies/azure-policy/arm/AZPOL_efa3f296-ff2b-4f38-bc0d-5ef12c965b68.json` | +| `f0e5abd0-2554-4736-b7c0-4ffef23475ef` | Queue Storage should use customer-managed key for encryption | `policies/azure-policy/arm/AZPOL_f0e5abd0-2554-4736-b7c0-4ffef23475ef.json` | +| `f16a3ca9-b57a-4392-b660-4c1f8442aa8d` | [Preview]: SQL Elastic database pools should be Zone Redundant | `policies/azure-policy/arm/AZPOL_f16a3ca9-b57a-4392-b660-4c1f8442aa8d.json` | +| `f4b53539-8df9-40e4-86c6-6b607703bd4e` | Disk encryption should be enabled on Azure Data Explorer | `policies/azure-policy/arm/AZPOL_f4b53539-8df9-40e4-86c6-6b607703bd4e.json` | +| `f6b68e5a-7207-4638-a1fb-47d90404209e` | [Deprecated]: Web Application Firewall should be a set mode for Application Gateway and Azure Front Door Service | `policies/azure-policy/arm/AZPOL_f6b68e5a-7207-4638-a1fb-47d90404209e.json` | +| `f7d52b2d-e161-4dfa-a82b-55e564167385` | Azure Synapse workspaces should use customer-managed keys to encrypt data at rest | `policies/azure-policy/arm/AZPOL_f7d52b2d-e161-4dfa-a82b-55e564167385.json` | +| `f8f774be-6aee-492a-9e29-486ef81f3a68` | Azure Event Grid domains should disable public network access | `policies/azure-policy/arm/AZPOL_f8f774be-6aee-492a-9e29-486ef81f3a68.json` | +| `fa298e57-9444-42ba-bf04-86e8470e32c7` | Saved-queries in Azure Monitor should be saved in customer storage account for logs encryption | `policies/azure-policy/arm/AZPOL_fa298e57-9444-42ba-bf04-86e8470e32c7.json` | +| `fa498b91-8a7e-4710-9578-da944c68d1fe` | [Preview]: Azure PostgreSQL flexible server should have Microsoft Entra Only Authentication enabled | `policies/azure-policy/arm/AZPOL_fa498b91-8a7e-4710-9578-da944c68d1fe.json` | +| `fb893a29-21bb-418c-a157-e99480ec364c` | Kubernetes Services should be upgraded to a non-vulnerable Kubernetes version | `policies/azure-policy/arm/AZPOL_fb893a29-21bb-418c-a157-e99480ec364c.json` | +| `fb97d6e1-5c98-4743-a439-23e0977bad9e` | [Preview]: Boot Diagnostics should be enabled on virtual machines | `policies/azure-policy/arm/AZPOL_fb97d6e1-5c98-4743-a439-23e0977bad9e.json` | +| `fc264132-db9c-4302-bb7d-3994c36461fe` | Communication services resource should have local authentication methods disabled | `policies/azure-policy/arm/AZPOL_fc264132-db9c-4302-bb7d-3994c36461fe.json` | +| `fc4d8e41-e223-45ea-9bf5-eada37891d87` | Virtual machines and virtual machine scale sets should have encryption at host enabled | `policies/azure-policy/arm/AZPOL_fc4d8e41-e223-45ea-9bf5-eada37891d87.json` | +| `fd34e936-069e-4fe5-bac6-f7c9824caab6` | App Service app slots should use an Azure file share for its content directory | `policies/azure-policy/arm/AZPOL_fd34e936-069e-4fe5-bac6-f7c9824caab6.json` | +| `fdccbe47-f3e3-4213-ad5d-ea459b2fa077` | Public network access should be disabled for MariaDB servers | `policies/azure-policy/arm/AZPOL_fdccbe47-f3e3-4213-ad5d-ea459b2fa077.json` | +| `fe3fd216-4f83-4fc1-8984-2bbec80a3418` | Cognitive Services accounts should use a managed identity | `policies/azure-policy/arm/AZPOL_fe3fd216-4f83-4fc1-8984-2bbec80a3418.json` | +| `fe83a0eb-a853-422d-aac2-1bffd182c5d0` | Storage accounts should have the specified minimum TLS version | `policies/azure-policy/arm/AZPOL_fe83a0eb-a853-422d-aac2-1bffd182c5d0.json` | +| `ff05e24e-195c-447e-b322-5e90c9f9f366` | Container registries should have repository scoped access token disabled. | `policies/azure-policy/arm/AZPOL_ff05e24e-195c-447e-b322-5e90c9f9f366.json` | +| `ff1f1879-a60d-4f23-9641-41e7391ec19a` | Azure Application Gateway should be deployed with Azure WAF | `policies/azure-policy/arm/AZPOL_ff1f1879-a60d-4f23-9641-41e7391ec19a.json` | +| `ff25f3c8-b739-4538-9d07-3d6d25cfb255` | Keys using elliptic curve cryptography should have the specified curve names | `policies/azure-policy/arm/AZPOL_ff25f3c8-b739-4538-9d07-3d6d25cfb255.json` | +| `ffe25541-3853-4f4e-b71d-064422294b11` | API Management should have username and password authentication disabled | `policies/azure-policy/arm/AZPOL_ffe25541-3853-4f4e-b71d-064422294b11.json` | +| `ffea632e-4e3a-4424-bf78-10e179bb2e1a` | Bot Service should have local authentication methods disabled | `policies/azure-policy/arm/AZPOL_ffea632e-4e3a-4424-bf78-10e179bb2e1a.json` | + +## Blocked + +| GUID | Display name | Codes | Reason | +| --- | --- | --- | --- | +| `c15dcc82-b93c-4dcb-9332-fbf121685b54` | API Management calls to API backends should be authenticated | T5, T18, T10 | Compliance for an http backend is credentials.certificate being a non-empty list OR both credentials.authorization.scheme and credentials.authorization.parameter existing - a per-resource disjunction across different att | +| `d5448c98-e503-4fdd-bcd2-784960c00d04` | API Management policies should inherit parent scope policies using | T18, T10 | Upstream reads the policy XML out of `properties.value`, strips \r, \n, space and tab with four nested replace() calls, and then requires the stripped string to contain all four of , , | +| `801543d1-1953-4a90-b8b0-8cf6d41473a5` | App Service apps should enable configuration routing to Azure Virtual Network | T5, T18 | The rule is a per-resource disjunction over two different attribute sets chosen by the request API version: for `[requestContext().apiVersion]` < 2024-11-01 compliance is all three of `properties.vnetImagePullEnabled`, ` | +| `a691eacb-474d-47e4-b287-b4813ca44222` | App Service apps should enable outbound application traffic routing to Azure Virtual Network | T5, T18 | The rule is a per-resource disjunction over two different attribute sets chosen by the request API version: for `[requestContext().apiVersion]` < 2024-11-01 compliance is `properties.vnetRouteAllEnabled == "true"`; for > | +| `0a914e76-4921-4c19-b460-a2d36003525a` | Audit resource location matches resource group location | T14, T8 | The comparison value is `[resourcegroup().location]` - the location of the resource group the deployment targets. That value is not in the ARM template at all: it belongs to the deployment scope, which tirith never sees | +| `0e246bcf-5f6f-4f87-bc6f-775d4712c7ea` | Authorized IP ranges should be defined on Kubernetes Services | T5 | Upstream fires only when BOTH apiServerAccessProfile.authorizedIPRanges is absent AND the cluster is not private (apiServerAccessProfile.enablePrivateCluster absent or false). Compliance is therefore a per-resource disju | +| `67121cc7-ff39-4ab8-b7e3-95b84dab487d` | Azure AI Services resources should encrypt data at rest with a customer-managed key (CMK) | T18, T5 | Upstream fires only on accounts whose sibling `kind` is outside a 16-entry excludedKinds default list that contains the two most widely deployed kinds, `CognitiveServices` and `AIServices` (also ContentSafety, LUIS, QnAM | +| `037eea7a-bd0a-46c5-9a66-03aea78705d3` | Azure AI Services resources should restrict network access | T5, T18 | The policyRule is an anyOf over two per-type branches, and each branch is itself a per-resource disjunction over DIFFERENT attributes. A Microsoft.CognitiveServices/accounts resource is compliant when properties.publicNe | +| `d6759c02-b87f-42b7-892e-71b3f471d782` | Azure AI Services resources should use Azure Private Link | T14, T9 | Compliance is "at least one element of `properties.privateEndpointConnections` has `privateLinkServiceConnectionState.status == "Approved"`" (upstream fires when that filtered count is < 1). Two obstacles, and the first | +| `1ee56206-5dd1-42ab-b02d-8aae8b1634ce` | Azure API for FHIR should use private link | T14, T9 | The rule counts elements of properties.privateEndpointConnections whose privateLinkServiceConnectionState.status is "Approved" and fires when that count is below 1. privateEndpointConnections is a read-only, service-popu | +| `7eab1da3-2bf0-4ff0-8303-1a4277c380e8` | Azure Arc Private Link Scopes should be configured with a private endpoint | T14, T9 | Compliance is "at least one element of `properties.privateEndpointConnections` has `privateLinkServiceConnectionState.status == "Approved"`" (upstream fires when that filtered count is < 1). Two obstacles, and the first | +| `862e97cf-49fc-4a5c-9de4-40d4e2e7c8eb` | Azure Cosmos DB accounts should have firewall rules | T5, T14 | Upstream fires only when four independent clauses hold at once, so compliance is a per-resource disjunction over four *different* attributes of the same account: `properties.publicNetworkAccess` is "Disabled", OR `proper | +| `9d83ccb1-f313-46ce-9d39-a198bfdb51a0` | Azure Cosmos DB accounts should not exceed the maximum number of days allowed since last account key regeneration. | T10, T14 | The rule compares four `properties.keysMetadata.*.generationTime` timestamps against `[addDays(utcNow(), mul(int(parameters('maxDaysSinceLastRegeneration')), -1))]`, i.e. now minus 60 days by default. That is date arithm | +| `0473574d-2d43-4217-aefe-941fcdf7e684` | Azure Cosmos DB allowed locations | T19, T10 | The allow-list lives only in the assignment: `listOfAllowedLocations` is an Array parameter with strongType "location" and **no defaultValue**, so the definition itself names no acceptable location and card rule 10 canno | +| `0b7ef78e-a035-4f23-b9bd-aff122a1b1cf` | Azure Cosmos DB throughput should be limited | T19, T5, T10 | Three independent blockers. (a) `throughputMax` is an Integer parameter with no defaultValue, so the definition names no threshold at all and card rule 10 cannot be satisfied - any number chosen here would be invented si | +| `f7735886-8927-431f-b201-c953922512b8` | Azure Data Explorer cluster should use private link | T14, T9 | Compliance is "at least one element of `properties.privateEndpointConnections` (the alias spells it PrivateEndpointConnections) has `privateLinkServiceConnectionState.status == "Approved"`" (upstream fires when that filt | +| `6809a3d0-d354-42fb-b955-783d207c62a8` | Azure Data Factory linked service resource type should be in allow list | T19 | The upstream definition carries no allow-list of its own: `allowedLinkedServiceResourceTypes` is a required Array parameter with 111 allowedValues and no defaultValue, so the set of acceptable `properties.type` values ex | +| `127ef6d7-242f-43b3-9eef-947faf1725d0` | Azure Data Factory linked services should use Key Vault for storing secrets | T5, T18 | The rule is a 25-branch anyOf whose branches read different attribute paths of different linked-service types, so compliance is a per-resource conjunction over 25 negations that tirith's document-wide `&&`/`\|\|` cannot ex | +| `f78ccdb4-7bf4-4106-8647-270491d2978a` | Azure Data Factory linked services should use system-assigned managed identity authentication when it is supported | T18, T5 | The upstream `if` is scoped twice by sibling attributes that tirith cannot bind to the same resource. (a) It only applies to linked services whose own `properties.type` is one of nine MSI-capable store types (AzureSqlDat | +| `258823f2-4595-4b52-b333-cc96192710d8` | Azure Databricks Workspaces should use private link | T14, T9 | Compliance is "at least one element of `properties.privateEndpointConnections` has `privateLinkServiceConnectionState.status == "Approved"`" (upstream fires when that filtered count is < 1). Two obstacles, and the first | +| `08a6b96f-576e-47a2-8511-119a212d344d` | Azure Edge Hardware Center devices should have double encryption support enabled | T5 | Upstream flags an orderItem only when BOTH orderItemDetails.preferences.encryptionPreferences.doubleEncryptionStatus and orderItemDetails.productDetails.productDoubleEncryptionStatus differ from "Enabled" - a per-resourc | +| `9830b652-8523-49cc-b1b3-e17dce1127ca` | Azure Event Grid domains should use private link | T14, T9 | The rule counts elements of `properties.privateEndpointConnections` on Microsoft.EventGrid/domains whose `privateLinkServiceConnectionState.status` is "Approved" and fires when the count is below 1. privateEndpointConnec | +| `cd8f7644-6fe8-4516-bded-0e465ead03ac` | Azure Event Grid namespace MQTT broker should use private link | T14, T9, T18 | As with the sibling namespace-topic rule, the assertion is a filtered count over `properties.privateEndpointConnections`, a read-only, service-populated property that never appears in an ARM template, under audit/disable | +| `1301a000-bc6b-4d90-8414-7091e3abdc40` | Azure Event Grid namespace topic broker should use private link | T14, T9, T18 | Same read-only field as the other private-link rules: it counts `properties.privateEndpointConnections` elements on Microsoft.EventGrid/namespaces and fires when fewer than 1 qualifies. The property is service-populated | +| `4b90e17e-8448-49db-875e-bd83fb6f804f` | Azure Event Grid topics should use private link | T14, T9 | The rule counts `properties.privateEndpointConnections` elements on Microsoft.EventGrid/topics whose `privateLinkServiceConnectionState.status` is "Approved" and fires below 1. That property is read-only and service-popu | +| `04408ca5-aa10-42ce-8536-98955cdddd4c` | Azure Kubernetes Service Clusters should enable node os auto-upgrade | T5 | Upstream's `if` is allOf[ type, allOf[ anyOf[upgradeChannel absent, upgradeChannel != "node-image"], anyOf[nodeOSUpgradeChannel absent, nodeOSUpgradeChannel notin parameters('allowedNodeOsUpgradeChannels')] ] ]. Negated, | +| `f110a506-2dcb-422e-bcea-d533fc8c35e2` | Azure Machine Learning compute instances should be recreated to get the latest software updates | T14, T18 | The whole assertion is `Microsoft.MachineLearningServices/workspaces/computes/osImageMetadata.isLatestOsImageVersion == false`. `osImageMetadata` is a service-computed, read-only property of a deployed compute instance - | +| `9259053b-ddb8-40ab-842a-0aef19d0ade4` | Azure Purview accounts should use private link | T14, T9 | Compliance is "at least one element of `properties.privateEndpointConnections` has `privateLinkServiceConnectionState.status == "Approved"`" (upstream fires when that filtered count is < 1). Two obstacles, and the first | +| `deeddb44-9f94-4903-9fa0-081d524406e3` | Azure Recovery Services vaults should use private link for backup | T14, T9 | The rule counts members of `Microsoft.RecoveryServices/vaults/privateEndpointConnections[*]` whose `privateLinkServiceConnectionState.status` is "Approved", whose `provisioningState` is "Succeeded" and whose `id` contain | +| `ca950cd7-02f7-422e-8c23-91ff40f169c1` | Azure Virtual Desktop service should use private link | T14, T9 | Both branches count elements of `properties.privateEndpointConnections` whose `privateLinkServiceConnectionState.status` is "Approved" - on Microsoft.DesktopVirtualization/hostpools and on Microsoft.DesktopVirtualization | +| `ad5621d6-a877-4407-aa93-a950b428315e` | BotService resources should use private link | T14, T9 | The rule counts members of `Microsoft.BotService/botServices/privateEndpointConnections[*]` whose `privateLinkServiceConnectionState.status` is "Approved" and fires when that count is below 1. privateEndpointConnections | +| `f772fb64-8e40-40ad-87bc-7706e1949427` | Certificates should not expire within the specified number of days | T19, T10, T14 | Three independent blockers. (a) T0: `daysToExpire` is a required Integer parameter of the built-in with no defaultValue and no allowedValues (verified in vendor/azure-policy/built-in-policies/policyDefinitions/Key Vault/ | +| `93c45b74-42a1-4967-b25d-82c4dc630921` | Communication service resource should use allow listed data location | T19 | The upstream definition carries no allow-list: `allowedDataLocations` is a required Array parameter with no defaultValue and no allowedValues, so the set of acceptable `properties.dataLocation` values exists only in the | +| `d0793b48-0edc-4296-a390-4c75d1bdfd71` | Container registries should not allow unrestricted network access | T5 | Upstream flags a registry only when BOTH halves are bad: (networkRuleSet.defaultAction absent or "Allow") AND (publicNetworkAccess absent or "Enabled"). Compliance is therefore a per-resource disjunction over two differe | +| `58440f8a-10c5-4151-bdce-dfbaad4a20b7` | CosmosDB accounts should use private link | T14, T9 | The rule counts the elements of `properties.privateEndpointConnections` whose `privateLinkServiceConnectionState.status` is "Approved" and fires when that count is less than 1. privateEndpointConnections is a read-only, | +| `1abc5157-29f8-4dbd-b28e-ff99526cb8b7` | ElasticSan Volume Group should use private endpoints | T14, T9 | The rule counts elements of `properties.privateEndpointConnections` on Microsoft.ElasticSan/elasticSans/volumeGroups whose `privateLinkServiceConnectionState.status` is "Approved" and fires when the count is below 1. Tha | +| `b1a9997f-2883-4f12-bdff-2280f99b5915` | Ensure cluster containers have readiness or liveness probes configured | T14 | mode `Microsoft.Kubernetes.Data`: `policyRule.if` is a bare `{"field": "type"}` resource selector with no property assertion at all, and the whole rule body is a Gatekeeper constraint template referenced by `then.details | +| `836cd60e-87f3-4e6a-a27c-29d687f01a4c` | Event Hub namespaces should have double encryption enabled | T18 | Upstream's `if` is `type equals Microsoft.EventHub/namespaces` AND `clusterArmId exists true` AND `encryption.requireInfrastructureEncryption notEquals "true"` (confirmed in vendor/azure-policy/built-in-policies/policyDe | +| `a1ad735a-e96f-45d2-a7b2-9a4932cab7ec` | Event Hub namespaces should use a customer-managed key for encryption | T18 | The rule is scoped by a sibling attribute of the same resource: it fires only when `Microsoft.EventHub/namespaces/clusterArmId` `exists: true`, i.e. only for namespaces that belong to an Event Hubs Dedicated cluster. The | +| `f380edf5-de79-4862-bd57-ee407d8bd8b6` | Function app slots should enable configuration routing to Azure Virtual Network | T5, T18 | The rule is a per-resource disjunction over two different attribute sets chosen by the request API version: for `[requestContext().apiVersion]` < 2024-11-01 compliance is all three of `properties.vnetImagePullEnabled`, ` | +| `75c42c42-dfb3-453d-8fd5-9f978e1b1106` | Function app slots should enable outbound application traffic routing to Azure Virtual Network | T5, T18 | The rule is a per-resource disjunction over two different attribute sets chosen by the request API version: for `[requestContext().apiVersion]` < 2024-11-01 compliance is `properties.vnetRouteAllEnabled == "true"`; for > | +| `5e57f5ad-995a-485b-9f42-4748935ee5d9` | Function apps should enable configuration routing to Azure Virtual Network | T5, T18 | The rule is a per-resource disjunction over two different attribute sets chosen by the request API version: for `[requestContext().apiVersion]` < 2024-11-01 compliance is all three of `properties.vnetImagePullEnabled`, ` | +| `32a913ec-5ed7-4b31-a5b0-198450d8fb13` | Function apps should enable outbound application traffic routing to Azure Virtual Network | T5, T18 | The rule is a per-resource disjunction over two different attribute sets chosen by the request API version: for `[requestContext().apiVersion]` < 2024-11-01 compliance is `properties.vnetRouteAllEnabled == "true"`; for > | +| `35f9c03a-cc27-418e-9c0c-539ff999d010` | Gateway subnets should not be configured with a network security group | T18 | The rule is scoped by the resource's own `name`: it fires only on a Microsoft.Network/virtualNetworks/subnets resource whose `field name equals "GatewaySubnet"` and that also has `networkSecurityGroup.id`. As the capabil | +| `d8cf8476-a2ec-4916-896e-992351803c44` | Keys should have a rotation policy ensuring that their rotation is scheduled within the specified number of days after creation. | T19, T10, T14 | Upstream fires when `scheduledRotationDate` is absent OR `scheduledRotationDate > addDays(attributes.createdOn, maximumDaysToRotate)`. (a) T0: `maximumDaysToRotate` is a required Integer parameter with no defaultValue (K | +| `5ff38825-c5d8-47c5-b70e-069a21955146` | Keys should have more than the specified number of days before expiration | T19, T10 | Upstream fires when `attributes.expiresOn` exists AND `expiresOn < addDays(utcNow(), minimumDaysBeforeExpiration)`. (a) T0: `minimumDaysBeforeExpiration` is a required Integer parameter with no defaultValue (Keys_Expiry_ | +| `49a22571-d204-4c91-a7b6-09b1a586fbc9` | Keys should have the specified maximum validity period | T19, T10, T14 | Upstream fires when `attributes.expiresOn` is absent OR `expiresOn > addDays(attributes.createdOn, maximumValidityInDays)`. (a) T0: `maximumValidityInDays` is a required Integer parameter with no defaultValue (Keys_Valid | +| `c26e4b24-cf98-4c67-b48b-5a25c4c69eb9` | Keys should not be active for longer than the specified number of days | T19, T10, T14 | The whole condition is a value expression: `utcNow() greater addDays(if(empty(attributes.notBefore), attributes.createdOn, attributes.notBefore), maximumValidityInDays)` (azure_impurities lists `value-expression`, and va | +| `51fb1409-3c36-43c4-a750-ce814ac35ea6` | Keys using AES cryptography should have a specified minimum key size | T19, T18 | (a) T0: `minimumOCTKeySize` is a required Integer parameter with no defaultValue (allowedValues [128, 192, 256, 512]) - Keys_OCT_MinimumKeySize.json - so the built-in carries no threshold. Picking one ourselves would inv | +| `82067dbb-e53b-4e06-b631-546d197452d9` | Keys using RSA cryptography should have a specified minimum key size | T19, T18 | (a) T0: `minimumRSAKeySize` is a required Integer parameter with no defaultValue (allowedValues [2048, 3072, 4096]) - Keys_RSA_MinimumKeySize.json. The built-in states no bound of its own; writing GreaterThanEqualTo 2048 | +| `a2abc456-f0ae-464b-bd3a-07a3cdbd7fb1` | Kubernetes cluster Windows containers should not overcommit cpu and memory | T14, T10 | mode `Microsoft.Kubernetes.Data`: `policyRule.if` is a bare `{"field": "type"}` resource selector with no property assertion at all, and the whole rule body is a Gatekeeper constraint template referenced by `then.details | +| `5485eac0-7e8f-4964-998b-a44f4f0c1e75` | Kubernetes cluster Windows containers should not run as ContainerAdministrator | T14, T6 | mode `Microsoft.Kubernetes.Data`: `policyRule.if` is a bare `{"field": "type"}` resource selector with no property assertion at all, and the whole rule body is a Gatekeeper constraint template referenced by `then.details | +| `57dde185-5c62-4063-b965-afbb201e9c1c` | Kubernetes cluster Windows containers should only run with approved user and domain user group | T14 | mode `Microsoft.Kubernetes.Data`: `policyRule.if` is a bare `{"field": "type"}` resource selector with no property assertion at all, and the whole rule body is a Gatekeeper constraint template referenced by `then.details | +| `56d0a13f-712f-466b-8416-56fb354fb823` | Kubernetes cluster containers should not use forbidden sysctl interfaces | T14, T10 | mode `Microsoft.Kubernetes.Data`: `policyRule.if` is a bare `{"field": "type"}` resource selector with no property assertion at all, and the whole rule body is a Gatekeeper constraint template referenced by `then.details | +| `c26596ff-4d70-4e6a-9a30-c2506bd2f80c` | Kubernetes cluster containers should only use allowed capabilities | T14, T10 | mode `Microsoft.Kubernetes.Data`: `policyRule.if` is a bare `{"field": "type"}` resource selector with no property assertion at all, and the whole rule body is a Gatekeeper constraint template referenced by `then.details | +| `975ce327-682c-4f2e-aa46-b9598289b86c` | Kubernetes cluster containers should only use allowed seccomp profiles | T14 | mode `Microsoft.Kubernetes.Data`: `policyRule.if` is a bare `{"field": "type"}` resource selector with no property assertion at all, and the whole rule body is a Gatekeeper constraint template referenced by `then.details | +| `82985f06-dc18-4a48-bc1c-b9f4f0098cfe` | Kubernetes cluster pods should only use approved host network and port list | T14, T10 | mode `Microsoft.Kubernetes.Data`: `policyRule.if` is a bare `{"field": "type"}` resource selector with no property assertion at all, and the whole rule body is a Gatekeeper constraint template referenced by `then.details | +| `b0fdedee-7b9e-4a17-9f5d-5e8e912d2f01` | Kubernetes cluster services should use unique selectors | T14, T8 | mode `Microsoft.Kubernetes.Data`: `policyRule.if` is a bare `{"field": "type"}` resource selector with no property assertion at all, and the whole rule body is a Gatekeeper constraint template referenced by `then.details | +| `95edb821-ddaf-4404-9732-666045e056b4` | Kubernetes cluster should not allow privileged containers | T14 | mode `Microsoft.Kubernetes.Data`: `policyRule.if` is a bare `{"field": "type"}` resource selector with no property assertion at all, and the whole rule body is a Gatekeeper constraint template referenced by `then.details | +| `65280eef-c8b4-425e-9aec-af55e55bf581` | Kubernetes cluster should not use naked pods | T14 | mode `Microsoft.Kubernetes.Data`: `policyRule.if` is a bare `{"field": "type"}` resource selector with no property assertion at all, and the whole rule body is a Gatekeeper constraint template referenced by `then.details | +| `a3dc4946-dba6-43e6-950d-f96532848c9f` | Kubernetes clusters should ensure that the cluster-admin role is only used where required | T14 | mode `Microsoft.Kubernetes.Data`: `policyRule.if` is a bare `{"field": "type"}` resource selector with no property assertion at all, and the whole rule body is a Gatekeeper constraint template referenced by `then.details | +| `a27c700f-8a22-44ec-961c-41625264370b` | Kubernetes clusters should not use specific security capabilities | T14 | mode `Microsoft.Kubernetes.Data`: `policyRule.if` is a bare `{"field": "type"}` resource selector with no property assertion at all, and the whole rule body is a Gatekeeper constraint template referenced by `then.details | +| `9f061a12-e40d-4183-a00e-171812443373` | Kubernetes clusters should not use the default namespace | T14 | mode `Microsoft.Kubernetes.Data`: `policyRule.if` is a bare `{"field": "type"}` resource selector with no property assertion at all, and the whole rule body is a Gatekeeper constraint template referenced by `then.details | +| `9a5f4e39-e427-4d5d-ae73-93db00328bec` | Kubernetes resources should have required annotations | T14 | mode `Microsoft.Kubernetes.Data`: `policyRule.if` is a bare `{"field": "type"}` resource selector with no property assertion at all, and the whole rule body is a Gatekeeper constraint template referenced by `then.details | +| `8405fdab-1faf-48aa-b702-999c9c172094` | Managed disks should disable public network access | T5, T18 | Compliance is a per-resource disjunction across two DIFFERENT attributes: the upstream `if` fires only when `properties.networkAccessPolicy` is not in [DenyAll, AllowPrivate] AND `properties.publicNetworkAccess` is not " | +| `882e19a6-996f-400e-a30f-c090887254f4` | Migrate WAF from WAF Config to WAF Policy on Application Gateway | T18, T5 | Upstream fires only when all three hold on the SAME application gateway: `properties.sku.tier == "WAF_v2"`, `properties.webApplicationFirewallConfiguration` present, and `properties.firewallPolicy` absent. Tirith cannot | +| `a22123bd-b9da-4c86-9424-24903e91fd55` | No AKS Specific Labels | T14, T18 | mode `Microsoft.Kubernetes.Data`: `policyRule.if` is a bare `{"field": "type"}` resource selector with no property assertion at all, and the whole rule body is a Gatekeeper constraint template referenced by `then.details | +| `c0e996f8-39cf-4af9-9f45-83fbde810432` | Only approved VM extensions should be installed | T0 | Same unbound-parameter shape as AZPOL::cccc23c7: the rule is `field Microsoft.Compute/virtualMachines/extensions/type notIn [parameters('approvedExtensions')]` and the definition ships no default approved-extension list, | +| `5e1de0e3-42cb-4ebc-a86d-61d0c619ca48` | Public network access should be disabled for PostgreSQL flexible servers | T5 | Upstream's `if` is a three-attribute boolean over one resource: violation when `(delegatedSubnetResourceId exists OR privateDnsZoneArmResourceId exists OR (publicNetworkAccess exists AND != "Disabled")) AND (delegatedSub | +| `36fd7371-8eb7-4321-9c30-a7100022d048` | Requires resources to not have a specific tag. This is a versioning test built-in. | T19 | The whole rule is `exists true` on the field `[concat('tags[', parameters('tagName'), ']')]`, and `tagName` is a String parameter with no defaultValue and no allowedValues (verified in vendor/azure-policy/built-in-polici | +| `0088bc63-6dee-4a9c-9d29-91cfdc848952` | SQL Server Integration Services integration runtimes on Azure Data Factory should be joined to a virtual network | T18, T5 | Two blockers compose. The rule is scoped by sibling attributes of the same resource - it fires only where `properties.type` equals "Managed" AND `properties.typeProperties.ssisProperties` exists - and tirith cannot scope | +| `b0eb591a-5e70-4534-a8bf-04b9c489584a` | Secrets should have more than the specified number of days before expiration | T19, T10 | The secrets counterpart of 5ff38825: fires when `attributes.expiresOn` exists AND `expiresOn < addDays(utcNow(), minimumDaysBeforeExpiration)`. (a) T0: `minimumDaysBeforeExpiration` is a required Integer parameter with n | +| `342e8053-e12e-4c44-be01-c3c2f318400f` | Secrets should have the specified maximum validity period | T19, T10, T14 | The secrets counterpart of 49a22571: fires when `attributes.expiresOn` is absent OR `expiresOn > addDays(attributes.createdOn, maximumValidityInDays)`. (a) T0: `maximumValidityInDays` has no defaultValue (Secrets_Validit | +| `e8d99835-8a06-45ae-a8e0-87a91941ccfe` | Secrets should not be active for longer than the specified number of days | T19, T10, T14 | The secrets counterpart of c26e4b24, and equally a pure value expression: `utcNow() greater addDays(if(empty(attributes.notBefore), attributes.createdOn, attributes.notBefore), maximumValidityInDays)` (azure_impurities: | +| `9c376cb6-0545-4233-a22c-074540d4d935` | Service Bus Premium namespaces should use a customer-managed key for encryption in mission platforms | T18 | The rule is scoped to Service Bus namespaces whose sku tier is Premium: `sku.tier == 'Premium'` AND `not(encryption.keySource == 'Microsoft.Keyvault')`. tirith's json provider cannot select a collection member by a sibli | +| `617c02be-7f02-4efd-8836-3180d47b6c68` | Service Fabric clusters should have the ClusterProtectionLevel property set to EncryptAndSign | T9, T18 | Compliance requires that the cluster have a fabricSettings entry named "Security" that itself contains a parameters entry whose name is "ClusterProtectionLevel" AND whose value is "EncryptAndSign". That is a per-resource | +| `ea6c4923-510a-4346-be26-1894919a5b97` | Stream Analytics job should use managed identity to authenticate endpoints | T18, T5, T11 | The rule is a four-way disjunction over resource types (streamingjobs, /inputs, /outputs, /functions) and, inside the inputs and outputs branches, fifteen further branches each selected by a sibling attribute: `datasourc | +| `d416745a-506c-48b6-8ab1-83cb814bcaa3` | Virtual machines should be connected to an approved virtual network | T19 | The comparison value is the unbound assignment parameter `virtualNetworkId`: the rule is `not { field Microsoft.Network/networkInterfaces/ipconfigurations[*].subnet.id like "[concat(parameters('virtualNetworkId'),'/*')]" | +| `564feb30-bf6a-4854-b4bb-0d2d2d1e6c66` | Web Application Firewall (WAF) should be enabled for Application Gateway | T5 | Upstream fires only when a single application gateway has NEITHER `properties.webApplicationFirewallConfiguration` NOR `properties.firewallPolicy` - a per-resource disjunction across two different attributes. Tirith's `\| | +| `549814b6-3212-4203-bdc8-1548d342fb67` | [Deprecated]: API Management minimum API version should be set to 2019-12-01 or higher | T5, T18 | The `if` fires only when apiVersionConstraint.minApiVersion matches none of the three date patterns AND sku.name is not "Consumption". Compliance is therefore a per-resource disjunction across two different attributes - | +| `f8d398ae-0441-4921-a341-40f3973d4647` | [Deprecated]: Azure Data Factory pipelines should only communicate with allowed domains. Versioning Test BuiltIn | T14, T19 | A `mode: Microsoft.DataFactory.Data` policy over the proxy type Microsoft.DataFactory.Data/factories/outboundTraffic, which is a data-plane object evaluated when a pipeline makes an outbound call - it is never declared i | +| `711c24bb-7f18-4578-b192-81a6161e1f17` | [Deprecated]: Azure Firewall Premium should configure a valid intermediate certificate to enable TLS inspection | T18 | Upstream fires only when the SAME firewall policy has `properties.sku.tier == "Premium"` AND no `properties.transportSecurity.certificateAuthority`. Tirith cannot scope an evaluator by a sibling attribute, so the only av | +| `85793e88-5a58-4555-93fa-4df63c86ae9c` | [Deprecated]: Azure Machine Learning Model Registry Deployments are restricted except for the allowed Registry. Versioning Test BuiltIn. | T14, T0 | A `mode: Microsoft.MachineLearningServices.v2.Data` policy over workspaces/deployments - a data-plane deployment object, not an ARM template resource, so the input is unreachable (T14). It is also unusable at its own def | +| `a77d8bb4-8d22-4bc1-a884-f582a705b480` | [Deprecated]: Azure Media Services accounts should use an API that supports Private Link | T0 | The upstream definition is deprecated (metadata.deprecated true, version '1.1.0-deprecated', default effect Disabled) and its policyRule has been replaced by a placeholder that no longer implements the titled intent: the | +| `ccf93279-9c91-4143-a841-8d1f21505455` | [Deprecated]: Azure Media Services accounts that allow access to the legacy v2 API should be blocked | T0 | The upstream definition is deprecated (metadata.deprecated true, version '1.1.0-deprecated', default effect Disabled) and its policyRule has been replaced by a placeholder that no longer implements the titled intent: the | +| `10ee2ea2-fb4d-45b8-a7e9-a2e770044cd9` | [Deprecated]: Custom subscription owner roles should not exist | T9, T18, T10 | Three separate blockers, all on the same resource. (a) The rule applies only to role definitions whose `properties.type` is `CustomRole` - a per-resource scoping predicate tirith cannot express (T18). (b) Both remaining | +| `98cec160-6f57-4d11-86e2-0a03290a3a8a` | [Deprecated]: Key Vault Managed HSM keys using elliptic curve cryptography should have the specified curve names. Versioning Test BuiltIn. | T0, T14 | Vacuous at its shipped defaults: `allowedECNamesV2` has defaultValue ["P-256","P-256K","P-384","P-521"] and allowedValues identical to it, and those four are the complete set of elliptic curves a Managed HSM key can carr | +| `fa8af49a-f61d-4f56-9138-46b77d37df43` | [Deprecated]: Keys should have a rotation policy within the specified number of days after creation. Versioning Test BuiltIn. | T14, T10, T19 | A `mode: Microsoft.KeyVault.Data` policy over Microsoft.KeyVault.Data/vaults/keys - the data-plane key object, not the Microsoft.KeyVault/vaults/keys resource an ARM template declares - and `scheduledRotationDate` is a s | +| `033f1f0f-0eff-42f0-85aa-f79b78e59a40` | [Deprecated]: Load tests should be run only against private endpoints from within a virtual network. Versioning Test BuiltIn. | T14 | A `mode: Microsoft.LoadTestService.Data` policy over Microsoft.LoadTestService.Data/loadTests/testRuns. A test run is a runtime execution object created by the Load Testing service, never a resource in an ARM template, s | +| `83a0809a-a4e3-4ef2-8a24-2afc156607af` | [Deprecated]: No AKS Specific Labels. Versioning Test BuiltIn. | T14, T0 | A `mode: Microsoft.Kubernetes.Data` policy: it does not inspect the ARM resource at all. Its `if` asserts only `parameters('testString') == "test"` (defaultValue "test", so always true) and that the resource type is a ma | +| `2c89a2e5-7285-40fe-afe0-ae8654b92fb2` | [Deprecated]: Unattached disks should be encrypted | T18, T14 | The upstream `if` is scoped by a sibling attribute: it fires only on a Microsoft.Compute/disks resource whose `properties.diskState` equals "Unattached", and only then checks `encryptionSettingsCollection.enabled`. tirit | +| `752c6934-9bcc-4749-b004-655e676ae2ac` | [Deprecated]: [Deprecated]: Audit enabling of diagnostic logs in App Services | T18 | Upstream scopes the audit to one member of the `Microsoft.Web/sites/config` collection with `field name equals "web"`; the three properties it tests (detailedErrorLoggingEnabled, httpLoggingEnabled, requestTracingEnabled | +| `3d02a511-74e5-4dab-a5fd-878704d4a61a` | [Preview]: Azure Data Factory pipelines should only communicate with allowed domains | T19 | The allow-list exists only in the assignment: `allowedDomainNames` is an Array parameter with no defaultValue and no allowedValues, so the definition names no acceptable domain and there is no concrete value that makes t | +| `59fee2f4-d439-4f1b-9b9a-982e1474bfd8` | [Preview]: Azure Key Vault Managed HSM should use private link | T14, T9, T7 | Upstream fires when `Microsoft.KeyVault/managedHSMs/privateEndpointConnections` is absent OR the count of connections whose `privateLinkServiceConnectionState.status` equals "Approved" is 0 (azure_impurities: count). (a) | +| `5e5a0673-649e-4d50-bf9d-5a387a4e2081` | [Preview]: Kubernetes cluster containers should use only allowed sysctl interfaces | T14 | mode `Microsoft.Kubernetes.Data`: `policyRule.if` is a bare `{"field": "type"}` resource selector with no property assertion at all, and the whole rule body is a Gatekeeper constraint template referenced by `then.details | +| `b81f454c-eebb-4e4f-9dfe-dca060e8a8fd` | [Preview]: Kubernetes clusters should restrict creation of given resource type | T14 | mode `Microsoft.Kubernetes.Data`: `policyRule.if` is a bare `{"field": "type"}` resource selector with no property assertion at all, and the whole rule body is a Gatekeeper constraint template referenced by `then.details | diff --git a/.claude/skills/tirith-migrate/reference/azure-policy.md b/.claude/skills/tirith-migrate/reference/azure-policy.md new file mode 100644 index 00000000..52daa751 --- /dev/null +++ b/.claude/skills/tirith-migrate/reference/azure-policy.md @@ -0,0 +1,179 @@ +# Azure Policy to Tirith + +An Azure Policy definition is a JSON document whose `properties.policyRule` has an `if` tree of +`{field, operator, value}` leaves under `allOf` / `anyOf` / `not`, and a `then.effect`. That is +structurally a Tirith policy: `field` is the attribute, the operator is the condition, the logic is +`eval_expression`. Two things make the translation more than a rename, and both are decided first. + +## 1. Polarity: `if` describes the violation + +Azure fires the effect when `if` matches. A Tirith evaluator describes compliance. So every leaf is +translated to its **opposite**, and the logic flips with it: + +| `if` leaf (violation) | Tirith condition (compliance) | +| --- | --- | +| `equals X` | `NotEquals X` | +| `notEquals X` | `Equals X` | +| `in [..]` | `NotContainedIn [..]` | +| `notIn [..]` | `ContainedIn [..]` | +| `contains X` | `NotContains X` | +| `notContains X` | `Contains X` | +| `containsKey K` (on a map, e.g. tags) | `NotContains K` on the map. `Contains` on a map tests its keys, verified | +| `notContainsKey K` | `Contains K` | +| `exists: "false"` | `IsNotEmpty` | +| `exists: "true"` | `IsEmpty` | +| `less N` | `GreaterThanEqualTo N` | +| `lessOrEquals N` | `GreaterThan N` | +| `greater N` | `LessThanEqualTo N` | +| `greaterOrEquals N` | `LessThan N` | +| `like "a*"` | `RegexMatch "^a.*$"` and `!` in the expression. `*` is the only wildcard | +| `notLike "a*"` | `RegexMatch "^a.*$"` | +| `match`, `notMatch`, `matchInsensitively`, `notMatchInsensitively` | not expressible: `#` digit, `?` letter, `.` any is not a regex. Hand-translate the pattern to a regex and mark approximate | + +| `if` logic | Tirith | Fidelity | +| --- | --- | --- | +| `{field: type, equals: T}` inside an `allOf` | scope: `terraform_resource_type` (or the json type guard) | exact | +| `anyOf [A, B]` (violation if either) | `notA && notB` | exact | +| `not X` | `X` translated positively | exact | +| `allOf [A, B]` on two attributes of the same resource (violation only if both) | `notA \|\| notB` | approximate, stricter: Tirith cannot bind both tests to one resource. Issue #316 | +| `count { field: X[*], where: {...} } greater 0` | wildcard path over `X.*.` where the inner test is one attribute | approximate; a `where` on two attributes is not expressible | +| `{value: "[...]", ...}` (an ARM expression, `requestContext()`, `resourceGroup()`, `field()` arithmetic) | none | not expressible | + +Most built-ins are `allOf [{type equals T}, {one field test}]`: scope plus one leaf, which is exact. + +## 2. Target: Terraform plan or ARM template + +| Target | Provider | When | +| --- | --- | --- | +| Terraform (`azurerm`) | `stackguardian/terraform_plan` | The team writes Terraform. Attribute names come from the crosswalk below; one Azure type is often two or three azurerm types | +| ARM template | `stackguardian/json` | The team deploys ARM or Bicep-built JSON. Paths come from the alias rule below; every translation is approximate because the json provider scopes the document, not the resource | + +### Terraform: alias to azurerm attribute + +Verified: each row is an attribute a corpus policy evaluates end to end against a plan. + +| Azure alias | azurerm resource | attribute | +| --- | --- | --- | +| `location` (any type) | `*` | `location`, `error_tolerance: 2` | +| `tags['K']`, `tags.K` | `*` | `tags` with `Contains "K"`, `error_tolerance: 2` | +| `type` in `[..]` (Not allowed resource types) | each forbidden type | operation `count`, `Equals 0` | +| `type` not in `[..]` (Allowed resource types) | `*` with `exclude_resource_types` = allowed | operation `count`, `Equals 0` | +| `Microsoft.Compute/virtualMachines/sku.name` | `azurerm_linux_virtual_machine`, `azurerm_windows_virtual_machine` | `size`; legacy `azurerm_virtual_machine` uses `vm_size` | +| `Microsoft.Compute/virtualMachines/securityProfile.encryptionAtHost` | `azurerm_linux_virtual_machine`, `azurerm_windows_virtual_machine`, both scale sets | `encryption_at_host_enabled` | +| `Microsoft.Compute/virtualMachines/osProfile.linuxConfiguration.disablePasswordAuthentication` | `azurerm_linux_virtual_machine` | `disable_password_authentication` | +| `Microsoft.Storage/storageAccounts/supportsHttpsTrafficOnly` | `azurerm_storage_account` | `https_traffic_only_enabled` (4.x), `enable_https_traffic_only` (3.x) | +| `Microsoft.Storage/storageAccounts/minimumTlsVersion` | `azurerm_storage_account` | `min_tls_version`, values `TLS1_2`, `TLS1_3` | +| `Microsoft.Storage/storageAccounts/allowBlobPublicAccess` | `azurerm_storage_account` | `allow_nested_items_to_be_public` (3.x+), `allow_blob_public_access` (2.x) | +| `Microsoft.Storage/storageAccounts/networkAcls.defaultAction` | `azurerm_storage_account` | `network_rules.*.default_action` | +| `Microsoft.Storage/storageAccounts/encryption.requireInfrastructureEncryption` | `azurerm_storage_account` | `infrastructure_encryption_enabled` | +| `Microsoft.Storage/storageAccounts/blobServices/containers/publicAccess` | `azurerm_storage_container` | `container_access_type`, compliant `private` | +| `Microsoft.KeyVault/vaults/enablePurgeProtection` | `azurerm_key_vault` | `purge_protection_enabled` | +| `Microsoft.KeyVault/vaults/enableSoftDelete` | `azurerm_key_vault` | `soft_delete_retention_days` (soft delete is always on in 3.x+) | +| `Microsoft.KeyVault/vaults/networkAcls.defaultAction` | `azurerm_key_vault` | `network_acls.*.default_action` | +| `Microsoft.Web/sites/httpsOnly` | `azurerm_linux_web_app`, `azurerm_windows_web_app`, `azurerm_app_service`, slots | `https_only` | +| `Microsoft.Web/sites/config/minTlsVersion` | same | `site_config.*.minimum_tls_version` (`min_tls_version` on `azurerm_app_service`) | +| `Microsoft.Web/sites/publicNetworkAccess` | same | `public_network_access_enabled` | +| `Microsoft.Sql/servers/minimalTlsVersion` | `azurerm_mssql_server` | `minimum_tls_version` | +| `Microsoft.Sql/servers/publicNetworkAccess` | `azurerm_mssql_server` | `public_network_access_enabled` | +| `Microsoft.DBforPostgreSQL/servers/sslEnforcement`, `minimalTlsVersion` | `azurerm_postgresql_server` | `ssl_enforcement_enabled`, `ssl_minimal_tls_version_enforced` | +| `Microsoft.DBforMySQL/servers/minimalTlsVersion` | `azurerm_mysql_server` | `ssl_minimal_tls_version_enforced` | +| `Microsoft.ContainerService/managedClusters/apiServerAccessProfile.enablePrivateCluster` | `azurerm_kubernetes_cluster` | `private_cluster_enabled` | +| `Microsoft.ContainerService/managedClusters/aadProfile.enableAzureRBAC` | `azurerm_kubernetes_cluster` | `azure_active_directory_role_based_access_control.*.azure_rbac_enabled` | +| `Microsoft.ContainerService/managedClusters/agentPoolProfiles[*].enableNodePublicIP` | `azurerm_kubernetes_cluster` | `default_node_pool.*.enable_node_public_ip` | +| `Microsoft.ContainerRegistry/registries/publicNetworkAccess` | `azurerm_container_registry` | `public_network_access_enabled` | +| `Microsoft.ContainerRegistry/registries/networkRuleSet.defaultAction` | `azurerm_container_registry` | `network_rule_set.*.default_action` | +| `Microsoft.Network/networkSecurityGroups/securityRules/*` | `azurerm_network_security_rule` | `direction`, `access`, `destination_port_range`, `source_address_prefix`; inline rules are `security_rule.*.` on `azurerm_network_security_group` | +| `Microsoft.Cache/redis/minimumTlsVersion` | `azurerm_redis_cache` | `minimum_tls_version` | +| `Microsoft.EventHub/namespaces/minimumTlsVersion` | `azurerm_eventhub_namespace` | `minimum_tls_version` | +| `Microsoft.ServiceBus/namespaces/minimumTlsVersion` | `azurerm_servicebus_namespace` | `minimum_tls_version` | +| `*/publicNetworkAccess` on most PaaS types | the azurerm type | `public_network_access_enabled` | +| `Microsoft.Network/networkInterfaces/enableIPForwarding`, publicIPs on NICs, resource-group-level anything | | not expressible: no single attribute carries it | + +An attribute the plan leaves unset arrives as `null` in `change.after`, not absent. `Equals` and +`ContainedIn` fail it cleanly; `Contains` fails it as an unsupported type; `IsNotEmpty` fails it. +A key the type does not have at all is absent and is what `error_tolerance: 2` skips. + +### ARM: alias to JSON path + +The rule, validated in the corpus against 36 independently verified paths: strip the resource type +(the whole `Namespace/type/childType/`, not one segment), turn `[*]` into `.*`, join with `.`, +prefix `properties.` unless the first segment is an envelope key (`sku`, `identity`, `tags`, +`location`, `zones`, `plan`, `kind`, `managedBy`, `name`, `type`, `id`). `type`, `name`, `location`, +`kind`, `identity.type` and `tags...` stay as they are. + +The json provider cannot scope by resource type (issue #317), so every ARM translation uses the +**type guard** and is approximate: + +```json +{"id": "no_such_type", + "provider_args": {"operation_type": "get_value", "key_path": "resources.*.type"}, + "condition": {"type": "NotContainedIn", "value": ["Microsoft.Storage/storageAccounts", "microsoft.storage/storageaccounts"], "error_tolerance": 2}} +``` + +with `"eval_expression": "no_such_type || the_check"`. A template with no such type passes; once +the type is present anywhere, the attribute path is read across **every** resource in the template, +so a second resource type that happens to carry the same property name is swept in. ARM type strings +are case-insensitive in Azure and compared exactly here; list the spellings you expect. Use the guard +only when Azure fails on an absent property (`notEquals`, `exists: false`): it turns absence into +failure at every inner tolerance. + +## 3. Effects + +| `then.effect` | Tirith | Fidelity | +| --- | --- | --- | +| `deny` | `meta.enforcement: "deny"`, run with `--fail-on-error` | exact | +| `audit` | `meta.enforcement: "audit"`. The CLI has no advisory tier: keep audit policies in their own directory and do not gate the job on their exit, or treat exit 3 from that run as a warning | approximate | +| `disabled` | do not translate | | +| `[parameters('effect')]` | the assignment decides; record the allowed values in the notes and pick the assignment's | | +| `auditIfNotExists`, `deployIfNotExists` | asserts a *related* resource exists, from `then.details`, not from `if`. `direct_references` with `referenced_by` covers "every X is referenced by a Y" at type level; anything on the related resource's attributes is not expressible | approximate or not expressible | +| `modify`, `append`, `denyAction`, `manual` | remediation or process, not evaluation | not expressible | + +## 4. Modes and parameters + +- **`mode: Indexed`** means Azure evaluates only resource types that support tags and location. + Translate with `error_tolerance: 2` on `location` or `tags` over `terraform_resource_type: "*"`, + which skips the types that lack the attribute. **`mode: All`** includes everything, including + resource groups and subscriptions, which Terraform models as resources too. +- **`[parameters('x')]`** becomes `{{ var.x }}` with a `variables.json` beside the policy. The + definition often has no `defaultValue`; the value lives in the **assignment**. Ask for it, or + read `properties.parameters` on the assignment. A parameter feeding `terraform_resource_type` or + `exclude_resource_types` cannot be a `-var`: write the types out, one evaluator per type. +- **`[concat('tags[', parameters('tagName'), ']')]`** is the `tags` attribute with `Contains {{ var.tagName }}`. +- **Initiatives** (policy set definitions) are a directory of policies. **Assignments**, scopes and + exemptions have no counterpart: a Tirith policy applies to whatever plan it is run against. + +## 5. What to expect + +Measured on HashiCorp-style public data, this time Microsoft's own `Azure/azure-policy` repository, +by the tirith-policy-corpus project (ARM target, json provider): + +| | count | +| --- | --- | +| Definitions with an asserting effect (`audit` or `deny`) | 713 | +| Translated to Tirith | 376, all approximate (document scope) | +| Blocked | 110 | +| Not started | 227 | + +Why blocked, in order: `mode: Microsoft.Kubernetes.Data` (Gatekeeper constraints inside a live +cluster, not ARM); a per-resource disjunction over two attribute sets chosen by API version (T5); +per-resource scoping on a sibling attribute (T18, issue #316); a comparison value that is an +assignment-time parameter with no default (T19, translatable once the assignment supplies it); +`count` with a `where` over two attributes (T9); value expressions and arithmetic (T10, issue #338). + +The built-ins a customer assigns first, Allowed locations, Require a tag, Allowed and Not allowed +resource types, Allowed VM SKUs, are all parameter-driven and untyped, so the corpus skipped them. +They translate cleanly to the Terraform target; `examples/azure-policy/` has each one verified. + +`reference/azure-policy-corpus.md` lists the 376 translated and 110 blocked definitions by GUID and +display name, with the corpus path or the blocking reason. Look a built-in up there before +translating it; if it is translated, the ARM version exists and the Terraform version is a +crosswalk away. + +## 6. Verifying + +Azure definitions ship no test fixtures. Write a plan by hand: `terraform show -json` shape, one +`resource_changes` entry per resource, the attribute under `change.after`. Include one resource of a +type the policy does not cover, so the run proves the scope. For `Indexed` translations include a +type without `location` or `tags` (a role assignment) and confirm it is skipped, not failed. Expect +exit `3` on the violating plan and `0` on the compliant one, and for every approximate row a +`diverges.json` where Azure and Tirith disagree. diff --git a/.claude/skills/tirith-migrate/reference/sentinel-corpus.md b/.claude/skills/tirith-migrate/reference/sentinel-corpus.md new file mode 100644 index 00000000..14c7a178 --- /dev/null +++ b/.claude/skills/tirith-migrate/reference/sentinel-corpus.md @@ -0,0 +1,118 @@ +# The public Sentinel corpus, classified + +Every policy in `hashicorp/terraform-sentinel-policies` and `hashicorp/policy-library-CIS-Policy-Set-for-AWS-Terraform`, +with the fidelity a Tirith translation can reach and why. Look a policy up here before translating it; +if it is not listed, classify it with `reference/sentinel.md` and add a row. + +| Policy | Pattern | Fidelity | Why | +| --- | --- | --- | --- | +| `aws/check-ec2-environment-tag` | tag-keys-required | exact | Two per-resource single-attribute tests (tags Contains 'Environment', tags.Environment ContainedIn list); a missing key fails both Sentinel and Tirith (missing attribute = severity-2 fail). | +| `aws/enforce-mandatory-tags` | tag-keys-required | exact | tags Contains [keys] per resource is Sentinel's not_contains_list on a map; resource_types cannot be a -var (each type is its own evaluator, error_tolerance 1 so absent types pass), but the default list gives identical verdicts. | +| `aws/require-dns-support-for-vpcs` | attribute-equals | exact | Two single-attribute Equals true checks; null/missing fails in both engines. | +| `aws/require-private-acl-and-kms-for-s3-buckets` | attribute-equals | exact | Two single-attribute Equals checks on one resource type; S3 permits a single rule so the wildcard equals Sentinel's index-0 read, and missing fails in both. | +| `aws/require-vpc-and-kms-for-lambda-functions` | attribute-present | exact | Sentinel violates on kms_key_arn null and vpc_config == []; Tirith IsNotEmpty on both yields the same verdicts (Terraform always emits [] for an unset block list, so Sentinel's null-vpc_config pass is unreachable). | +| `aws/restrict-availability-zones` | attribute-in-list | exact | Single-attribute ContainedIn; an unset/computed availability_zone is a violation in Sentinel (null->'null') and a severity-2 failure in Tirith. | +| `aws/restrict-db-instance-engines` | attribute-in-list | exact | Single-attribute ContainedIn with identical null handling. | +| `aws/restrict-ec2-instance-type` | attribute-in-list | exact | Single-attribute ContainedIn with identical null handling. | +| `aws/restrict-eks-node-group-size` | attribute-numeric | exact | Single numeric comparison; null max_size violates in Sentinel and fails as missing in Tirith. | +| `aws/restrict-launch-configuration-instance-type` | attribute-in-list | exact | Single-attribute ContainedIn with identical null handling. | +| `aws/restrict-sagemaker-notebooks` | attribute-equals | exact | Two single-attribute Equals checks; null fails in both engines. | +| `azure/enforce-mandatory-tags` | tag-keys-required | exact | Same shape as the AWS tags policy; per-type evaluators with error_tolerance 1 reproduce Sentinel's pass on absent types. | +| `azure/restrict-aks-clusters` | attribute-numeric | exact | Six single-attribute checks over two resource types; Sentinel ignores null counts, which error_tolerance 2 reproduces, while a missing vm_size fails in both. | +| `azure/restrict-app-service-to-https` | attribute-equals | exact | Single-attribute Equals true. | +| `azure/restrict-vm-image-id` | attribute-regex | exact | Anchored RegexMatch per resource type; Sentinel's '\|null' alternative lets an unset id pass, reproduced with error_tolerance 2 on a missing attribute. | +| `azure/restrict-vm-publisher` | attribute-in-list | exact | Three single-attribute ContainedIn checks over three resource types with identical null handling. | +| `azure/restrict-vm-size` | attribute-in-list | exact | Three single-attribute ContainedIn checks over three resource types. | +| `cis-aws/cloudtrail/cloudtrail-log-file-validation-enabled` | attribute-equals | exact | Boolean with a provider default; missing attribute fails in both. | +| `cis-aws/ec2/ec2-ebs-encryption-enabled` | attribute-equals | exact | Missing/unknown encrypted is a violation in Sentinel and a failing missing attribute in Tirith. | +| `cis-aws/iam/iam-password-expiry` | attribute-threshold | exact | Numeric threshold; missing attribute fails in both. | +| `cis-aws/iam/iam-password-length` | attribute-threshold | exact | Numeric threshold; missing attribute fails in both. | +| `cis-aws/iam/iam-password-lowercase` | attribute-equals | exact | Boolean equality; missing attribute fails in both. | +| `cis-aws/iam/iam-password-numbers` | attribute-equals | exact | Boolean equality; missing attribute fails in both. | +| `cis-aws/iam/iam-password-reuse` | attribute-threshold | exact | Sentinel uses strict equality (not >=), which Equals reproduces; missing attribute fails in both. | +| `cis-aws/iam/iam-password-symbols` | attribute-equals | exact | Boolean equality; missing attribute fails in both. | +| `cis-aws/iam/iam-password-uppercase` | attribute-equals | exact | Boolean equality; missing attribute fails in both. | +| `cis-aws/rds/rds-encryption-at-rest-enabled` | attribute-equals | exact | Missing/unknown storage_encrypted is a violation in Sentinel and a failing missing attribute in Tirith. | +| `cis-aws/rds/rds-minor-version-upgrade-enabled` | attribute-equals | exact | Boolean with provider default true; missing fails in both. | +| `cis-aws/rds/rds-public-access-disabled` | attribute-equals | exact | Sentinel treats a missing attribute as compliant; error_tolerance=2 reproduces that, and the provider default false makes the attribute always present anyway. | +| `cis-aws/s3/s3-block-public-access-account-level` | attribute-equals | exact | All four attributes must be true on every resource; conjunction of all-quantified evaluators equals Sentinel's per-resource conjunction here since a single false anywhere fails both. | +| `cloud-agnostic/prevent-destruction-of-prohibited-resources` | destroy-guard | exact | One action evaluator per type with value ["delete"] matches Sentinel's delete-and-not-create/update filter exactly (replacements pass in both), and error_tolerance 1 makes absent types skip as Sentinel's empty filter passes. | +| `cloud-agnostic/prevent-tfe-provider-workspace-deletion` | destroy-guard | exact | Sentinel refuses only `actions is ["delete"]`, a pure delete; Tirith `action ContainedIn ["delete"]` with `!` fires only on a pure delete because a replacement's `create` element fails the detector. `error_tolerance` 1 skips when no tfe_workspace exists, matching the empty filter. | +| `cloud-agnostic/restrict-panos-srgs` | attribute-not-contains | exact | The wildcard walks every rule block and NotContains "any" fails on the same rules Sentinel's contains check does; error_tolerance 2 skips null lists and destroyed SRGs, as Sentinel's find_resources and null handling do. | +| `gcp/enforce-mandatory-labels` | tag-keys-required | exact | Same shape as the tags policies, on the labels map. | +| `gcp/restrict-gce-machine-type` | attribute-in-list | exact | Single-attribute ContainedIn with identical null handling. | +| `gcp/restrict-gke-clusters` | attribute-numeric | exact | Six single-attribute checks; Sentinel skips null counts and lists 'null' as an allowed machine type, both reproduced by error_tolerance 2 on the missing attribute. | +| `vmware/require-storage-drs` | attribute-equals | exact | Single-attribute Equals true. | +| `vmware/require_nfs41_and_kerberos` | attribute-equals | exact | One Equals and one ContainedIn on the same resource type; null fails in both engines. | +| `vmware/restrict-virtual-disk-size-and-type` | attribute-numeric | exact | One numeric bound and one Equals; null size violates in Sentinel and fails as missing in Tirith. | +| `vmware/restrict-vm-cpu-and-memory` | attribute-numeric | exact | Two numeric bounds on one resource type with identical null handling. | +| `vmware/restrict-vm-disk-size` | attribute-numeric | exact | A single attribute across a nested block list via the * wildcard; Sentinel's per-VM 'any disk violates' collapses to the same per-resource fail. | +| `aws/enforce_s3_encryption` | cross-resource-reference | approximate | Tirith can require every aws_s3_bucket_server_side_encryption_configuration to use AES256/aws:kms and every aws_s3_bucket to be referenced by one (direct_references, type-level), but loses the tfconfig-reference pairing of a specific config to a specific bucket and the legacy branch that demands inline aws:kms only. | +| `aws/protect-against-rds-instance-deletion` | destroy-guard | approximate | Tirith `action ContainedIn ["delete"]` with `!` blocks a pure delete of aws_db_instance (a replacement still passes, matching Sentinel), but cannot read change.before so it is stricter: destroys of instances with deletion_protection=false are also blocked. | +| `aws/restrict-ami-owners` | attribute-in-list | approximate | Only reachable by feeding the state file to the json provider; the wildcard path cannot be filtered to mode=data/type=aws_ami, so any other state resource exposing an owners attribute (e.g. aws_ami_ids) is also checked (stricter), and the list-of-lists ContainedIn semantics are undefined. | +| `aws/restrict-assumed-roles` | provider-region | approximate | provider_config can read constant provider settings, but Sentinel also resolves a role_arn given as var.X through tfplan.variables; Tirith sees no constant there and would fail/skip that provider. | +| `aws/restrict-current-ec2-instance-type` | attribute-in-list | approximate | Requires the state file via the json provider; the wildcard cannot select type=aws_instance, so other state resources with an instance_type attribute (aws_launch_configuration, aws_spot_instance_request) are also checked (stricter). | +| `aws/restrict-egress-sg-rule-cidr-blocks` | nested-block-cidr-port | approximate | The egress.*.cidr_blocks evaluator on aws_security_group is exact, but for aws_security_group_rule the type=='egress' filter cannot be bound to the same resource, so ingress rules with 0.0.0.0/0 are also flagged (stricter). | +| `aws/restrict-iam-policy-actions` | attribute-not-in-list | approximate | Needs the state file via the json provider and cannot filter to the data source type; case-insensitivity is only available if the regex engine accepts (?i) and NotContains is a literal match, so 'IAM:create*' casing variants may slip through. | +| `aws/restrict-ingress-sg-rule-cidr-blocks` | nested-block-cidr-port | approximate | ingress.*.cidr_blocks on aws_security_group is exact; for aws_security_group_rule the type=='ingress' filter cannot be bound per resource, so egress rules with 0.0.0.0/0 are also flagged (stricter). | +| `aws/restrict_s3_acl` | cross-resource-reference | approximate | Tirith can require acl=private on aws_s3_bucket (error_tolerance for buckets without inline acl) and on every aws_s3_bucket_acl, plus a type-level direct_references check, but cannot pair a specific acl resource to a specific bucket via tfconfig references, and cannot reproduce Sentinel's fail-when-no-buckets quirk. | +| `aws/validate-providers-from-desired-regions` | provider-region | approximate | provider_config covers constant regions, but Sentinel also resolves var.* references through tfplan.variables, module-call arguments and variable defaults, and the tfrun.variables env-var check has no Tirith counterpart. | +| `azure/require-database-auditing` | cross-resource-reference | approximate | The OR is per database in Sentinel but per evaluator in Tirith: a plan with one inline-audited DB and one externally-audited DB passes Sentinel yet fails both Tirith evaluators (stricter). | +| `azure/require-free-sec-center-subscription-pricing-for-vms` | conditional-attribute | approximate | The resource_type filter cannot be bound to the tier test on the same resource; a plan with (VirtualMachines, Free) and (StorageAccounts, Standard) passes Sentinel but fails Tirith (stricter). | +| `azure/restrict-inbound-source-address-prefixes` | conditional-attribute | approximate | The direction==Inbound AND access==Allow filter cannot be bound to the prefix test per rule or per nested block, so Deny-from-* (default-deny) and Outbound rules are flagged too (stricter). | +| `azure/restrict-outbound-destination-address-prefixes` | conditional-attribute | approximate | The direction==Outbound AND access==Allow filter cannot be bound per rule/block, so Deny-to-* and Inbound rules with those destinations are flagged too (stricter). | +| `azure/restrict-publishers-of-current-vms` | attribute-in-list | approximate | Requires the state file via the json provider; cannot restrict to the three VM types, so other state resources with the same nested publisher attribute (e.g. VM scale sets) are also checked (stricter). | +| `cis-aws/cloudtrail/cloudtrail-cloudwatch-logs-group-arn-present` | attribute-present | approximate | A reference to a not-yet-created log group is unknown in the plan so change.after omits it: Sentinel accepts any reference, Tirith fails on the missing attribute (or, with error_tolerance=2, also skips a genuinely unset attribute). | +| `cis-aws/cloudtrail/cloudtrail-server-side-encryption-enabled` | attribute-present | approximate | kms_key_id almost always references a new aws_kms_key whose ARN is unknown at plan time, so change.after omits it; Sentinel passes any reference while Tirith sees a missing attribute (fail, or skip with error_tolerance=2 which also skips an unset key). | +| `cis-aws/ec2/ec2-metadata-imdsv2-required` | attribute-equals | approximate | The fallback is per instance in Sentinel but a \|\| b is plan-wide in Tirith: with a compliant metadata_defaults resource present, an aws_instance that explicitly sets http_tokens='optional' passes Tirith but fails Sentinel. | +| `cis-aws/ec2/ec2-network-acl` | nested-block-cidr-port | approximate | Stricter: cidr, protocol, from_port<=P<=to_port and egress=false cannot be bound to the same rule/ingress block, so an ACL with one 0.0.0.0/0 rule on port 443 and a separate 10.0.0.0/8 rule on port 22 fails Tirith but passes Sentinel; the list-valued blocked_ports param also needs one evaluator pair per port. | +| `cis-aws/ec2/ec2-security-group-ingress-traffic-restriction-port` | nested-block-cidr-port | approximate | Stricter: the catch-all CIDR test and the from_port<=port<=to_port (or protocol=-1) test cannot be tied to the same ingress block or rule, so a group with an open 443 rule plus a restricted 22 rule fails Tirith but passes Sentinel. | +| `cis-aws/ec2/ec2-security-group-ingress-traffic-restriction-protocol` | nested-block-cidr-port | approximate | Stricter for the same reason (no per-block conjunction of CIDR and port-range/protocol), and the two boolean params that switch IPv4/IPv6 checks off entirely have no Tirith equivalent other than editing the policy. | +| `cis-aws/ec2/ec2-vpc-default-security-group-no-traffic` | cross-resource-reference | approximate | Stricter: direct_references is type-level so a rule that references aws_vpc for cidr_blocks=[aws_vpc.x.cidr_block] is flagged the same as one referencing aws_vpc.x.default_security_group_id; Sentinel only matches the security_group_id reference. | +| `cis-aws/ec2/ec2-vpc-flow-logging-enabled` | cross-resource-reference | approximate | Cannot pair a flow log's traffic_type with the VPC it references: a VPC whose only flow log is ACCEPT passes if another flow log elsewhere uses ALL, and a plan with an ACCEPT flow log on an unmanaged VPC id fails Tirith but not Sentinel. | +| `cis-aws/efs/efs-encryption-at-rest-enabled` | attribute-equals | approximate | kms_key_id referencing a new aws_kms_key is unknown at plan time and absent from change.after, so Tirith fails (or skips with error_tolerance=2) where Sentinel accepts the reference. | +| `cis-aws/iam/iam-no-policies-attached-to-users` | count-limit | approximate | Sentinel's planned_values excludes resources being destroyed while Tirith count includes every resource_changes entry, so a plan that only destroys an existing user policy attachment fails Tirith but passes Sentinel (action Equals [delete] with error_tolerance=1 would close the gap if list equality is supported). | +| `cis-aws/kms/kms-key-rotation-enabled` | attribute-equals | approximate | Stricter: the exemption is per key in Sentinel but a \|\| b is plan-wide, so a disabled key without rotation alongside an enabled rotated key fails Tirith (a fails on key 1, b fails on key 2) yet passes Sentinel. | +| `cis-aws/s3/s3-block-public-access-bucket-level` | cross-resource-reference | approximate | Cannot pair a compliant block with its bucket: a non-compliant block attached to an unmanaged bucket (bucket = "existing-name") fails Tirith but is ignored by Sentinel; var-driven settings resolve better in the plan than Sentinel's var-only lookup. | +| `cis-aws/s3/s3-require-mfa-delete` | cross-resource-reference | approximate | Cannot pair the compliant versioning resource with its bucket: a versioning resource with mfa_delete=Disabled on an unmanaged bucket fails Tirith but is ignored by Sentinel, and a bucket whose versioning is non-compliant fails both only because the attribute evaluator is plan-wide. | +| `cis-aws/vpc/vpc-flow-logging-enabled` | cross-resource-reference | approximate | Cannot pair a flow log's traffic_type with the VPC it references: a VPC whose only flow log is ACCEPT passes when another flow log is REJECT, and a REJECT-less flow log on an unmanaged VPC id fails Tirith but not Sentinel. | +| `cloud-agnostic/allowed-providers` | provider-allowlist | approximate | provider_name on planned_values resources reproduces the resource/data-source check for the root module and each enumerated child_modules depth (allowlist spelled as registry.terraform.io/hashicorp/), but a provider block with no resources and resources in modules nested deeper than the enumerated wildcard depth are not flagged (looser). | +| `cloud-agnostic/allowed-resources` | resource-type-allowlist | approximate | resource_changes.*.type ContainedIn reproduces the allowlist for every instance in the plan across all modules, but resource blocks with zero instances (count=0/for_each={}) are invisible (looser) while deferred data sources and resources being destroyed are judged too (stricter). | +| `cloud-agnostic/limit-cost-and-percentage-increase` | cost | approximate | The absolute limit maps to total_monthly_cost LessThanEqualTo 1000, but the percentage-increase check needs prior cost, delta and division, so plans under $1000 with a >10% jump pass (looser); the figure is also an Infracost breakdown rather than the TFC estimate. | +| `cloud-agnostic/limit-proposed-monthly-cost` | cost | approximate | Same shape, but the figure is an Infracost breakdown rather than the TFC cost estimate, so resource coverage and prices differ and a plan near the limit can flip verdicts; Sentinel also passes when no estimate exists. | +| `cloud-agnostic/prohibited-datasources` | datasource-denylist | approximate | planned_values lists both plan-time and deferred data sources by type, so root-module and enumerated-depth uses are caught, but data sources in deeper nested modules and blocks with zero instances are missed (looser). | +| `cloud-agnostic/prohibited-providers` | provider-denylist | approximate | provider_name on planned_values resources catches null_resource and root/enumerated-depth external/http data sources (denylist spelled as registry.terraform.io/hashicorp/), but a provider block with no resources and uses in deeper nested modules are not flagged (looser). | +| `cloud-agnostic/prohibited-resources` | resource-type-denylist | approximate | count Equals 0 per prohibited type covers every instance in the plan (including no-op), but a resource block with zero instances is invisible (looser) and an instance being destroyed is still counted (stricter). | +| `cloud-agnostic/restrict-databricks-clusters` | attribute-in-list | approximate | Both checks map to attribute evaluators with error_tolerance 2 so an absent autoscale block or unknown node_type_id skips as Sentinel allows null; the one divergence is an explicit JSON null node_type_id, which Sentinel whitelists via the literal "null" list entry and whose handling under ContainedIn Tirith does not define. | +| `cloud-agnostic/restrict-terraform-versions` | terraform-version | approximate | Sentinel compares the version as a plain string, so 0.2.x-0.9.x lexicographically exceed "0.12.0" and wrongly pass; a Tirith RegexMatch ^(0\.1[2-9]\|[1-9]) encodes the intended semver check and rejects them (stricter only on those legacy versions). | +| `gcp/restrict-egress-firewall-destination-ranges` | conditional-attribute | approximate | The direction==EGRESS filter cannot be bound to the destination_ranges test; an INGRESS rule that sets destination_ranges 0.0.0.0/0 passes Sentinel but fails Tirith (stricter, rare). | +| `gcp/restrict-ingress-firewall-source-ranges` | conditional-attribute | approximate | The direction==INGRESS filter cannot be bound to the source_ranges test; an EGRESS rule carrying source_ranges 0.0.0.0/0 (a plan GCP would reject at apply) passes Sentinel but fails Tirith (stricter). | +| `aws/require-most-recent-AMI-version` | tfconfig-only | not expressible | Needs tfconfig expression references on the ami argument (direct_references is only a type-level 'some reference exists' boolean and does not name the attribute) plus most_recent on a state data source, which the plan attribute operation cannot address. | +| `aws/restrict-assumed-roles-by-workspace` | provider-region | not expressible | Needs the TFC workspace name (tfrun) and a role->workspace-regex map evaluated per provider block, plus resolution of var-referenced role_arn; Tirith has no workspace context and no map lookup. | +| `aws/restrict-ingress-sg-rule-rdp` | nested-block-cidr-port | not expressible | Requires a per-rule/per-block conjunction (cidr contains 0.0.0.0/0 AND from_port<=3389 AND to_port>=3389); Tirith evaluators combine only at the all-resources level, so a public 443 rule plus a private 3389 rule would fail. | +| `aws/restrict-ingress-sg-rule-ssh` | nested-block-cidr-port | not expressible | Requires a per-rule/per-block conjunction (cidr contains 0.0.0.0/0 AND from_port<=22 AND to_port>=22); Tirith cannot bind three attribute tests to the same block or resource. | +| `aws/restrict-s3-bucket-policies` | other | not expressible | Needs tfconfig expression references for the policy argument, plan-time data-source blocks, and per-statement conjunctions over effect/condition.test/variable/values with an 'exists a matching Deny statement' (any) quantifier. | +| `aws/restrict-subnet-of-ec2-instances` | tfconfig-only | not expressible | Entirely about configuration expressions (references, constant_value, module_address) which Tirith cannot read; plan after-values contain only the resolved subnet id. | +| `cis-aws/cloudtrail/cloudtrail-bucket-access-logging-enabled` | cross-resource-reference | not expressible | Needs per-instance comparison of aws_cloudtrail.s3_bucket_name against aws_s3_bucket_logging.bucket (resolving aws_s3_bucket.x refs to bucket names); Tirith cannot compare two resources' attributes or follow instance-level references. | +| `cis-aws/cloudtrail/cloudtrail-logs-bucket-not-public` | cross-resource-reference | not expressible | Requires resolving aws_cloudtrail.s3_bucket_name and aws_s3_bucket_public_access_block.bucket to the same bucket name per instance (across three candidate resource types); Tirith has no cross-resource attribute comparison or instance-level reference resolution. | +| `cis-aws/iam/iam-no-admin-privileges-allowed-by-policies` | policy-document-statement | not expressible | Needs a per-statement conjunction (effect==Allow AND actions contains '*' AND resources contains '*' on the same statement) over data-source values that live only in tfstate; the json provider's wildcard cannot bind three fields of one statement and cannot filter to mode=data/type=aws_iam_policy_document. | +| `cis-aws/s3/s3-enable-object-logging-for-events` | cross-resource-reference | not expressible | Needs per-instance comparison of aws_s3_bucket.bucket against arn-prefix-trimmed aws_cloudtrail data_resource values, plus a per-event_selector conjunction (include_management_events AND read_write_type AND data_resource.type); Tirith has neither cross-resource attribute comparison nor per-nested-block conjunction. | +| `cis-aws/s3/s3-require-ssl` | policy-document-statement | not expressible | Needs resolution of the aws_s3_bucket_policy.policy reference to a data.aws_iam_policy_document (in tfstate or tfconfig), a per-statement conjunction of effect=Deny AND s3 actions AND condition{test=Bool,variable=aws:SecureTransport,values contains false}, and bucket-to-policy pairing; Tirith has none of these. | +| `cloud-agnostic/allowed-datasources` | datasource-allowlist | not expressible | Data source types are not exposed by any Tirith operation: plan-time-read data sources are absent from resource_changes, and the json provider cannot filter planned_values resources by mode or recurse child_modules, so an allowlist would also reject every managed resource. | +| `cloud-agnostic/allowed-provisioners` | provisioner-allowlist | not expressible | Provisioners exist only in the Terraform configuration (tfconfig.provisioners); no Tirith operation reads them. | +| `cloud-agnostic/evaluate-variables-in-nested-modules` | tfconfig-only | not expressible | The verdict is a constant pass, but the policy's entire effect is printing module-call inputs from tfconfig.module_calls[*].config, which Tirith has no operation for. | +| `cloud-agnostic/http-examples/asteroids` | http | not expressible | Requires the http import, time.now and float parsing; Tirith has no external HTTP data source. | +| `cloud-agnostic/http-examples/check-external-http-api` | http | not expressible | Requires the http import; Tirith has no external HTTP data source. | +| `cloud-agnostic/http-examples/use-latest-module-versions` | module-version | not expressible | Needs HTTP registry lookups, tfconfig module sources/version constraints and semver satisfies(); none exist in Tirith. | +| `cloud-agnostic/http-examples/use-recent-versions-from-pmr` | module-version | not expressible | Needs HTTP registry lookups, tfconfig module sources/version constraints and semver satisfies(); none exist in Tirith. | +| `cloud-agnostic/limit-cost-by-workspace-name` | cost | not expressible | Requires tfrun.workspace.name to select the limit and to fail unmatched names; Tirith has no workspace metadata, and a per-workflow -var limit would silently pass unmatched workspaces. | +| `cloud-agnostic/prevent-non-root-providers` | tfconfig-only | not expressible | Needs tfconfig.providers with module_address, config keys and version_constraint; Tirith's provider_config exposes settings of one named provider, not the set of provider blocks per module. | +| `cloud-agnostic/prevent-remote-exec-provisioners-on-null-resources` | provisioner-denylist | not expressible | Provisioners and their resource_address exist only in tfconfig.provisioners; no Tirith operation reads them. | +| `cloud-agnostic/prohibited-local-exec-commands` | provisioner-denylist | not expressible | Needs tfconfig.provisioners[*].config.command constant_value/references; no Tirith operation reads provisioners. | +| `cloud-agnostic/prohibited-provisioners` | provisioner-denylist | not expressible | Provisioners exist only in tfconfig.provisioners; no Tirith operation reads them. | +| `cloud-agnostic/require-all-modules-have-version-constraint` | module-version | not expressible | Module call version constraints live only in tfconfig.module_calls; no Tirith operation exposes module calls. | +| `cloud-agnostic/require-all-providers-have-version-constraint` | provider-version | not expressible | Needs quantification over all tfconfig.providers with version_constraint; Tirith's provider_config addresses one named provider's settings and cannot iterate the open set of providers. | +| `cloud-agnostic/require-all-resources-from-pmr` | module-source | not expressible | Needs module_call.source from tfconfig and tfrun.is_destroy; neither exists (json on resource_changes.*.module_address could catch root-module resources, but not module sources). | +| `cloud-agnostic/restrict-remote-state` | other | not expressible | The allowed list is selected by tfrun.workspace.name, which Tirith lacks; reading config.workspaces.name from a state file via the json provider is possible but cannot filter by resource type or fail unmatched workspace names. | +| `cloud-agnostic/restrict-resources-by-module-source` | module-source | not expressible | Needs module_calls[*].source resolved through module_address ancestry (tfconfig); Tirith sees module_address only, never module sources. | +| `cloud-agnostic/validate-variables-have-descriptions` | tfconfig-only | not expressible | Variables exist only in tfconfig.variables; no Tirith operation reads them. | diff --git a/.claude/skills/tirith-migrate/reference/sentinel.md b/.claude/skills/tirith-migrate/reference/sentinel.md new file mode 100644 index 00000000..74e63b7e --- /dev/null +++ b/.claude/skills/tirith-migrate/reference/sentinel.md @@ -0,0 +1,147 @@ +# Sentinel to Tirith + +Measured, not estimated. Every table here was built by classifying the 110 policies in +`hashicorp/terraform-sentinel-policies` (AWS, Azure, GCP, VMware, cloud-agnostic) and +`hashicorp/policy-library-CIS-Policy-Set-for-AWS-Terraform`, then checking the Tirith side against +the engine. + +## What to expect + +| | exact | approximate | not expressible | +| --- | --- | --- | --- | +| All 110 | 41 | 40 | 29 | +| CIS AWS (32) | 13 | 14 | 5 | +| Cloud-specific (48) | 25 | 17 | 6 | +| Cloud-agnostic (30) | 3 | 9 | 18 | + +Attribute policies on a single resource type translate exactly. The cloud-agnostic set is mostly +`tfconfig` and `tfrun` policies, which is why it does not. + +## Imports + +| Sentinel import | Tirith | Notes | +| --- | --- | --- | +| `tfplan/v2` | `stackguardian/terraform_plan` | The main path. Reads `resource_changes[].change.after` | +| `tfplan/v2` `terraform_version` | operation `terraform_version` | | +| `tfconfig/v2` provider blocks | operation `provider_config` | Only `region` (constant values) and `version_constraint`. Needs `terraform_provider_full_name` | +| `tfconfig/v2` anything else | none | Module sources and versions, variables, outputs, provisioners, expression references. Not expressible | +| `tfstate/v2` | `stackguardian/json` on a state file | `key_path` wildcards cannot filter by resource type, so other types are swept in | +| `tfrun.cost_estimate` | `stackguardian/infracost` | Different data source and different numbers; total only, no percentage increase | +| `tfrun.workspace`, `tfrun.variables`, `tfrun.is_destroy` | none | Not expressible | +| `http` | none | Not expressible | + +## Common-function helpers + +The `tfplan-functions` library is how most public policies are written. Each helper returns the +*violations*, so the Tirith condition is the desired state, which is the helper's opposite. + +The fidelity column rates the **test**. The **scope** and the **attribute** can still make a +translation approximate: Sentinel skips no-op and deleted resources and Tirith does not (see +"Scope differs even when the test is exact"), and an attribute that is computed at apply time, +such as `region` on a bucket that inherits it from the provider, is absent from `change.after` +and fails in Tirith where the helper's absence-tolerant flag passed it in Sentinel (see "Unknown +values"). Check both before marking a row exact. + +| Helper | Tirith condition | Fidelity | +| --- | --- | --- | +| `find_resources(type)` | `terraform_resource_type` | exact | +| `filter_attribute_is_value(a, v)` | `Equals v` | exact | +| `filter_attribute_is_not_value(a, v)` | `NotEquals v` | exact | +| `filter_attribute_not_in_list(a, allowed)` | `ContainedIn allowed` | exact | +| `filter_attribute_in_list(a, forbidden)` | `NotContainedIn forbidden` | exact | +| `filter_attribute_contains_items_from_list(a, forbidden)` | one `NotContains` evaluator per item, joined with `&&` | exact | +| `filter_attribute_contains_items_not_in_list(a, allowed)` | none: needs a subset test | not expressible | +| `filter_attribute_map_key_contains_items_not_in_list` | none: same | not expressible | +| `filter_attribute_greater_than_value(a, n)` | `LessThanEqualTo n` | exact | +| `filter_attribute_less_than_value(a, n)` | `GreaterThanEqualTo n` | exact | +| `filter_attribute_greater_than_equal_to_value(a, n)` | `LessThan n` | exact | +| `filter_attribute_less_than_equal_to_value(a, n)` | `GreaterThan n` | exact | +| `filter_attribute_does_not_match_regex(a, re)` | `RegexMatch re` | exact | +| `filter_attribute_matches_regex(a, re)` | `RegexMatch re` and `!` in the expression | exact | +| `filter_attribute_does_not_have_prefix(a, p)` | `RegexMatch "^p"` | exact | +| `filter_attribute_does_not_have_suffix(a, s)` | `RegexMatch "s$"` | exact | +| `case_insensitive_filter_...` | `RegexMatch "(?i)..."` | exact | +| `filter_attribute_was_value` | none: reads `change.before` | not expressible, issue #332 | +| `find_resources_being_destroyed()` | operation `action`, `NotEquals "delete"`, no negation | exact. `action` emits one result per action, so this fails on a replacement too. If the source excludes replacements, use `ContainedIn ["delete"]` with `!` instead | +| `find_providers_by_type` region checks | `provider_config`, `attribute: region` | approximate: a region set from a variable is invisible | +| `find_all_module_calls`, `get_module_source` | none | not expressible, issue #348 | +| `find_all_provisioners` | none | not expressible | +| `find_all_variables`, `find_all_outputs` | none | not expressible | +| `find_datasources` | `terraform_resource_type` | approximate: data sources read at plan time are absent from `resource_changes` | +| `limit_proposed_monthly_cost` | infracost `total_monthly_cost`, `LessThanEqualTo` | approximate: different estimator | +| `limit_cost_and_percentage_increase` | total only | percentage not expressible | + +## Idioms in hand-written policies + +| Sentinel | Tirith | +| --- | --- | +| `x is v`, `x == v` | `Equals` | +| `x is not v` | `NotEquals` | +| `x in [...]` | `ContainedIn` | +| `x not in [...]` | `NotContainedIn` | +| `list contains x` | `Contains` | +| `x matches "re"` | `RegexMatch` | +| `x is not null`, `x is defined` | `IsNotEmpty`. Handles both an absent key and an explicit `null` | +| `x else default` | `error_tolerance: 2` skips a resource whose `change.after` lacks the key. It does **not** cover `null`: `terraform show -json` renders an unset optional list such as `cidr_blocks` as `null`, and `Contains`/`NotContains` on `null` is a hard "unsupported data type" failure that no tolerance forgives. A rule with `source_security_group_id` instead of CIDRs is a false positive under a CIDR translation, and there is no workaround today | +| `x is null`, `x is empty` | `IsEmpty` | +| `< <= > >=` | `LessThan LessThanEqualTo GreaterThan GreaterThanEqualTo` | +| `keys(r.tags) contains "Owner"` | `Contains "Owner"` on the `tags` attribute. `Contains` on a map tests its keys | +| `all X as r { c }` | one evaluator: it fails if any resource fails | +| `any X as r { c }` | not expressible: no existential quantifier | +| `not any X as r { c }` | a detector evaluator and `!` in the expression | +| `rule_a and rule_b` | `a && b`. Exact only when each rule ranges over all resources independently | +| `length(violations) is 0` | the evaluator itself | +| `param name default v` | `{{ var.name }}` in the value, with `-var` or `-var-path` | +| enforcement level in `sentinel.hcl` | `meta.enforcement`, passed through to the result. The CLI treats every policy as hard-mandatory under `--fail-on-error` | + +Two engine behaviours to know, both verified: + +- A present-but-null attribute is **not** a missing attribute. `error_tolerance: 2` skips a + resource without the key; `"kms_key_id": null` is evaluated as null. `IsNotEmpty` fails it + cleanly; `Contains`, `NotContains`, `ContainedIn` and the ordering conditions fail it as an + unsupported type, which reads like a violation. +- A value unknown until apply is absent from `change.after`. Sentinel policies that accept any + reference (`kms_key_id = aws_kms_key.x.arn`) see a value; Tirith sees a missing attribute. + +## Scope differs even when the test is exact + +`find_resources` and the `actions contains "create" or "update"` idiom exclude no-op and deleted +resources. Tirith evaluates every `resource_changes` entry of the type, so an unchanged resource +that already violates the rule fails the plan (Sentinel passes it), and a deleted resource is a +severity-0 error that is skipped without touching its siblings' verdicts. This applies to every +row marked exact above: exact on the resources both tools evaluate, not on which resources are +evaluated. + +## Why 69 of 110 are not exact + +In order of how often each was the reason: + +1. **Conditional scope and per-block conjunction** (about 20). "Ingress rules where type is + ingress and from_port ≤ 22 ≤ to_port and cidr is 0.0.0.0/0" cannot bind the tests to one block. + The translation is stricter. Issue #316, `resource_filter`, is the fix. +2. **Instance-level pairing across resources** (about 11). "Every bucket has a logging resource + pointing at it." `direct_references` is type-level, so one compliant helper resource satisfies + every bucket. Not tracked as an issue. +3. **`tfconfig`-only data** (about 14). Module sources and versions, provisioners, variables, + provider version constraints. Issue #348, an HCL source provider, is research. +4. **Unknown values** (about 4). A `kms_key_id` referencing a key created in the same plan. +5. **`change.before` and destroys** (about 3). Issue #332. +6. **Cost percentage, workspace metadata, `http`** (about 7). Not planned. +7. **JSON documents inside strings** (2). IAM policy statements behind `jsonencode`. Issue #338. + +## From a Sentinel mock to a Tirith fixture + +Sentinel tests live in `test//`. Each `*.hcl` there names a mock file and states the +expected verdict (`main = false` is the failing case); mock filenames vary, so read the `.hcl` +first. The +`resource_changes` map in a mock has the same keys as `terraform show -json`: `address`, `type`, +`name`, `mode`, `change.actions`, `change.before`, `change.after`, `change.after_unknown`. +Transcribe the failing mock to `should-fail.json` and the passing one to `should-pass.json`, +wrapped as `{"format_version": "1.2", "terraform_version": "...", "resource_changes": [...]}`. +Drop `tfconfig` and `tfstate` mocks; Tirith does not read them. + +## Refusing well + +When a policy is not expressible, the reader needs three sentences: what the policy enforces, +what Tirith cannot see, and what would change that. See +`examples/sentinel/require-private-registry-modules/notes.md` for the shape. diff --git a/.claude/skills/tirith-policies/SKILL.md b/.claude/skills/tirith-policies/SKILL.md index e0de797c..81e1634a 100644 --- a/.claude/skills/tirith-policies/SKILL.md +++ b/.claude/skills/tirith-policies/SKILL.md @@ -5,35 +5,32 @@ description: Write, validate, run and debug Tirith IaC governance policies, inst # Tirith -Tirith evaluates the plan a pipeline already produces against declarative JSON policies, and -exits non-zero so a violating change never reaches `apply`. +Tirith evaluates the plan a pipeline already produces against declarative JSON policies and exits +non-zero so a violating change never reaches `apply`. A policy is **JSON data, not a program**: it +names a provider, the value to inspect, and the condition that value must satisfy. -A policy is **JSON data, not a program**. It names a provider, the value to inspect, and the -condition that value must satisfy. Tirith does the traversal and returns resource-level evidence. +## Install -## Install: not from PyPI - -`pip install tirith` installs an **unrelated project of the same name**. `pip install py-tirith` -finds nothing: that is the package name in `setup.py`, and it is not published. Install from git, -pinned to a tag: +Not from PyPI. `pip install tirith` installs an **unrelated project**; `py-tirith` is the +`setup.py` name and is not published. Install from git, pinned to a tag: ```bash pip install "git+https://github.com/StackGuardian/tirith.git@1.2.0" +tirith --version # 1.2.0 ``` ## The one rule **Never hand back a policy you have not run against a document that should fail it.** -A policy that matches nothing looks identical to one that works: same shape, same silence. Run it -against input you expect to be refused. If that run exits `0`, the policy matched nothing and -gates nothing. +A policy that matches nothing looks identical to one that works. Run it against input you expect +to be refused. Exit `0` means it matched nothing and gates nothing. ```bash -tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error +tirith -policy-path .tirith/policies -input-path should-fail.json --fail-on-error; echo $? # want 3 ``` -## Exit codes are a contract +## Exit codes | Exit | Meaning | What CI should do | | --- | --- | --- | @@ -41,27 +38,23 @@ tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error | `3` | A policy failed | Fail the job: the change was refused | | `1` | No verdict could be reached | Fail the job, but report a **tool or input** problem | -`ExitStatus.ERROR_TIMEOUT = 2` is declared in `status.py` and returned nowhere, including on the -platform path, which maps a timeout to `1`. Do not branch a pipeline on it. - -`3` is deliberately not `1`. Collapsing them reports an outage as a policy violation, and a job -that cannot tell them apart cannot tell a working gate from a broken one. - -**`final_result: null` is not a pass.** It means every check was skipped, so the policy evaluated -nothing. It exits `1`. +- Never collapse `3` into `1`. A job that cannot tell them apart reports an outage as a violation. +- `2` is never returned. A bad argument or unknown subcommand exits `1`, as does a platform + timeout. `1` means tool, input or usage; it never means a policy said no. +- **`final_result: null` is not a pass.** Every check was skipped, nothing was evaluated, exit `1`. +- Without `--fail-on-error` the exit is always `0`. Every real gate needs the flag. ## Write a policy -Work in this order. Guessing any of the four is the main source of silently-broken policies. +Decide these four in order. Guessing any of them is the main source of silently broken policies. -1. **Which document are you reading?** An OpenTofu or Terraform plan, a Kubernetes manifest, an - Infracost breakdown, or arbitrary JSON or YAML. That fixes `meta.required_provider`. There are - five providers and **no CloudFormation provider**: a CloudFormation template is arbitrary JSON, - read by `stackguardian/json`. -2. **Which operation?** Each provider exposes a closed set: see `reference/schema.md`. -3. **Which key names the value?** It differs per provider, and the wrong one is *ignored* rather - than rejected, so the check reads nothing and passes. See `reference/schema.md`. -4. **Which condition?** Thirteen, listed in `reference/schema.md`. There is no `Exists`. +1. **Document.** Terraform or OpenTofu plan, Kubernetes manifest, Infracost breakdown, or arbitrary + JSON or YAML. This fixes `meta.required_provider`. Five providers ship; there is **no + CloudFormation provider**, a template is arbitrary JSON read by `stackguardian/json`. +2. **Operation.** Each provider exposes a closed set: `reference/schema.md`. +3. **Key naming the value.** It differs per provider, and a wrong key is *ignored, not rejected*, + so the check reads nothing and passes: `reference/schema.md`. +4. **Condition.** Thirteen, listed in `reference/schema.md`. There is no `Exists`. ```json { @@ -85,44 +78,56 @@ Work in this order. Guessing any of the four is the main source of silently-brok ``` `eval_expression` combines evaluator **ids** with `&&`, `||`, `!` and parentheses. An evaluator -the expression never names cannot affect the verdict. `!` is the only negation mechanism: there -are no inverse conditions, so write the positive detector and invert it. - -## Four traps that cost the most time - -**`error_tolerance` goes inside `condition`, not on the evaluator.** On the evaluator it is -silently ignored: no warning, and the check still fails. +it never names cannot affect the verdict. `!` is the only negation: write the positive detector +and invert it. + +## Traps + +- **`error_tolerance` goes inside `condition`.** On the evaluator it is silently ignored. + `{"condition": {"type": "IsNotEmpty", "error_tolerance": 2}}` +- **One evaluator, one result per matching resource.** Three buckets give three results from one + rule, and the check fails if any of them fails. +- **Missing attribute is severity 2, missing resource type is severity 1.** With + `error_tolerance: 2` a resource lacking the attribute is *skipped*, not failed. If every + evaluator is skipped the policy is `final_result: null`, exit `1`. Skipping is not passing. +- **A type-scoped policy refuses a plan with none of that type.** Severity 1 under the default + tolerance is exit `3`; with `error_tolerance: 1` it is exit `1`. Neither is `0`. See + `reference/verdicts.md`. +- **The delete action is spelled `delete`.** `"destroy"` matches nothing and the guard exits `0`. + `action` emits one result per action: `NotEquals "delete"` blocks deletes and replacements, + `ContainedIn ["delete"]` with `!` blocks only a pure delete. See `reference/terraform-plan.md`. +- **An unknown `condition.type` exits `3`, not `1`.** The message names it (`` `Exists` is not a + supported evaluator ``) but `errors` is empty, so CI sees a violation. Check the type against + the closed list, not your memory. +- **`tirith lint` catches most of the above before CI does**, and needs no plan document. It is + on `main` but not in `1.2.0`, so pin `@main` to use it. `reference/validate.md` has the detail. + +## Test it with the bundled example + +`examples/required-tags/` holds the policy above, a plan that violates it and one that satisfies +it. Copy the pair and edit it when testing a new policy. -```json -{"condition": {"type": "IsNotEmpty", "error_tolerance": 2}} +```bash +cd examples/required-tags +tirith -policy-path policy.json -input-path should-fail.json --fail-on-error; echo $? # 3 +tirith -policy-path policy.json -input-path should-pass.json --fail-on-error; echo $? # 0 ``` -**One evaluator produces one result per matching resource.** A plan with three buckets gives three -results from one rule, and the check fails if any of them fails. That is the mechanism, not a -wildcard trick. - -**A missing attribute is severity 2, a missing resource type is severity 1.** With -`error_tolerance: 2` a resource lacking the attribute is *skipped* rather than failed, which can -turn the whole policy into `final_result: null`. Skipping is not passing. - -**`tirith lint` is not in the released package.** It is in development. The released CLI dispatches -`tirith`, `tirith ui` and `tirith platform check` and nothing else, so do not put it in a pipeline -you are writing for someone. Check policy shape by reading `reference/schema.md` and by running -the policy. See `reference/validate.md`. - ## Before you hand it back 1. Does `eval_expression` reference every evaluator you wrote? 2. Is every `condition.type` in the closed list of thirteen? -3. Is `error_tolerance`, if used, inside `condition`? -4. Did you **run it** against a document that should fail it, and did it exit `3`? +3. Is the argument key the one this provider reads? +4. Is `error_tolerance`, if used, inside `condition`? +5. Did you **run it** against a document that should fail it, and did it exit `3`? +6. Did you run it against a document that should pass, and did it exit `0`? ## Reference | File | Use it for | | --- | --- | | `reference/schema.md` | The closed vocabulary: conditions, providers, operations, argument keys | -| `reference/validate.md` | Checking a policy is well-formed, and the traps to check by hand | +| `reference/validate.md` | Checking a policy is well-formed with `tirith lint`, `tirith fmt` and the interface | | `reference/verdicts.md` | Running a policy, exit codes, and finding the resource behind a failure | | `reference/terraform-plan.md` | The plan provider's operations, for OpenTofu and Terraform | | `reference/other-providers.md` | Kubernetes, Infracost and arbitrary JSON or YAML | @@ -131,5 +136,7 @@ the policy. See `reference/validate.md`. | `reference/pipelines.md` | GitHub Actions, GitLab CI, Bitbucket, Jenkins, Azure DevOps, CircleCI | | `reference/platform.md` | Evaluating against an organization's central policies | | `reference/debug-ci.md` | Starting from a red build and ending at the rule and the resource | +| `examples/required-tags/` | A policy, a plan that fails it, and a plan that passes it | -Worked policy/input pairs live in `src/tirith/tui/examples/` in the Tirith repository. +Translating existing Sentinel policies is a separate skill, `tirith-migrate`, installed alongside +this one. diff --git a/.claude/skills/tirith-policies/examples/required-tags/README.md b/.claude/skills/tirith-policies/examples/required-tags/README.md new file mode 100644 index 00000000..ae4da128 --- /dev/null +++ b/.claude/skills/tirith-policies/examples/required-tags/README.md @@ -0,0 +1,27 @@ +# Worked example: every resource carries a costcenter tag + +One policy and two plans. Use it to confirm Tirith is installed and to see what a working +policy looks like before writing your own. + +| File | | +| --- | --- | +| `policy.json` | `IsNotEmpty` on `tags.costcenter` across every resource type | +| `should-fail.json` | Two resources, one without the tag. Expect exit `3` | +| `should-pass.json` | The same plan with both resources tagged. Expect exit `0` | + +```bash +cd .claude/skills/tirith-policies/examples/required-tags +tirith -policy-path policy.json -input-path should-fail.json --fail-on-error; echo "exit: $?" # 3 +tirith -policy-path policy.json -input-path should-pass.json --fail-on-error; echo "exit: $?" # 0 +``` + +Copy the pair when testing a new policy: edit `policy.json` to the rule you want, then change +`should-fail.json` until it violates it. A rule that has only ever been seen passing is untested. + +Things to try: + +- Add `"error_tolerance": 2` inside `condition` and run against `should-fail.json`. The check is + skipped rather than failed, `final_result` becomes `null`, and the exit is `1`, not `0`. +- Change `IsNotEmpty` to `Equals` with `"value": "product-123"` to pin one exact value. +- Change the type to `Exists`. It does not exist. The run exits `3` with `errors` empty, so CI + sees a violation; only the result message says "`Exists` is not a supported evaluator". diff --git a/.claude/skills/tirith-policies/examples/required-tags/policy.json b/.claude/skills/tirith-policies/examples/required-tags/policy.json new file mode 100644 index 00000000..daa3485b --- /dev/null +++ b/.claude/skills/tirith-policies/examples/required-tags/policy.json @@ -0,0 +1,22 @@ +{ + "meta": { + "version": "v1", + "required_provider": "stackguardian/terraform_plan", + "name": "Every resource carries a costcenter tag" + }, + "evaluators": [ + { + "id": "costcenter_tag_present", + "description": "Every taggable resource declares a costcenter tag", + "provider_args": { + "operation_type": "attribute", + "terraform_resource_type": "*", + "terraform_resource_attribute": "tags.costcenter" + }, + "condition": { + "type": "IsNotEmpty" + } + } + ], + "eval_expression": "costcenter_tag_present" +} diff --git a/.claude/skills/tirith-policies/examples/required-tags/should-fail.json b/.claude/skills/tirith-policies/examples/required-tags/should-fail.json new file mode 100644 index 00000000..01f573e4 --- /dev/null +++ b/.claude/skills/tirith-policies/examples/required-tags/should-fail.json @@ -0,0 +1,48 @@ +{ + "format_version": "1.2", + "terraform_version": "1.5.7", + "resource_changes": [ + { + "address": "aws_instance.web", + "mode": "managed", + "type": "aws_instance", + "name": "web", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": ["create"], + "before": null, + "after": { + "instance_type": "t3.micro", + "tags": { + "Name": "web-server", + "costcenter": "product-123" + } + }, + "after_unknown": { + "arn": true, + "id": true + } + } + }, + { + "address": "aws_s3_bucket.assets", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "assets", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": ["create"], + "before": null, + "after": { + "bucket": "example-assets", + "tags": { + "Name": "assets" + } + }, + "after_unknown": { + "arn": true + } + } + } + ] +} diff --git a/.claude/skills/tirith-policies/examples/required-tags/should-pass.json b/.claude/skills/tirith-policies/examples/required-tags/should-pass.json new file mode 100644 index 00000000..5e5a5e2b --- /dev/null +++ b/.claude/skills/tirith-policies/examples/required-tags/should-pass.json @@ -0,0 +1,53 @@ +{ + "format_version": "1.2", + "terraform_version": "1.5.7", + "resource_changes": [ + { + "address": "aws_instance.web", + "mode": "managed", + "type": "aws_instance", + "name": "web", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "instance_type": "t3.micro", + "tags": { + "Name": "web-server", + "costcenter": "product-123" + } + }, + "after_unknown": { + "arn": true, + "id": true + } + } + }, + { + "address": "aws_s3_bucket.assets", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "assets", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "bucket": "example-assets", + "tags": { + "Name": "assets", + "costcenter": "product-123" + } + }, + "after_unknown": { + "arn": true + } + } + } + ] +} diff --git a/.claude/skills/tirith-policies/reference/debug-ci.md b/.claude/skills/tirith-policies/reference/debug-ci.md index 892bf9cd..e3c2f9be 100644 --- a/.claude/skills/tirith-policies/reference/debug-ci.md +++ b/.claude/skills/tirith-policies/reference/debug-ci.md @@ -13,7 +13,7 @@ echo $? | --- | --- | --- | | `3` | A policy ran and refused the change | Step 2 — this is a real verdict | | `1` | No verdict was reached | Step 4 — this is not a violation | -| `0` but you expected a failure | Nothing was in scope, or `--fail-on-error` is missing | Step 5 | +| `0` but you expected a failure | The policy matched nothing (wrong key, wrong operation), or `--fail-on-error` is missing | Step 5 | A job that collapses `1` and `3` will send you to step 2 for a problem that lives in step 4. Fix the job's exit-code handling first if it does that. @@ -49,10 +49,10 @@ Check `final_result` in the result document. path, then whether `error_tolerance` is forgiving the very thing you meant to catch. - **No `final_result` at all** — the policy could not be loaded. Usually an unresolved variable; read the `errors` array. -- **A misconfigured policy** — an unsupported `condition.type` or unknown provider arrives as an - ordinary failed check and exits `3`, not `1`. Check `condition.type` and every `provider_args` - key against `reference/schema.md`: an unknown key is ignored rather than rejected, so the fault - is in the policy even though the failure points at infrastructure. +- **A misconfigured policy** — an unsupported `condition.type` arrives as a failed check and exits + `3`, not `1`; its message names the type. An unknown `provider_args` key is ignored rather than + rejected, so the check reads nothing. Check both against `reference/schema.md`: the fault is in + the policy even though the exit code points at infrastructure. ## 5. Exit `0` when you expected a failure diff --git a/.claude/skills/tirith-policies/reference/pipelines.md b/.claude/skills/tirith-policies/reference/pipelines.md index f118c995..c007ae7f 100644 --- a/.claude/skills/tirith-policies/reference/pipelines.md +++ b/.claude/skills/tirith-policies/reference/pipelines.md @@ -31,8 +31,9 @@ pip install "git+https://github.com/StackGuardian/tirith.git@1.2.0" Not PyPI: `pip install tirith` fetches an unrelated project. Pin the tag so a job cannot change behaviour underneath you. Python 3.8 or newer, so any `python:3.x` image works. -Do **not** add `tirith lint` to a pipeline: it is not in the released package. See -`reference/validate.md`. +Add `tirith lint .tirith/policies` as a step before the gate: it needs no plan document, so it +can run in a job with no cloud credentials, and it fails for a different reason than the gate +does. It is not in `1.2.0`, so pin `@main` in any job that uses it. See `reference/validate.md`. --- @@ -205,8 +206,18 @@ tirith --json -policy-path .tirith/policies -input-path plan.json > tirith-resul Publish it as a build artifact. It carries every evaluator, its result and the value that produced it, which is what makes a failure explainable after the fact. -## Not yet available +## At commit time -A `tirith-lint` pre-commit hook and a VS Code task loop are in development, and both depend on -`tirith lint`, which is not in the released package. Do not write either into a pipeline today. -`https://stackguardian.github.io/tirith/roadmap/` tracks them. +`.pre-commit-hooks.yaml` publishes `tirith-lint` and `tirith-fmt`, both running on the policy +files a commit touches: + +```yaml +repos: + - repo: https://github.com/StackGuardian/tirith + rev: main + hooks: + - id: tirith-lint + - id: tirith-fmt +``` + +`rev` is a branch because neither command is in `1.2.0` yet. See `reference/validate.md`. diff --git a/.claude/skills/tirith-policies/reference/platform.md b/.claude/skills/tirith-policies/reference/platform.md index cd06856c..b3d197ed 100644 --- a/.claude/skills/tirith-policies/reference/platform.md +++ b/.claude/skills/tirith-policies/reference/platform.md @@ -45,8 +45,7 @@ The same contract as local evaluation, plus one: | Exit | Meaning | | --- | --- | | `0` | Passed | -| `1` | Could not reach a verdict — bad input, unreachable API | -| `2` | Timed out waiting for the run | +| `1` | Could not reach a verdict — bad input, unreachable API, or the run timed out | | `3` | A policy failed (with `--fail-on-error`) | ## Credentials diff --git a/.claude/skills/tirith-policies/reference/schema.md b/.claude/skills/tirith-policies/reference/schema.md index 44e9caf9..46947ef8 100644 --- a/.claude/skills/tirith-policies/reference/schema.md +++ b/.claude/skills/tirith-policies/reference/schema.md @@ -1,11 +1,11 @@ # Schema — the closed vocabulary -Both registries are closed. Inventing a value does not raise an error: an unknown -`condition.type` reaches the engine as an **ordinary failed check with no error attached**, so it -is indistinguishable from a real violation and sends someone to debug infrastructure that is fine. +Both registries are closed. Inventing a value does not stop the run: an unknown +`condition.type` becomes a **failed check**, exit `3`, with `errors` empty, so CI reads it as a +policy violation rather than a tool problem. The result message does name it +(`` `Exists` is not a supported evaluator ``), so read the message before debugging infrastructure. -Confirm against the live registry rather than this file. `tirith lint --gotchas` will do it once -lint ships; today the registry itself is the source of truth: +Confirm against the live registry rather than this file. It is the source of truth: ```bash python -c "from tirith.core.evaluators import EVALUATORS_DICT; print(sorted(EVALUATORS_DICT))" diff --git a/.claude/skills/tirith-policies/reference/terraform-plan.md b/.claude/skills/tirith-policies/reference/terraform-plan.md index d3b546e1..d4cf8f35 100644 --- a/.claude/skills/tirith-policies/reference/terraform-plan.md +++ b/.claude/skills/tirith-policies/reference/terraform-plan.md @@ -29,27 +29,45 @@ Arguments: `terraform_resource_type` selects the resources (`"*"` = every type), ## `attribute` cannot see a destroy `attribute` reads **`change.after` only**. A resource being destroyed has `after: null`, so -nothing about a destroy is visible through it. Use `action`: +nothing about a destroy is visible through it. Use `action`. + +## `action` emits one result per action + +A resource's `change.actions` is a list: `["create"]`, `["update"]`, `["delete"]`, or for a +replacement `["delete", "create"]` / `["create", "delete"]`. The `action` operation emits **one +result per element**, and the evaluator fails if any element fails. Two forms follow from that, +and they mean different things: + +**Block every delete, including a replacement.** The universal form: every action must be +something other than `delete`. No negation. ```json { - "id": "no_database_destroy", - "provider_args": { - "operation_type": "action", - "terraform_resource_type": "aws_db_instance" - }, - "condition": {"type": "ContainedIn", "value": ["destroy"]} + "id": "no_database_delete", + "provider_args": {"operation_type": "action", "terraform_resource_type": "aws_db_instance"}, + "condition": {"type": "NotEquals", "value": "delete"} } ``` -with `"eval_expression": "!no_database_destroy"` — the check *detects* a destroy, and `!` turns -detection into refusal. +with `"eval_expression": "no_database_delete"`. Exit `3` on `["delete"]` and on +`["delete", "create"]`; exit `0` on `["update"]`. + +**Block only a pure delete, allow a replacement.** The detector form: `ContainedIn ["delete"]` +passes on a `delete` element and fails on any other, so on a replacement the evaluator has one +pass and one fail, fails as a whole, and `!` turns that into a pass. + +```json +{"condition": {"type": "ContainedIn", "value": ["delete"]}} +``` + +with `"eval_expression": "!no_database_delete"`. Exit `3` only on `["delete"]`. -## Replacement is two actions, not one +Two traps, both verified against the engine: -A replacement appears as `["delete", "create"]` or `["create", "delete"]`, and the order matters: -destroy-first means downtime, create-first does not. If the distinction matters to your rule, test -the ordering rather than the presence of `delete`. +- The action is spelled `delete`, never `destroy`. `"value": ["destroy"]` matches nothing and the + policy exits `0` on a real delete. +- The order of `["delete", "create"]` versus `["create", "delete"]` cannot be tested. Each element + is evaluated on its own. ## `count` measures the module, not the change @@ -83,6 +101,8 @@ attribute at all. Those raise severity `2`. Decide deliberately: - `error_tolerance: 0` — a resource without the attribute **fails**. Right for "everything must be tagged". - `error_tolerance: 2` — it is **skipped**. Right for "where this attribute exists, it must be X". + A skipped resource does not touch the verdict of the others: the evaluator still fails if any + resource fails, and is skipped as a whole only when every resource was tolerated away. On a wildcard policy every message reads identically, and only the resource address in the result distinguishes one finding from another. diff --git a/.claude/skills/tirith-policies/reference/validate.md b/.claude/skills/tirith-policies/reference/validate.md index 9f5c599b..85f0fedd 100644 --- a/.claude/skills/tirith-policies/reference/validate.md +++ b/.claude/skills/tirith-policies/reference/validate.md @@ -1,19 +1,34 @@ # Validate a policy -## `tirith lint` is not in the released package +## `tirith lint` checks the shape -It is in development. The released CLI dispatches `tirith`, `tirith ui` and `tirith platform -check` and nothing else, so `tirith lint` in a pipeline you are writing for someone else is a step -that fails with an unrecognised argument. +This is the one place in the pack that explains it; the other files point here. -Until it ships there are two ways to validate, and both are available today. +```bash +tirith lint .tirith/policies +``` + +It reads the live `EVALUATORS_DICT` and `PROVIDERS_DICT` registries, so it catches every trap in +the table below except the ones only evaluation can catch. Exit `3` when a policy has an error, +`1` when a path is missing or nothing was found, `0` when clean. `--strict` counts warnings as +errors, `--json` emits the findings as a document. + +With no path it lints `.tirith/policies` if that directory exists, otherwise the current +directory. JSON that is not a policy is skipped, so pointing it at a directory holding plan +documents is safe. + +**It is not in 1.2.0.** `tirith lint` and `tirith fmt` are on `main` and arrive in the next +release. Install `@main` rather than the tag if you want them now: +`pip install "git+https://github.com/StackGuardian/tirith.git@main"`. -## The interactive validator does ship +`tirith fmt` rewrites a policy into the canonical layout, and `tirith fmt --check` exits `3` if +a file would change. Neither command needs a plan document, which is why both work in a +pre-commit hook. -`tirith ui` carries one. `src/tirith/tui/validate.py` reads the live `EVALUATORS_DICT` and -`PROVIDERS_DICT` and returns errors and warnings as data, and the Playground runs it on every -keystroke while the Builder refuses to add a check that fails it. So the registry-checking that -`tirith lint` will do from the command line is already in the product, just interactively: +## The same validator, interactively + +`tirith ui` carries the one `tirith lint` calls. The Playground runs it on every keystroke while +the Builder refuses to add a check that fails it: ```bash pip install 'py-tirith[tui] @ git+https://github.com/StackGuardian/tirith.git' @@ -23,10 +38,11 @@ tirith ui --policy .tirith/policies/my-policy.json It is advisory by design: it reports a malformed policy rather than refusing to evaluate it, because experimenting with a half-written policy is the point of a playground. -## Without the interface +## Without either command -Check the shape against the closed vocabulary by hand, then evaluate the policy against a document -that should fail it. The second is the one that matters. +Pinned to `1.2.0` and unable to install `@main`? Check the shape against the closed vocabulary by +hand, then evaluate the policy against a document that should fail it. The second is the one that +matters, and it works on every version. ## Check the shape @@ -35,7 +51,7 @@ for a reason unrelated to your infrastructure. | Trap | Why it matters | | --- | --- | -| An invented condition type | There is no `Exists`, `Matches` or `In`. The engine returns an unknown type as an ordinary failed check, so it reads as a real violation rather than a typo. | +| An invented condition type | There is no `Exists`, `Matches` or `In`. The engine returns an unknown type as a failed check, exit `3`, `errors` empty. The result message does name it; the exit code does not. | | A key from the wrong provider | `terraform_plan` reads `terraform_resource_attribute`; `kubernetes` reads `attribute_path`. An unrecognised key is **ignored, not rejected**, so the evaluator reads nothing and the check passes. | | An operation that does not ship | `jmespath` and `jq_query` appear in some test fixtures. Neither exists. | | `error_tolerance` outside `condition` | It belongs **inside** `condition`. On the evaluator it is silently ignored: no warning, and the check still fails as though the tolerance were never written. | @@ -57,6 +73,9 @@ tirith -policy-path .tirith/policies -input-path should-fail.json --fail-on-erro echo "exit: $?" ``` +`examples/required-tags/` in this pack has a policy with a failing and a passing plan. Copy the +pair and edit it rather than starting from an empty file. + | Exit | Reading | | --- | --- | | `3` | The policy works. It refused a change it was supposed to refuse. | @@ -75,11 +94,3 @@ tirith --json -policy-path .tirith/policies -input-path plan.json > result.json The JSON carries every evaluator, its result, and the value that produced it. When a check surprises you, the value it actually read is the fastest way to the cause: an evaluator reading `None` on every resource is the signature of a key the provider ignored. - -## When lint ships - -It reads the engine's own registries, so it catches the invented condition type and the -wrong-provider key from the source of truth rather than from a table that can go stale. It will -exit `3` for a bad policy and `1` for an unreadable path, matching the rest of Tirith: the linter -saying no about a policy is a verdict, not a tool failure. Check -`https://stackguardian.github.io/tirith/roadmap/` before assuming it is available. diff --git a/.claude/skills/tirith-policies/reference/verdicts.md b/.claude/skills/tirith-policies/reference/verdicts.md index 4d1ea67a..c574463e 100644 --- a/.claude/skills/tirith-policies/reference/verdicts.md +++ b/.claude/skills/tirith-policies/reference/verdicts.md @@ -12,12 +12,18 @@ Add `--json` to get the result document instead of the pretty printer. | Exit | Meaning | | --- | --- | -| `0` | Policies passed, or nothing was in scope to gate on | +| `0` | Every check passed | | `1` | Tirith could not tell you either way — bad input, an unevaluable policy, or every check skipped | -| `2` | Timed out waiting for a StackGuardian run (`platform check` only) | | `3` | A policy ran and said no | | `130` | Interrupted | +`2` is never returned by Tirith itself. An unknown first argument is reported by name +(`tirith: 'lnit' is not a tirith command`) and exits `1`, the same as a tool or input problem. +Tirith has no timeout code: a `platform check` that times out is `1` too. + +`tirith lint` and `tirith fmt` use the same codes and need no `--fail-on-error`: `3` for a finding, +`1` for a missing path or nothing found, `0` for clean. + **`3` is deliberately not `1`.** `3` means a check ran and refused the change. `1` means Tirith could not reach a verdict. A job that treats every non-zero code alike reports an outage as a policy violation and cannot tell a working gate from a broken one. @@ -26,6 +32,16 @@ policy violation and cannot tell a working gate from a broken one. That is the historical behaviour, kept so upgrading cannot turn a passing pipeline red. Any real gate needs the flag. +## A type-scoped policy refuses a plan that has none of the type + +`terraform_resource_type: "aws_db_instance"` on a plan with no database is severity `1`, "resource +type not found". Under the default `error_tolerance: 0` that is a **failure, exit `3`**, so the +policy refuses every unrelated plan. With `error_tolerance: 1` it is skipped instead; if it was the +only evaluator, `final_result` is `null` and the exit is `1`. There is no setting that yields +"nothing in scope, pass" for a single-evaluator scoped policy. Choose deliberately: `1` with CI +treating exit `1` as advisory for that policy, or put several types' checks in one policy so a +skip on one leaves a verdict from the others. Not tracked as a Tirith issue at the time of writing. + ## `final_result: null` is not a pass It means **every check was skipped** — nothing was evaluated. Under `--fail-on-error` that exits @@ -55,8 +71,7 @@ looking in the plan for the resource lacking that attribute. ## A misconfigured policy fails closed -An unsupported `condition.type` or an unknown `required_provider` comes back as an ordinary failed -check with no error attached — indistinguishable from a real violation, and it exits `3`. It fails -in the safe direction, but it points at your infrastructure when the fault is in the policy. -Check the condition type against the closed list in `reference/schema.md`: a typo there is the -usual cause, and it is not reported as one. +An unsupported `condition.type` comes back as a failed check, exit `3`, with the `errors` array +empty. It fails in the safe direction, but a job that branches on the exit code sees a policy +violation. The result message names the fault (`` `Exists` is not a supported evaluator ``), so +when a check fails on every resource at once, read the message before reading the plan. diff --git a/.claude/skills/tirith-standards/SKILL.md b/.claude/skills/tirith-standards/SKILL.md new file mode 100644 index 00000000..458833e1 --- /dev/null +++ b/.claude/skills/tirith-standards/SKILL.md @@ -0,0 +1,146 @@ +--- +name: tirith-standards +description: Generate a Tirith policy set for an existing Terraform or OpenTofu repository, covering organization standards such as required tags, naming conventions, allowed regions, permitted resource types, sizes and encryption defaults. Use when asked to add guardrails to a repository that has no policies yet, to enforce a tagging or naming standard, to codify a team convention, or to produce a starting policy set from existing IaC. Requires the tirith-policies skill for the vocabulary. +--- + +# Standards policies from existing IaC + +A catalogue of generic checks is not what stops your incidents. The rules that do are the ones +only your organization can state: the tag your finance team reconciles against, the module +registry you are allowed to pull from, the two regions your data residency answer depends on. + +This skill turns a repository you already have into that policy set. + +## Vocabulary comes from `tirith-policies` + +Do not write policy JSON from memory. Read `../tirith-policies/reference/schema.md` for the +closed list of providers, operations, argument keys and the thirteen condition types. If that +skill is not installed, fetch `https://stackguardian.github.io/tirith/llms.txt` and follow it to +the schema page. Everything below assumes that vocabulary. + +## The boundary that decides every policy: HCL in, plan out + +You read `.tf` and `.tofu` files to **discover** what to write policies about. The policies +themselves never see that HCL. They evaluate `terraform show -json` output, where: + +| In your code | In the plan | +| --- | --- | +| `var.environment`, `local.tags`, `${}` interpolation | already resolved to a value, or `null` when it is not known until apply | +| a `module` block | its resources, at addresses like `module.vpc.aws_subnet.this[0]` | +| `count` / `for_each` | one resource instance per element | +| a computed name | `null`, because the plan does not know it yet | +| `dynamic` blocks | the blocks they expanded into | + +So: never write a policy about a variable, a local, or a module block. Write it about the +resource attribute the plan will carry. A standard that is only enforceable in HCL (for example +"always use our module rather than the raw resource") is expressible as +`direct_references` or as a `provider_config` check, or not at all. Say which, rather than +producing a policy that reads nothing. + +## The rule that catches most generated policies + +``` +severity > error_tolerance -> the check FAILS +otherwise -> the check is SKIPPED +``` + +`DEFAULT_ERROR_TOLERANCE` is `0`, and the plan provider raises severity `1` when the resource +type is absent from the plan and `2` when the attribute is absent from the resource. + +**Therefore a policy scoped to one resource type has no green outcome on a plan that does not +touch that type.** Both tolerance settings are red, in different ways, and this is the single +most common defect in a generated standards set: + +| `error_tolerance` | Plan has no resource of that type | Reads as | +| --- | --- | --- | +| omitted, so `0` | `1 > 0`, the check **fails**, exit `3` | your infrastructure violates a policy | +| `1` | the check is **skipped**, so every check is skipped, `final_result: null`, exit `1` | Tirith could not reach a verdict | + +### Guard every type-scoped policy + +`count` behaves differently from `attribute` on an absent type: it returns `0` rather than +raising severity `1`. So it can gate the rule. + +```json +{ + "evaluators": [ + {"id": "not_applicable", + "provider_args": {"operation_type": "count", "terraform_resource_type": "aws_s3_bucket"}, + "condition": {"type": "Equals", "value": 0}}, + {"id": "the_rule", "provider_args": {"...": "..."}, + "condition": {"type": "RegexMatch", "value": "...", "error_tolerance": 1}} + ], + "eval_expression": "not_applicable || the_rule" +} +``` + +Verified on the engine: exit `3` when a bucket breaks the rule, `0` when they all satisfy it, +`0` when the plan touches no bucket at all. `examples/org-standards/` is that policy with its +three plans. + +Keep `error_tolerance: 1` on the rule alongside the guard, so the not-applicable case is +*skipped* rather than *failed and then masked by the `||`*. Both give exit `0`; only one leaves +a clean result document. + +| Standard | Scope | Shape | +| --- | --- | --- | +| Presence, all types | `"*"` | no guard needed, omit the tolerance. A plan always contains something | +| Anything scoped to a type | `aws_s3_bucket` | guard + `error_tolerance: 1` | +| Anything | any | never `2`. It forgives the missing attribute, which is the thing you were checking | + +Deletes are safe by default: a destroyed resource is severity `0`, which the default tolerance +skips rather than fails. + +## Protocol + +1. **Inventory the repository.** List every distinct `resource "TYPE" "NAME"` across `.tf` and + `.tofu` files. Group by provider (`aws_`, `azurerm_`, `google_`). Note which types are used + more than once: those are where a standard pays for itself. Note the modules in use and where + they are sourced from. + +2. **Extract the conventions already there.** Read the existing code before proposing rules. What + tag keys appear on most resources? What naming pattern do the existing names follow? Which + regions and instance sizes appear? A standard the repository already follows is a rule you can + enforce today without a migration; a standard it does not follow yet is a change request, and + you must say so rather than shipping a policy that fails on day one. + +3. **Propose before writing.** Give the user a table: standard, the resource types it would cover, + how many resources in the repository would pass today, and how many would fail. Let them + choose. Do not generate thirty policies unasked. + +4. **Write one file per standard**, under `.tirith/policies/`, named for the rule + (`required-tags.json`, `naming-s3.json`). One concern per file: a single policy combining tags + and naming reports one verdict for two unrelated problems, and the person who has to fix it + cannot tell which fired. + +5. **Prove each one.** For every policy, produce a plan document that violates it and confirm + `tirith -policy-path P -input-path should-fail.json --fail-on-error` exits `3`, then one that + satisfies it and confirm exit `0`. A policy that has only been seen passing is untested. Run + `tirith lint` over the directory first: it catches an invented condition type or a misplaced + `error_tolerance` without needing any plan at all. + +6. **Report what you could not express.** Name the standard, name what Tirith would need, and stop. + A policy that approximates a rule without saying so is worse than an absent one. + +## The standards catalogue + +`reference/standards.md` carries one row per standard: the exact `provider_args`, the condition, +the tolerance, and the trap specific to that standard. Read it before writing any of them. It +covers required tags, tag value shape, naming conventions, allowed regions, permitted and +forbidden resource types, size ceilings, encryption and public-access defaults, provider version +pinning, and module source restrictions. + +## What not to generate + +| | Why | +| --- | --- | +| A policy per resource instance | Standards are about types, not addresses. `terraform_resource_type` already quantifies over every instance | +| A policy about `var.` or `local.` | Neither survives into the plan. See the boundary table above | +| `error_tolerance: 2` on a presence check | It forgives exactly the case the check exists to catch | +| A rule the repository does not already follow, shipped silently | It fails on the first run and gets disabled. Propose it as a change first | +| Thirty policies from one request | Propose the set, ship what is agreed | + +## Worked example + +`examples/org-standards/` holds a naming policy with the plan that violates it and the plan that +satisfies it, including the `error_tolerance: 1` that keeps it quiet on unrelated changes. diff --git a/.claude/skills/tirith-standards/examples/org-standards/README.md b/.claude/skills/tirith-standards/examples/org-standards/README.md new file mode 100644 index 00000000..a1412345 --- /dev/null +++ b/.claude/skills/tirith-standards/examples/org-standards/README.md @@ -0,0 +1,61 @@ +# Worked example: a naming standard that stays green on unrelated changes + +One policy and three plans. It exists to demonstrate the shape every type-scoped standards +policy needs, because the obvious version of this policy is red on most pull requests. + +| File | | +| --- | --- | +| `naming.json` | `RegexMatch` on `aws_s3_bucket.bucket`, guarded by a `count` evaluator | +| `should-fail.json` | Two buckets, one named `my-logs-bucket`. Expect exit `3` | +| `should-pass.json` | The same two buckets, both named to convention. Expect exit `0` | +| `no-bucket.json` | A plan that touches only an EC2 instance. Expect exit `0` | + +```bash +cd .claude/skills/tirith-standards/examples/org-standards +for doc in should-fail should-pass no-bucket; do + tirith -policy-path naming.json -input-path "$doc.json" --fail-on-error >/dev/null 2>&1 + echo "$doc -> exit $?" +done +# should-fail -> exit 3 +# should-pass -> exit 0 +# no-bucket -> exit 0 +``` + +## Why the guard is there + +A policy scoped to one resource type has no green outcome on a plan that does not touch that +type. Both settings are red, in different ways: + +| `error_tolerance` | Plan has no bucket | Reads as | +| --- | --- | --- | +| omitted, so `0` | severity `1` is greater than `0`, so the check **fails**, exit `3` | your infrastructure violates a policy | +| `1` | the check is **skipped**, every check is skipped, `final_result` is `null`, exit `1` | Tirith could not reach a verdict | + +Neither is what you want on a pull request that only edits a Lambda. + +The guard fixes it because `count` behaves differently from `attribute` on an absent type: it +returns `0` rather than raising severity `1`. So `no_bucket_in_this_plan` evaluates cleanly to +`true`, the naming check is skipped and dropped from the expression, and + +``` +no_bucket_in_this_plan || bucket_name_matches_convention +``` + +is `true`. On a plan that does have buckets the guard is `false` and the naming check decides. + +`error_tolerance: 1` stays on the naming evaluator so that in the no-bucket case it is *skipped* +rather than *failed and then masked by the guard*. Both produce exit `0` here, but a failure +hidden behind an `||` shows up in `--json` output and in the printed result, and a reader +debugging the policy later should not have to work out whether that failure mattered. + +## Things to try + +- Delete the guard evaluator and set `eval_expression` to the naming check alone. `no-bucket.json` + now exits `1` with `final_result: null`. +- Delete the guard *and* the `error_tolerance`. `no-bucket.json` now exits `3`, reporting a + policy violation for a plan containing nothing the policy is about. +- Change `RegexMatch` to `Matches`. It does not exist. The run exits `3` with `errors` empty, so + CI sees a violation and only the message says the evaluator is unsupported. +- Add a bucket using `bucket_prefix` instead of `bucket` to `should-pass.json`. The name is not + known at plan time, arrives as `null`, and fails the regex. This is the trap named in + `meta.description`. diff --git a/.claude/skills/tirith-standards/examples/org-standards/naming.json b/.claude/skills/tirith-standards/examples/org-standards/naming.json new file mode 100644 index 00000000..daedf8f7 --- /dev/null +++ b/.claude/skills/tirith-standards/examples/org-standards/naming.json @@ -0,0 +1,37 @@ +{ + "meta": { + "version": "v1", + "required_provider": "stackguardian/terraform_plan", + "name": "S3 bucket names follow the acme-- convention", + "description": "Guarded so a plan touching no bucket passes rather than erroring. Without the guard this policy cannot be green on an unrelated change: at the default tolerance an absent resource type is severity 1 and fails (exit 3), and at error_tolerance 1 it is skipped, which leaves final_result null and exits 1. Buckets using bucket_prefix arrive with a null name and will fail; scope the rule if the repository uses them." + }, + "evaluators": [ + { + "id": "no_bucket_in_this_plan", + "description": "True when the plan touches no aws_s3_bucket, so the naming rule does not apply. count returns 0 for an absent type rather than raising severity 1, which is what makes it usable as a guard.", + "provider_args": { + "operation_type": "count", + "terraform_resource_type": "aws_s3_bucket" + }, + "condition": { + "type": "Equals", + "value": 0 + } + }, + { + "id": "bucket_name_matches_convention", + "description": "Every aws_s3_bucket name is acme-, then dev|staging|prod, then a lowercase purpose. error_tolerance 1 skips this cleanly when there is no bucket, rather than failing and relying on the guard to mask it.", + "provider_args": { + "operation_type": "attribute", + "terraform_resource_type": "aws_s3_bucket", + "terraform_resource_attribute": "bucket" + }, + "condition": { + "type": "RegexMatch", + "value": "^acme-(dev|staging|prod)-[a-z0-9-]+$", + "error_tolerance": 1 + } + } + ], + "eval_expression": "no_bucket_in_this_plan || bucket_name_matches_convention" +} diff --git a/.claude/skills/tirith-standards/examples/org-standards/no-bucket.json b/.claude/skills/tirith-standards/examples/org-standards/no-bucket.json new file mode 100644 index 00000000..a88ec17a --- /dev/null +++ b/.claude/skills/tirith-standards/examples/org-standards/no-bucket.json @@ -0,0 +1,29 @@ +{ + "format_version": "1.2", + "terraform_version": "1.5.7", + "resource_changes": [ + { + "address": "aws_instance.web", + "mode": "managed", + "type": "aws_instance", + "name": "web", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "instance_type": "t3.micro", + "tags": { + "Owner": "platform" + } + }, + "after_unknown": { + "arn": true, + "id": true + } + } + } + ] +} diff --git a/.claude/skills/tirith-standards/examples/org-standards/should-fail.json b/.claude/skills/tirith-standards/examples/org-standards/should-fail.json new file mode 100644 index 00000000..3d33a3ec --- /dev/null +++ b/.claude/skills/tirith-standards/examples/org-standards/should-fail.json @@ -0,0 +1,52 @@ +{ + "format_version": "1.2", + "terraform_version": "1.5.7", + "resource_changes": [ + { + "address": "aws_s3_bucket.assets", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "assets", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "bucket": "acme-prod-assets", + "tags": { + "Owner": "platform" + } + }, + "after_unknown": { + "arn": true, + "id": true + } + } + }, + { + "address": "aws_s3_bucket.logs", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "logs", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "bucket": "my-logs-bucket", + "tags": { + "Owner": "platform" + } + }, + "after_unknown": { + "arn": true, + "id": true + } + } + } + ] +} diff --git a/.claude/skills/tirith-standards/examples/org-standards/should-pass.json b/.claude/skills/tirith-standards/examples/org-standards/should-pass.json new file mode 100644 index 00000000..970fb202 --- /dev/null +++ b/.claude/skills/tirith-standards/examples/org-standards/should-pass.json @@ -0,0 +1,52 @@ +{ + "format_version": "1.2", + "terraform_version": "1.5.7", + "resource_changes": [ + { + "address": "aws_s3_bucket.assets", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "assets", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "bucket": "acme-prod-assets", + "tags": { + "Owner": "platform" + } + }, + "after_unknown": { + "arn": true, + "id": true + } + } + }, + { + "address": "aws_s3_bucket.logs", + "mode": "managed", + "type": "aws_s3_bucket", + "name": "logs", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "bucket": "acme-prod-logs", + "tags": { + "Owner": "platform" + } + }, + "after_unknown": { + "arn": true, + "id": true + } + } + } + ] +} diff --git a/.claude/skills/tirith-standards/reference/standards.md b/.claude/skills/tirith-standards/reference/standards.md new file mode 100644 index 00000000..0cadc767 --- /dev/null +++ b/.claude/skills/tirith-standards/reference/standards.md @@ -0,0 +1,202 @@ +# The standards catalogue + +One row per standard. Every `provider_args` block below is +`"required_provider": "stackguardian/terraform_plan"` unless it says otherwise, and every +`error_tolerance` goes **inside `condition`**. + +**Every entry scoped to a resource type needs the guard evaluator from `SKILL.md`**, not just the +tolerance shown here. Without it the policy is red on any plan that does not touch that type: +exit `3` at tolerance `0`, exit `1` at tolerance `1`. The tolerance shown per standard is the one +to pair with the guard. + +Read `../../tirith-policies/reference/schema.md` for the closed condition list. There is no +`Exists`, no `Matches`, no `In` and no `NotRegexMatch`. + +--- + +## Tags + +### Every resource carries a tag key + +```json +{ + "operation_type": "attribute", + "terraform_resource_type": "*", + "terraform_resource_attribute": "tags.Owner" +} +``` +`IsNotEmpty`, no `error_tolerance`. + +`"*"` is right here: the rule is "everything", and a plan always contains something. Leaving the +tolerance at the default is what makes a missing tag fail, because an absent attribute is +severity `2`. + +**Trap.** `"*"` includes resource types that cannot be tagged at all +(`aws_iam_role_policy_attachment`, `random_id`, `null_resource`, every data source). Those fail +on a rule they can never satisfy. If the repository has them, scope to the taggable types you +actually use, one policy per type or one evaluator per type combined with `&&`, and set +`error_tolerance: 1` on each. + +**Trap.** AWS provider v4+ splits tags into `tags` and `tags_all`. `tags` holds what the +resource declares; `tags_all` includes provider-level `default_tags`. If your organization sets +`default_tags`, check `tags_all.Owner` or the rule fails on resources that inherit correctly. + +### A tag must take one of a fixed set of values + +```json +{ + "operation_type": "attribute", + "terraform_resource_type": "aws_instance", + "terraform_resource_attribute": "tags.Environment" +} +``` +`ContainedIn` with `"value": ["dev", "staging", "prod"]`, `error_tolerance: 1`. + +**Trap.** `ContainedIn` on two lists is element membership, not subset. If the attribute is +itself a list, this asks a different question than you think. + +### A tag must match a shape + +`RegexMatch` with `"value": "^[a-z]+-[0-9]{4}$"` on `tags.CostCentre`, `error_tolerance: 1`. + +**Trap.** `RegexMatch` against `null` does not match, so an absent tag fails. That is usually +what you want; say so in `meta.description` so nobody later "fixes" it with a tolerance. + +--- + +## Naming conventions + +The attribute holding the name **differs per resource type**. There is no generic `name`. + +| Type | Attribute | +| --- | --- | +| `aws_s3_bucket` | `bucket` | +| `aws_instance` | `tags.Name` | +| `aws_db_instance` | `identifier` | +| `aws_iam_role`, `aws_lambda_function`, `aws_ecs_cluster` | `name` / `function_name` | +| `azurerm_*` | `name` | +| `google_*` | `name` | + +```json +{ + "operation_type": "attribute", + "terraform_resource_type": "aws_s3_bucket", + "terraform_resource_attribute": "bucket" +} +``` +`RegexMatch` with `"value": "^acme-(dev|staging|prod)-[a-z0-9-]+$"`, `error_tolerance: 1`. + +**Trap, and it is the big one.** A name the plan does not know yet arrives as `null`. That +happens with `bucket_prefix`, with `name_prefix`, and with any name built from an attribute of a +resource being created in the same plan. `RegexMatch` against `null` fails, so the rule rejects +a change that is actually fine. Check the repository for `_prefix` arguments before shipping a +naming rule, and if they are in use, scope the rule to the resources that set a literal name. + +**Trap.** Anchor the pattern. `"acme-"` without `^` matches `not-acme-prod-thing`. + +--- + +## Allowed regions + +Region is not a resource attribute; it lives on the provider block. + +```json +{ + "operation_type": "provider_config", + "terraform_provider_full_name": "registry.terraform.io/hashicorp/aws", + "attribute_to_get": "region" +} +``` +`ContainedIn` with `"value": ["eu-central-1", "eu-west-1"]`. + +**Trap.** The full registry name is required, not `aws`. A wrong name is severity `1`. + +**Trap.** A region supplied by an environment variable or by an aliased provider will not appear +here. This rule proves the declared region, not the effective one. + +--- + +## Permitted and forbidden resource types + +Forbid a type by counting it: + +```json +{"operation_type": "count", "terraform_resource_type": "aws_iam_user"} +``` +`Equals` with `"value": 0`. + +**Trap.** `count` measures the root module and includes no-op resources, so a resource that is +merely present and unchanged still counts. This rule reads "must not exist in this +configuration", not "must not be created by this change". + +--- + +## Size ceilings + +```json +{ + "operation_type": "attribute", + "terraform_resource_type": "aws_ebs_volume", + "terraform_resource_attribute": "size" +} +``` +`LessThanEqualTo` with `"value": 100`, `error_tolerance: 1`. + +For instance types, prefer `ContainedIn` against an allow-list over a regex: an allow-list is +readable in a diff and a regex over instance families is not. + +--- + +## Encryption and public access + +```json +{ + "operation_type": "attribute", + "terraform_resource_type": "aws_ebs_volume", + "terraform_resource_attribute": "encrypted" +} +``` +`Equals` with `"value": true`, `error_tolerance: 1`. + +**Trap.** `true` and `"true"` are different questions. `condition.value` keeps its JSON type. + +**Trap.** An argument the configuration omits is often present in the plan as the schema default, +so it is checkable. An argument that is genuinely absent is severity `2` and fails at tolerance +`1`, which for an encryption rule is the correct outcome: unset encryption is unencrypted. + +--- + +## Provider version pinning + +```json +{ + "operation_type": "terraform_version" +} +``` +`GreaterThanEqualTo` with `"value": "1.6.0"`. + +**Trap.** This is a string comparison. `"1.10.0"` sorts below `"1.6.0"`. Pin a floor you are +confident about lexically, or state the limitation in `meta.description`. + +--- + +## Module source restrictions + +There is no operation that reads a module's `source`. The nearest available check is +`direct_references`, which reports what a resource references, and it produces no edge for +`var`, `local` or `module` references. + +**Say so rather than approximating.** "Only modules from our registry" is not expressible today. +Tracking: the roadmap's `module_address` filter would make it so. + +--- + +## Assembling the set + +- One file per standard under `.tirith/policies/`, named for the rule. +- Fill `meta.name` and `meta.description`. The description is where a trap gets recorded, and it + is what a person reads when the check fires eighteen months from now. +- `meta.severity` and `meta.tags` are carried into the result document but nothing gates on them + yet, so do not build a workflow that depends on them. +- Run `tirith lint .tirith/policies` before running anything against a plan. +- Prove each policy against a document that should fail it. diff --git a/.cursor/rules/tirith-policies.mdc b/.cursor/rules/tirith-policies.mdc index 120d4676..7864a7d3 100644 --- a/.cursor/rules/tirith-policies.mdc +++ b/.cursor/rules/tirith-policies.mdc @@ -34,8 +34,8 @@ rejects those with `Unsupported operator in eval_expression`. `!` is the only ne the positive detector and invert it. An evaluator the expression never references cannot affect the verdict. -**Verdicts:** exit `0` passed · `3` a policy failed · `1` no verdict was reached. (`2` is declared -in `status.py` but never returned.) `final_result: null` means every check was skipped: that is not a pass, it exits `1`, and +**Verdicts:** exit `0` passed · `3` a policy failed · `1` no verdict was reached. (`2` is never returned; +a bad argument exits `1`.) `final_result: null` means every check was skipped: that is not a pass, it exits `1`, and it usually means `provider_args` matched nothing. **Gotchas:** @@ -60,8 +60,9 @@ for that test, and exit `0` means it matched nothing. tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error ``` -`tirith lint` is **not in the released package**; it is in development. Do not put it in a -pipeline. The released CLI dispatches `tirith`, `tirith ui` and `tirith platform check`. +`tirith lint` checks a policy's shape without a plan document, and `tirith fmt` rewrites it into +the canonical layout. Both are on `main` but **not in `1.2.0`**, so pin `@main` in any job that +uses them. The CLI dispatches `lint`, `fmt`, `ui` and `platform`. ## Installing, and adding it to CI @@ -106,7 +107,7 @@ between them as an artifact: was reached. A job that cannot tell them apart reports an outage as a policy violation, and cannot tell a working gate from a broken one. -Worked examples: `src/tirith/tui/examples/`. Deeper reference, if the repository has it: +Worked example, if the repository has it: `.claude/skills/tirith-policies/examples/required-tags/`. Deeper reference: `.claude/skills/tirith-policies/reference/` covers the schema, validation, verdicts, each provider, policy variables, installing Tirith, six CI platforms, and debugging a red check. Online: https://stackguardian.github.io/tirith/llms.txt diff --git a/documentation/DESIGN-NOTES.md b/documentation/DESIGN-NOTES.md index 7912262b..850e4045 100644 --- a/documentation/DESIGN-NOTES.md +++ b/documentation/DESIGN-NOTES.md @@ -82,9 +82,11 @@ would turn `--fail-on-error` into a flag that does not exist. per page on `.page`, with a dark-theme block that redefines the same names. To change the palette, change the token block — do not hard-code colours in rules. -> Note for whoever rebuilds this: the token block is currently duplicated across five page -> stylesheets. That was fine at two pages and is now the main thing worth refactoring — -> one shared file, imported everywhere. +The shared file that note used to ask for now exists: `src/css/custom.css` declares the whole +palette at `:root` and maps Infima's variables onto it, which is what lets the documentation +be restyled from the same tokens as the pages. The five page stylesheets still carry their own +identical block and win on specificity, so nothing about them has changed yet. Removing those +five blocks is now a deletion rather than a rewrite, and is the remaining half of the job. **Section grammar**, shared by every page: a two-digit number, a title, an optional lede, then the content. Numbering is per page and runs `01`, `02`, `03`… @@ -215,13 +217,47 @@ diagrams → `03` opposed gates are the product. **Design note.** This page is linked only from footers. It is background for someone who has finished a product page, not a step toward installing anything. -### Docs — `docs-example.html` - -Standard Docusaurus documentation, wearing the site's chrome: same navbar, same 96rem -measure, same palette. Included so the review covers the moment a visitor crosses from the -designed pages into the documentation — historically the point where a site stops feeling -like one site. The page body itself is authored Markdown and is not part of this design -work. +### Docs, at `/tirith/docs/…` + +The moment a visitor crosses from the designed pages into the documentation is historically +where a site stops feeling like one site. It used to be that here: stock Infima below the +navbar, which meant rounded pills, card shadows, a system typeface and six syntax colours. + +It is now built from the same tokens. Two files do it, and the split is the point: + +- **`src/css/custom.css`** declares the palette and maps Infima's variables onto it. One + block moves the fonts, the accent and every corner radius at once, `--ifm-global-radius: 0` + being the single line that squares the whole theme. +- **`src/css/docs.css`** handles only what a variable cannot express: hairlines where Infima + draws cards, micro-labels where it draws sentence case, a rule above every `h2` so a long + reference page has the same spine as a landing page, and a heading scale stepped down from + the pages because documentation is read rather than scanned. + +Three swizzles support it, all of them wrappers rather than ejections: `DocBreadcrumbs` hosts +the copy-page control, `PaginatorNavLink` draws previous/next, `Admonition` supplies icons. + +**Syntax highlighting is one theme for both colour schemes**, defined in +`docusaurus.config.js` entirely in custom properties. That is not a shortcut: +prism-react-renderer writes its colours as inline `style` attributes on the token spans, and +an inline style cannot be overridden from a stylesheet, so naming variables is the only way +the highlighting can answer to the light/dark switch at all. Two hues, like everything else: +a key is ink, a value is the accent. + +**The copy-page control** beside the breadcrumbs hands the page to an agent: copy the +markdown, view it, or open it in ChatGPT or Claude. It serves the `.md` twin that +`scripts/generate-llms-full.py` writes for every route, so it is a pointer at something that +already existed rather than a scrape of the rendered DOM. + +Its last two items are **the only place on this site that draws a logo other than Tirith's +own**, which is a deliberate exception to the rule below and the reason it is written down. +The marks are the official single-path versions from simple-icons, unmodified, in +`src/components/docs/brandMarks.js`. The justification is recognition: "Open in ChatGPT" +beside a generic speech bubble is a control a reader has to read, and beside the real mark it +is one they can see. Every other glyph in the menu is stroked at the same hairline weight as +the rest of the design; these two are filled, because stroking a wordless trademark thickens +it into a blot and is a modification of someone else's mark. + +The page bodies are authored Markdown and are not part of this design work. --- diff --git a/documentation/README.md b/documentation/README.md index 10be0526..c71968c6 100644 --- a/documentation/README.md +++ b/documentation/README.md @@ -12,16 +12,16 @@ The documentation site and the marketing pages, built with | --- | --- | --- | | `/tirith/` | Landing | `src/pages/index.js` | | `/tirith/learn/` | Six lessons and a browser playground | `src/pages/learn.js` | -| ~~`/tirith/skills/`~~ | Tirith with a coding agent — **hidden**, see below | `src/pages/skills.js` | +| `/tirith/skills/` | Tirith with a coding agent | `src/pages/skills.js` | | `/tirith/docs/…` | The documentation | `docs/`, ordered by `sidebars.js` | | `/tirith/at-scale/` | Many repositories, one policy set — the commercial page | `src/pages/at-scale.js` | | `/tirith/origins/` | Where the name and the mark come from | `src/pages/origins.js` | -**Skills is hidden, not removed.** `src/pages/skills.js` and `src/pages/skills.module.css` -are untouched; the route is kept out of the build by the `pages.exclude` entry in -`docusaurus.config.js`, and the navbar item and the At scale colophon link are commented out -beside their originals. To restore the page: drop `'skills.js'` from that exclude list and -uncomment those two links. +One static asset is part of the site's contract rather than decoration: +`static/skill.sh`, served at `https://stackguardian.github.io/tirith/skill.sh`. It is the +one-line installer the Skills page, the editor documentation and `llms.txt` all point at, so +it is a published URL and moving or renaming it breaks three pages and every agent that has +read the brief. It downloads the files in `.claude/skills/tirith-policies/` and nothing else. `/origins/` is reachable only from the landing page's footer, by design — it is background for a reader who has finished the page, not a step towards installing anything, so it is deliberately @@ -141,6 +141,19 @@ The idea behind the mark — the city the name comes from, the four moves that r plan, and why opposed gates are the product — is the `/origins/` page. Its geometry comes from `src/data/logoStory.js`. +### The documentation, and the design + +`src/css/custom.css` declares the palette once and maps Infima's variables onto it; +`src/css/docs.css` handles the structure a variable cannot express. Together they put the +documentation in the same design as the landing pages, so the two halves of the site do not +read as two products. Three small swizzles support it: `DocBreadcrumbs` hosts the copy-page +control, `PaginatorNavLink` draws previous/next, and `Admonition` supplies the icons. + +Every documentation page carries a **copy-page menu** beside its breadcrumbs: copy the +markdown, view it, or open the page in ChatGPT or Claude. It serves the `.md` twin that +`scripts/generate-llms-full.py` already writes for every route, so adding a page means +re-running that script or the menu has nothing to hand over. + 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 diff --git a/documentation/docs/getting-started-with-tirith.md b/documentation/docs/getting-started-with-tirith.md index ea5c59c7..a36bc3fc 100644 --- a/documentation/docs/getting-started-with-tirith.md +++ b/documentation/docs/getting-started-with-tirith.md @@ -16,7 +16,7 @@ import TabItem from '@theme/TabItem'; Explore a failing evaluation down to the resource that caused it, assemble policies from a form, and experiment in a playground with worked examples. Install it with -`pip install 'py-tirith[tui] @ git+https://github.com/StackGuardian/tirith.git'` and run +`pip install 'py-tirith[tui] @ git+https://github.com/StackGuardian/tirith.git@1.2.0'` and run `tirith ui` — see [the interactive interface](tirith-usage/interactive-interface.md). diff --git a/documentation/docs/tirith-installation/quick-intallation.md b/documentation/docs/tirith-installation/quick-intallation.md index 25cec02d..b80f1be6 100644 --- a/documentation/docs/tirith-installation/quick-intallation.md +++ b/documentation/docs/tirith-installation/quick-intallation.md @@ -36,21 +36,40 @@ slug: quick-installation/ If you simply want to install and start using Tirith, this option provides a fast installation process with minimal setup. Perfect for end users and non-developers who only need basic functionality. ## Prerequisite -- Make sure your machine has [Python](https://www.python.org/downloads/) and [pip](https://pip.pypa.io/en/stable/installation/) installed. +- Make sure your machine has [Python](https://www.python.org/downloads/) 3.8 or newer and [pip](https://pip.pypa.io/en/stable/installation/) installed. - Install [Git](https://git-scm.com/downloads) on your machine. ## Steps to Install Tirith ### Step 1: Install using the `pip` command -Run the following command in your terminal to download Tirith directly from the GitHub repository and install it on your local system. This command ensures that you have the latest version. + +:::danger Not from PyPI +`pip install tirith` installs an **unrelated project of the same name**, and `pip install py-tirith` +finds nothing: that is the package name in `setup.py` and it is not published. Installing Tirith +means installing from git, as below. +::: + +Run the following command in your terminal to install Tirith directly from the GitHub repository, +pinned to a released tag: + +```bash +pip install "git+https://github.com/StackGuardian/tirith.git@1.2.0" +``` + +Pin the tag rather than tracking the default branch, so an install today and an install next month +give you the same tool. `1.2.0` is the newest tag; +`git ls-remote --tags https://github.com/StackGuardian/tirith.git` lists them all. + +To use [the interactive interface](../tirith-usage/interactive-interface.md) as well, install the +optional extra, which needs Python 3.9 or newer: ```bash -pip install git+https://github.com/StackGuardian/tirith.git +pip install "py-tirith[tui] @ git+https://github.com/StackGuardian/tirith.git@1.2.0" ``` - ### Step 2: Verify Installation -Once installed, verify that Tirith is working by checking its version. You should see a version number (e.g., 1.0.0-beta.12) indicating successful installation. +Once installed, verify that Tirith is working by checking its version. You should see `1.2.0`, +which confirms both that the install succeeded and that you got the tag you asked for. ```bash tirith --version ``` diff --git a/documentation/docs/tirith-providers/overview.md b/documentation/docs/tirith-providers/overview.md index ede6d7ca..67a674fb 100644 --- a/documentation/docs/tirith-providers/overview.md +++ b/documentation/docs/tirith-providers/overview.md @@ -88,3 +88,51 @@ When a provider cannot find what an operation asked for, it reports an error ins 2. **Errors without a severity value.** Some errors (an unsupported `operation_type` in the `json` and `kubernetes` providers, and all errors from the `infracost` and `sg_workflow` providers) carry no severity. These always **fail** the check, regardless of `error_tolerance`. Each provider page below lists exactly which situation produces which severity. See also [error tolerance](../tirith-policies/tirith-policy-error-tolerance.md). + +## Write one for what you actually run + +Five providers ship. That is not a claim about what is worth gating, it is a list of what has been written so far, and the interesting policies are usually about the system nobody wrote a provider for yet. + +A provider is small. It is one function: + +```python +def provide(provider_args: dict, input_data) -> list[dict]: + """Turn a document into values a condition can be run against.""" +``` + +It receives the `provider_args` from an evaluator and the parsed input document, and it returns a list of outputs: `{"value": ...}` for something a condition can judge, or `{"value": ProviderError(severity_value=1), "err": "..."}` for something it could not find. That is the entire contract. The thirteen conditions, `eval_expression`, `error_tolerance`, the result document, the exit codes and every CI integration already work on top of it. `kubernetes/handler.py` is about fifty lines, and it is a complete provider. + +:::note How a provider is registered +There is no plugin discovery and no entry point to hook: `PROVIDERS_DICT` in `src/tirith/providers/__init__.py` is a literal dictionary, so a new provider is a module plus one line in that dict. In practice that means a pull request, or a fork you install from your own git URL. Making providers loadable from outside the package is a real request and worth opening an issue for if you need it. +::: + +### What people ask for + +The pattern that makes a good provider is narrow: **a document that describes a proposed change, available before the change is applied.** If you can get that as JSON, you can gate it. + +| | | +|---|---| +| **Other IaC formats** | CloudFormation change sets, Pulumi previews, ARM and Bicep what-if output, Helm rendered templates and values | +| **Cloud and SaaS APIs** | AWS Config or Cloud Control, GCP asset inventory, Datadog monitors, PagerDuty schedules, an identity provider's roles | +| **Your own APIs** | A service catalogue, a CMDB, a deployment API, an internal platform's change request. This is the one nobody else can write for you, and it is usually where the rules that matter to your organisation live | +| **Supply chain** | An SBOM, a lockfile, a dependency manifest, image provenance and signatures | +| **Cost and capacity** | Beyond Infracost: quota headroom, commitment coverage, a chargeback model | +| **Compliance evidence** | Turning a control framework into checks that run on every change instead of once a quarter | + +### The one that does not exist yet + +Everything above is the same shape as what ships today: a plan, a manifest, an estimate. The shape holds somewhere less obvious. + +An AI agent with tools is a system that proposes changes and then applies them. Before it calls a tool, there is a document describing what it is about to do: which tool, which arguments, what it costs, what it can reach. That is a plan, in every sense that matters to a policy engine, and today almost nothing sits between an agent's intention and its action. + +**A provider for agent runtime decisions** would let the rules be written the same way the rest of your governance is: this agent may not call a tool that writes to production, may not spend beyond a threshold in one run, may not touch a resource outside its blast radius, may not act at all without a plan a human approved. The same thirteen conditions, the same expression grammar, the same verdict and exit code, evaluated before the call rather than in a review afterwards. + +This is **aspirational**. There is no such provider, it is not on the [roadmap](https://stackguardian.github.io/tirith/roadmap/) with a date, and it is written down here because it is the clearest example of the point: the engine does not care what the document is about. If you are building agent infrastructure and want a policy layer with a real evaluator behind it rather than a prompt asking a model to behave, this is worth a conversation. + +### Start one + +Open an issue describing the document you want to gate and what a rule over it would say. That is enough to work out whether it is a new provider, a new operation on an existing one, or something the `json` provider already does. + +- **[Propose a provider](https://github.com/StackGuardian/tirith/issues/new?template=feature_request.md&title=Provider%3A+)**: the system, the document, and one rule you would write +- **[Read an existing one](https://github.com/StackGuardian/tirith/tree/main/src/tirith/providers/kubernetes)**: the shortest complete example in the repository +- **[Good first issues](https://github.com/StackGuardian/tirith/labels/good%20first%20issue)**: if you would rather start somewhere smaller diff --git a/documentation/docs/tirith-usage/agent-skills.md b/documentation/docs/tirith-usage/agent-skills.md new file mode 100644 index 00000000..58fb9c0c --- /dev/null +++ b/documentation/docs/tirith-usage/agent-skills.md @@ -0,0 +1,165 @@ +--- +id: agent-skills +title: Agent Skills +sidebar_label: Agent Skills +description: Install the Tirith skill pack so a coding agent writes policies from the real vocabulary instead of inventing condition types that look plausible. +keywords: + - tirith + - agent + - skill + - claude + - cursor + - agents.md + - copilot +site_name: Tirith +slug: agent-skills/ +--- + +An agent asked for a Tirith policy will produce one. The JSON will be well formed, the keys will +look right, and it will very often be wrong in a way that reads as correct: a condition type named +`Matches` or `Exists`, neither of which exists, or the argument key from a different provider. + +That failure is quiet. The policy parses, the evaluator does not match, and the check reports a +pass. **A rule that gates nothing looks exactly like a rule that found nothing wrong.** + +The skill pack fixes the cause: it gives the agent the closed vocabulary instead of leaving it to +guess from a plausible-looking shape. + +## Install it + +```bash +curl -fsSL https://stackguardian.github.io/tirith/skill.sh | sh +``` + +Three skills under `.claude/skills/`: `tirith-policies`, for writing policies, +`tirith-standards`, for generating a policy set from a repository you already have, and +`tirith-migrate`, for translating existing Sentinel or Azure policies. No config file, and they are picked +up in any repository you copy them into. A session that is already running may not see a newly installed +skill until it is restarted; a new session sees it immediately. + +| Flag | | +|---|---| +| `--cursor` | Also install `.cursor/rules/tirith-policies.mdc`, scoped with globs | +| `--global` | Install into `~/.claude/skills/` instead of this repository | +| `--ref REF` | Install from a branch or tag instead of `main` | +| `--help` | The same summary, from the script itself | + +The script downloads one archive of the repository, copies the skills out of it, and does nothing +else: no package is installed, no `PATH` is changed, nothing is executed after the download, and +it never touches a file it did not create. It extracts to a temporary directory and copies each +skill into place only once the whole archive has arrived, because a half-written skill is worse +than none: an agent reads whatever files exist and works from a partial vocabulary without saying +so. It needs `curl` and `tar`. + +It is [a committed file in this repository](https://github.com/StackGuardian/tirith/blob/main/documentation/static/skill.sh) +served from the same origin as this page, so the thing you pipe into a shell is the thing you can +read first. + +### Cursor + +```bash +curl -fsSL https://stackguardian.github.io/tirith/skill.sh | sh -s -- --cursor +``` + +Cursor reads a single rule file scoped with globs, so it attaches by itself the moment a policy +file is open and stays out of the way otherwise. + +### Codex, Zed, and anything reading `AGENTS.md` + +```bash +curl -fsSL https://stackguardian.github.io/tirith/skill.sh | sh +printf '\n## Tirith policies\nSee .claude/skills/tirith-policies/SKILL.md\n' >> AGENTS.md +``` + +One file at the repository root is read by a growing number of clients, and the pack beside it +keeps the references resolvable. + +## Check it worked + +Ask for a policy in plain words: *every bucket needs an Owner tag*. With the pack loaded your agent +names a real condition type and the argument key that provider actually takes. Without it, it +invents one that reads perfectly and gates nothing. + +## What is in the pack + +`SKILL.md` is the entry point and is loaded first; the references are read on demand, so a client +with a small context window pays for only what the task needs. + +| File | | +|---|---| +| `SKILL.md` | Turning an intent into valid policy JSON: provider, operation, condition, expression | +| `reference/schema.md` | The closed vocabulary. Thirteen condition types, each provider's operations, and the argument key that differs per provider | +| `reference/validate.md` | The mistakes that produce a policy which looks right and gates nothing | +| `reference/verdicts.md` | Reading a result document and an exit code | +| `reference/terraform-plan.md` | The Terraform and OpenTofu plan provider | +| `reference/other-providers.md` | Kubernetes, Infracost, JSON and StackGuardian Workflow | +| `reference/variables.md` | One policy across environments | +| `reference/install.md` | Installing Tirith, and why the install is a git URL | +| `reference/pipelines.md` | Adding the gate to six CI platforms | +| `reference/platform.md` | Evaluating an organization's policies | +| `reference/debug-ci.md` | Diagnosing a red check | +| `examples/required-tags/` | A policy, a plan that fails it and a plan that passes it, so the agent can prove its own work before it hands it back | + +## Standards from a repository you already have + +`tirith-standards` starts from the Terraform or OpenTofu you already run rather than from a +catalogue. It reads the code to work out which resource types are in use and which conventions +are already followed, proposes a set of standards with the count of resources that would pass and +fail today, and writes one policy per rule: required tags, naming conventions, allowed regions, +permitted resource types, size ceilings, encryption defaults. + +Two things in it are worth knowing even if you never install it, because they decide whether a +generated policy is usable: + +- **The policies never see your HCL.** The skill reads `.tf` files only to decide what to write + about; the rules themselves evaluate `terraform show -json`, where variables are resolved, + modules are expanded into addressed resources, and a name that is not known until apply is + `null`. A rule about a variable or a module block reads nothing. +- **A rule scoped to one resource type needs a guard.** With the default `error_tolerance` an + absent resource type is severity `1` and the check fails, so the rule is red on every plan that + does not touch that type. At `error_tolerance: 1` it is skipped instead, every check is skipped, + and `final_result` is `null`, which exits `1`. Neither is green. The skill's fix is a `count` + evaluator, which returns `0` for an absent type rather than erroring, combined with `||`. + +## Migrating from Sentinel or Azure Policy + +The third skill, `tirith-migrate`, is for teams with existing HashiCorp Sentinel or Azure Policy +definitions. It is a +projection from a larger language onto a smaller one, and the skill's job is to say what survives. +Measured against the 110 policies in HashiCorp's public libraries, 41 translate exactly, 40 +approximately, and 29 not at all. Each translation is tagged with that fidelity, every approximate +one ships a plan on which Sentinel and Tirith disagree, and every impossible one is refused in +words with the Tirith issue that would change it. For Azure Policy the reference carries an +alias-to-azurerm crosswalk, the corpus's classification of 713 built-ins, and eight verified +translations of the policies a subscription is usually assigned first. Checkov and OPA/Rego are +planned next. + +## Two things decide whether the policy actually works + +The pack teaches vocabulary. It does not run anything, and it is not a substitute for evaluating +the policy: + +1. **Give the agent `tirith` on `PATH`.** It is an ordinary command, so an agent with a shell can + evaluate its own work without a protocol server or a plugin. See + [Quick Installation](../tirith-installation/quick-intallation.md). +2. **Give it a document that should fail.** Ask for the policy *and* a plan that violates it, then + check the exit code is `3`. If it is `0`, the policy matched nothing, which is the failure this + whole page exists to prevent. The pack ships a starting pair in `examples/required-tags/`. + See [Exit codes](exit-codes.md). + +## Keeping it current + +The pack is a copy, so it does not update itself. Re-run the installer to take the current +version: + +```bash +curl -fsSL https://stackguardian.github.io/tirith/skill.sh | sh +``` + +Re-running is safe: it replaces each skill directory it owns wholesale, so a file removed upstream +does not linger, and it leaves everything else alone. + +`--ref` takes a branch or a commit, which is worth knowing for a fork or a pull request. It cannot +yet take a release tag: the pack was added after `1.2.0`, so `main` is the only ref that has it, +and asking for a tag that predates it fails with exit `1` rather than installing something +incomplete. diff --git a/documentation/docs/tirith-usage/ci-integration.md b/documentation/docs/tirith-usage/ci-integration.md index eee22147..b29c7afc 100644 --- a/documentation/docs/tirith-usage/ci-integration.md +++ b/documentation/docs/tirith-usage/ci-integration.md @@ -43,17 +43,26 @@ permissions: checks: write # check run steps: - - run: | - terraform plan -out=tfplan -input=false - terraform show -json tfplan > plan.json + - run: terraform plan -out=tfplan -input=false - uses: StackGuardian/tirith-iac-governance-action@v2 + with: + plan-file: tfplan + fail-on-error: true ``` -With a `plan.json` in the working directory that is the whole integration — no `with:` block. The -action finds the document by convention (`plan.json` or `tfplan.json`) and evaluates the policy -files committed under `.tirith/policies`, on the runner, talking to nothing. Add -`with: { fail-on-error: true }` to make a failing policy fail the job. +The two write permissions are the only setup the action cannot do for itself, and are the thing +most often missing on a first install. `-input=false` matters in CI: without it a missing variable +waits for a prompt that never comes, and the job hangs instead of failing. + +Handing the action the **binary plan** rather than exporting JSON first is one step shorter and +strictly safer: the action renders it with `terraform show -json` in memory, so no unmasked plan +JSON is written to the workspace where a later step, a cache or an artifact upload could pick it +up. + +If your pipeline already writes `plan.json`, drop `plan-file` and the action finds the document by +convention (`plan.json` or `tfplan.json`). Either way it evaluates the policy files committed under +`.tirith/policies`, on the runner, talking to nothing. ### Local mode and platform mode @@ -182,13 +191,18 @@ pipelines: - step: name: Policy gate script: - - pip install "git+https://github.com/StackGuardian/tirith.git@1.2.0" - - tirith lint .tirith/policies # needs a build from main until the next release + # @main rather than @1.2.0, because the lint step below is not in 1.2.0 yet. + - pip install "git+https://github.com/StackGuardian/tirith.git@main" + - tirith lint .tirith/policies - tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error ``` -A complete file is in [`examples/ci/bitbucket-pipelines.yml`](https://github.com/StackGuardian/tirith/blob/main/examples/ci/bitbucket-pipelines.yml), -and a worked repository is at +Linting first is cheap and it fails for a different reason than the gate does: a policy that is +malformed never gets as far as disagreeing with your infrastructure. It needs no plan document, +so it can also run in a job that has no cloud credentials at all. See +[lint and format](lint-and-fmt.md). + +A worked repository is at [tirith-bitbucket-demo](https://bitbucket.org/__refeed__/tirith-bitbucket-demo). ## Jenkins @@ -215,18 +229,23 @@ stage('Policy gate') { } ``` -The full pipeline, including install, lint and artifact archiving, is in -[`examples/ci/Jenkinsfile`](https://github.com/StackGuardian/tirith/blob/main/examples/ci/Jenkinsfile). +`returnStatus: true` is what makes this work: without it the shell step throws on any non-zero +exit and the two cases become one. ## As a pre-commit hook +:::note Not in 1.2.0 +`tirith lint` and `tirith fmt` are on `main` and arrive in the next release, so pin `rev` to a +branch until then. Everything else on this page works on 1.2.0. +::: + Catch a broken policy before it is committed, let alone before CI runs it. Tirith publishes a -`tirith-lint` hook: +`tirith-lint` and a `tirith-fmt` hook: ```yaml title=".pre-commit-config.yaml" repos: - repo: https://github.com/StackGuardian/tirith - rev: main # 1.2.0 predates the hook; pin the first tag that includes it + rev: main hooks: - id: tirith-lint - id: tirith-fmt @@ -234,12 +253,12 @@ repos: ```bash pre-commit install -pre-commit run tirith-lint --all-files +pre-commit run --all-files ``` -The hooks run only when a file under `.tirith/` or a `*.tirith.json` changes, and lint exactly the -files that changed. `tirith-fmt` rewrites them into the canonical layout; commit again after it -does. +Both hooks run only when a file under `.tirith/` or a `*.tirith.json` changes, and both are +handed the individual changed files rather than the whole directory: `tirith lint` and +`tirith fmt` each take any number of paths, so a commit touching one policy checks one policy. :::note Why linting and not evaluation Evaluating a policy needs a plan document, and producing one means running `terraform plan` — diff --git a/documentation/docs/tirith-usage/cli-reference.md b/documentation/docs/tirith-usage/cli-reference.md index b8a726c5..b5f6d622 100644 --- a/documentation/docs/tirith-usage/cli-reference.md +++ b/documentation/docs/tirith-usage/cli-reference.md @@ -19,9 +19,23 @@ tirith -policy-path policy.json -input-path plan.json Run with no arguments, `tirith` prints its help text and exits `0`. -There is one subcommand, `tirith platform check`, which evaluates against the policies a -StackGuardian organization enforces instead of local files. It has its own flags and its own page: -[Platform Check](platform-check.md). +Four subcommands sit beside it, each with its own flags and its own page: + +| Subcommand | What it does | +|---|---| +| [`tirith lint`](lint-and-fmt.md) | Check policy files for mistakes that would gate nothing or fail as a false violation. No plan document needed | +| [`tirith fmt`](lint-and-fmt.md) | Rewrite policy files into the canonical layout | +| [`tirith ui`](interactive-interface.md) | Explore results, build policies and experiment in an interactive interface. Needs the optional `tui` extra | +| [`tirith platform check`](platform-check.md) | Evaluate against the policies a StackGuardian organization enforces instead of local files | + +A first argument that is not one of those four and does not begin with a dash is rejected by name, +because the flat command below takes no positional arguments: + +```console +$ tirith lnit .tirith/policies +tirith: 'lnit' is not a tirith command. Commands: fmt, lint, platform, ui. +Run 'tirith --help' for the local-evaluation options. +``` ## Flags diff --git a/documentation/docs/tirith-usage/editor-and-local.md b/documentation/docs/tirith-usage/editor-and-local.md index b7a7af4c..8d48c239 100644 --- a/documentation/docs/tirith-usage/editor-and-local.md +++ b/documentation/docs/tirith-usage/editor-and-local.md @@ -14,15 +14,14 @@ slug: editor-and-local/ --- :::note Not in 1.2.0 - -`tirith lint`, `tirith fmt` and the pre-commit hooks are on `main` and will be in the next -release. `pip install "git+https://github.com/StackGuardian/tirith.git@1.2.0"` does not have them; -install from `main` until then. - +`tirith lint` and `tirith fmt` are on `main` and will arrive in the next release. Until then, +install from the branch rather than the tag: +`pip install "git+https://github.com/StackGuardian/tirith.git@main"`. Evaluation, the second half +of the loop, works on any installed version. ::: -CI is the last place a policy should fail. This page is about the loop before that — running -Tirith on your own machine, while the code is still being written. +CI is the last place a policy should fail. This page is about the loop before that, running +Tirith on your own machine while the code is still being written. It matters most when an agent is drafting the policy for you. Generated policy JSON is plausible by construction: it parses, it looks right, and a policy that matches nothing is indistinguishable @@ -31,13 +30,15 @@ from one that works until you evaluate it. ## The loop ```bash +tirith fmt .tirith/policies # layout tirith lint .tirith/policies # shape tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error # meaning ``` -Linting checks the **shape** — that every condition type exists, that no `provider_args` key -belongs to a different provider, that `eval_expression` names every evaluator. Only evaluation -checks the **meaning**. +Linting checks the **shape**: that every condition type exists, that no `provider_args` key +belongs to a different provider, that `eval_expression` names every evaluator, that +`error_tolerance` is inside `condition` where the engine reads it. Only evaluation checks the +**meaning**. [Lint and format](lint-and-fmt.md) covers both commands in full. Run both against a document that *should* fail. A guardrail only ever seen passing is a guardrail nobody has tested. @@ -50,6 +51,13 @@ Drop this in `.vscode/tasks.json` and the loop becomes one keystroke: { "version": "2.0.0", "tasks": [ + { + "label": "Tirith: format policies", + "type": "shell", + "command": "tirith fmt .tirith/policies", + "problemMatcher": [], + "group": "test" + }, { "label": "Tirith: lint policies", "type": "shell", @@ -70,16 +78,17 @@ Drop this in `.vscode/tasks.json` and the loop becomes one keystroke: ``` `Tirith: check the plan` is the default test task, so **⇧⌘B** / **Ctrl+Shift+B** runs the whole -loop. The complete file — including a task to refresh `plan.json` and one to open the result in -the interactive explorer — is -[`.vscode/tasks.json`](https://github.com/StackGuardian/tirith/blob/main/.vscode/tasks.json). +loop: lint first, then evaluate, because `dependsOn` will not run the second if the first exits +non-zero. ## Catch it at commit time +Tirith publishes both commands as pre-commit hooks: + ```yaml title=".pre-commit-config.yaml" repos: - repo: https://github.com/StackGuardian/tirith - rev: main # 1.2.0 predates the hooks; pin the first tag that includes them + rev: main hooks: - id: tirith-lint - id: tirith-fmt @@ -89,8 +98,9 @@ repos: pre-commit install ``` -The hook runs only when a policy file changes, needs no network, and exits `3` when a policy has -an error. See [CI integration](ci-integration.md) for why it lints rather than evaluates. +Both run only on the policy files a commit actually touches, need no network, and exit `3` on a +finding. See [CI integration](ci-integration.md) for why they lint rather than evaluate, and +[lint and format](lint-and-fmt.md) for the flags and the files they read. ## When an agent is writing the policy @@ -98,13 +108,11 @@ Install the Tirith skill and your agent gets the closed condition list, the argu provider reads, and the instruction to run a policy before claiming it works: ```bash -mkdir -p .claude/skills/tirith-policies/reference -BASE=https://raw.githubusercontent.com/StackGuardian/tirith/main/.claude/skills/tirith-policies -curl -sL $BASE/SKILL.md -o .claude/skills/tirith-policies/SKILL.md +curl -fsSL https://stackguardian.github.io/tirith/skill.sh | sh ``` -Cursor reads `.cursor/rules/tirith-policies.mdc` instead, scoped with globs so it attaches by -itself when a policy file is open. +Add `--cursor` for the Cursor rule. [Agent Skills](agent-skills.md) covers what is in the pack, +the other clients, and how to tell whether it took effect. Two things make the difference between a drafted policy and a working one: diff --git a/documentation/docs/tirith-usage/exit-codes.md b/documentation/docs/tirith-usage/exit-codes.md index cac5ad99..c42ff18e 100644 --- a/documentation/docs/tirith-usage/exit-codes.md +++ b/documentation/docs/tirith-usage/exit-codes.md @@ -11,18 +11,21 @@ site_name: Tirith slug: exit-codes/ --- -Tirith's exit codes are a contract shared by both surfaces — local evaluation (`tirith`) and -platform evaluation (`tirith platform check`) — so a caller scripting both only has to learn one -vocabulary. +Tirith's exit codes are one contract shared by every surface: local evaluation (`tirith`), +platform evaluation (`tirith platform check`), and the two file commands, `tirith lint` and +`tirith fmt`. A caller scripting more than one only has to learn a single vocabulary. | Code | Meaning | |---|---| -| `0` | Policies passed, or nothing was in scope to gate on | -| `1` | Tirith could not complete the evaluation — bad input, a policy it could not evaluate, unreachable API | -| `2` | Timed out waiting for a StackGuardian run (`tirith platform check` only; local evaluation never produces it) | -| `3` | A policy failed. Only with `--fail-on-error`, on either surface | +| `0` | Policies passed, nothing was in scope to gate on, or the files were clean | +| `1` | Tirith could not complete the run: bad input, a policy it could not evaluate, an unreachable API, a path that does not exist | +| `3` | A policy said no. On the evaluation surfaces only with `--fail-on-error`; `lint` and `fmt` need no flag | | `130` | Interrupted (Ctrl-C) | +`2` is deliberately absent. It used to be a timeout code that nothing ever returned, and +`argparse` already exits `2` of its own accord on a usage error, so a caller seeing `2` has +passed a bad argument. Do not write a pipeline that branches on it. + ## `3` is deliberately not `1` `3` means a check ran and said no: your infrastructure violates a policy. `1` means Tirith could @@ -36,6 +39,21 @@ Both surfaces **fail closed**: anything that leaves the verdict unknown exits no of `--fail-on-error`. That flag governs policy verdicts, not tool health — a run that produced no verdict must never look like a pass. +## `tirith lint` and `tirith fmt` + +Both use the same three codes, and neither takes `--fail-on-error`: a linter that cannot fail is +not a linter, and there is no existing green pipeline to protect because nothing has run them +before. + +| Command | `0` | `3` | `1` | +|---|---|---|---| +| `tirith lint` | every policy is clean | a policy has an error-level finding, or a warning under `--strict` | a path is missing, or nothing was found to lint | +| `tirith fmt` | nothing to change, or the changes were written | `--check` found files that would change | a path is missing, a file is not valid JSON, or nothing was found | + +"Nothing was found" being `1` rather than `0` is the same fail-closed rule the evaluation +surfaces follow. A hook pointed at the wrong directory reports a problem instead of quietly +passing every commit. See [lint and format](lint-and-fmt.md). + ## Without `--fail-on-error` The local command exits `0` whether the policy passed or failed, with the verdict in the output. diff --git a/documentation/docs/tirith-usage/interactive-interface.md b/documentation/docs/tirith-usage/interactive-interface.md index af0cd04b..b9ebb639 100644 --- a/documentation/docs/tirith-usage/interactive-interface.md +++ b/documentation/docs/tirith-usage/interactive-interface.md @@ -32,7 +32,7 @@ pipeline should pay to install an interface they never open. It needs Python 3.9 Tirith itself still supports 3.8. ```bash -pip install 'py-tirith[tui] @ git+https://github.com/StackGuardian/tirith.git' +pip install 'py-tirith[tui] @ git+https://github.com/StackGuardian/tirith.git@1.2.0' ``` Tirith is not on PyPI — `pip install py-tirith` finds nothing and `pip install tirith` installs an diff --git a/documentation/docs/tirith-usage/lint-and-fmt.md b/documentation/docs/tirith-usage/lint-and-fmt.md new file mode 100644 index 00000000..7f02afef --- /dev/null +++ b/documentation/docs/tirith-usage/lint-and-fmt.md @@ -0,0 +1,216 @@ +--- +id: lint-and-fmt +title: Lint and format +sidebar_label: Lint and format +description: tirith lint checks policy files for mistakes that would gate nothing or fail as a false violation. tirith fmt rewrites them into one canonical layout. Neither needs a plan document. +keywords: + - tirith + - lint + - fmt + - format + - pre-commit +site_name: Tirith +slug: lint-and-fmt/ +--- + +Two commands that read policy files and nothing else. Neither needs a plan document, an account, +or a network call, which is what lets them run in a pre-commit hook and in a slim CI image without +the optional `tui` extra. + +```bash +tirith lint .tirith/policies # is the policy well formed? +tirith fmt .tirith/policies # rewrite it into the canonical layout +``` + +:::note Not in 1.2.0 +Both commands are on `main` and will arrive in the next release. To use them today, install from +the branch rather than the `1.2.0` tag: + +```bash +pip install "git+https://github.com/StackGuardian/tirith.git@main" +``` +::: + +## `tirith lint` + +Lint checks the **shape**, not the meaning. It runs the same validator the +[interactive interface](interactive-interface.md) runs on every keystroke, so the two never +disagree, and it catches the class of mistake that otherwise reaches CI looking like a real +infrastructure violation: + +- a condition `type` that does not exist, against the closed list of + [thirteen conditions](../tirith-reference/evaluators.md) +- a `provider_args` key that belongs to a different provider than the one in + `meta.required_provider` +- an evaluator that `eval_expression` never names, so it is computed and then discarded +- `error_tolerance` placed on the evaluator instead of inside `condition`, where it is silently + ignored and the check still fails + +What lint cannot tell you is whether a well-formed policy matches anything. Only evaluating it +against a document that *should* fail answers that: + +```bash +tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error +``` + +### Usage + +``` +tirith lint [PATH ...] [--json] [--strict] [--quiet] +``` + +| Flag | What it does | +|---|---| +| `--json` | Print the report as a JSON document instead of text | +| `--strict` | Treat warnings as errors when deciding the exit code | +| `--quiet` | Print findings only, with no summary line and no skipped files | + +Findings are printed one per line, in a form an editor or a log scraper can parse: + +``` +.tirith/policies/s3.json:evaluators[0].condition: error: unknown condition type 'EqualTo' +.tirith/policies/s3.json:eval_expression: warning: evaluator 'bucket_acl' is never referenced +2 policies, 1 errors, 1 warnings +``` + +`--json` returns the same report structured, so an editor integration or another program does not +have to parse the text: + +```json +{ + "files": [ + { + "path": ".tirith/policies/s3.json", + "findings": [ + {"severity": "error", "where": "evaluators[0].condition", "message": "unknown condition type 'EqualTo'"} + ] + } + ], + "skipped": [], + "missing": [], + "summary": {"policies": 2, "errors": 1, "warnings": 1, "ignored": 0}, + "exit_status": 3 +} +``` + +### Exit codes + +| Code | Meaning | +|---|---| +| `0` | Every policy is clean | +| `3` | A policy has an error-level finding, or a warning under `--strict` | +| `1` | A path does not exist, or nothing was found to lint | + +`3` rather than `1` for a bad policy is deliberate, and matches the rest of Tirith: the linter +saying no about a policy is a verdict, not a tool failure. See [exit codes](exit-codes.md). + +## `tirith fmt` + +Format rewrites a policy into one canonical layout, so that two people writing the same policy +produce the same bytes and a diff shows a change of meaning rather than a change of key order. + +It reorders keys and normalises whitespace. It never changes a value, adds a key, or reorders a +list, and the result parses back to a document equal to the one it read. + +### Usage + +``` +tirith fmt [PATH ...] [--check] [--diff] +``` + +| Flag | What it does | +|---|---| +| `--check` | Do not write. Exit `3` if any file would change | +| `--diff` | Print a unified diff of what would change. Implies `--check` | + +With no flags it rewrites the files in place and prints the path of each one it changed. + +### The layout + +Whitespace is exactly `json.dumps(indent=2, ensure_ascii=False)` plus one trailing newline, which +means a policy written by any tool using the Python standard library is already canonical without +knowing `fmt` exists. Non-ASCII characters stay as written rather than being escaped. + +Keys are ordered: + +| Object | Order | +|---|---| +| top level | `$schema`, `meta`, `evaluators`, `eval_expression` | +| `meta` | `version`, `required_provider`, `id`, `name`, `description`, `severity`, `enforcement`, `tags`, `remediation` | +| each evaluator | `id`, `description`, `provider_args`, `condition` | +| `provider_args` | `operation_type` first, then the provider's own keys in their original order | +| `condition` | `type`, `value`, `error_tolerance` | + +A key the document has that is not in these lists keeps its original relative order after the +listed ones, so an unrecognised key is preserved rather than dropped or sorted somewhere +surprising. + +### Exit codes + +| Code | Meaning | +|---|---| +| `0` | Nothing to change, or the changes were written | +| `3` | `--check` found files that would change | +| `1` | A path is missing, a file is not valid JSON, or nothing was found | + +## Which files they read + +Both commands take any number of paths, and both default to the same place when given none: +`.tirith/policies` if that directory exists, otherwise the current directory. + +A directory is searched recursively for `*.json`. A **policy** is a JSON object with at least one +of the three top-level keys the engine reads, `meta`, `evaluators` or `eval_expression`; anything +else that parses as JSON is left alone. That is what lets you point either command at a directory +that also holds a `package.json` or a plan document without producing findings about files that +were never policies. + +- A JSON file named **explicitly on the command line** that is not a policy is reported as skipped + by name, so a typo'd path to a plan document does not read as a clean run. +- A JSON file met **while walking a directory** that is not a policy is counted rather than + listed. Input documents living beside their policies are expected there. +- A `.json` file inside a policy directory that does not parse at all is reported as an error, not + ignored. An unparseable policy is a defect. + +These directories are never descended into: `.git`, `.terraform`, `node_modules`, `__pycache__`, +`.venv`, `venv`. Note that `.tirith` is deliberately not among them, since that is where policies +conventionally live and skipping dot-directories wholesale would skip the one that matters. + +## In a pre-commit hook + +This repository publishes both commands as hooks, so a commit that touches one policy lints and +formats that one policy: + +```yaml title=".pre-commit-config.yaml" +repos: + - repo: https://github.com/StackGuardian/tirith + rev: main + hooks: + - id: tirith-lint + - id: tirith-fmt +``` + +```bash +pre-commit install +pre-commit run --all-files +``` + +Both hooks match `.tirith/*.json` and `*.tirith.json` by default. Override `files` if your +policies live somewhere else. + +Linting at commit time rather than evaluating is a deliberate choice. Evaluating needs a plan +document, and producing one means running `terraform plan`, which is too slow for a commit hook +and needs cloud credentials a hook has no business holding. See +[CI integration](ci-integration.md) for the full argument, and for where evaluation belongs. + +## In CI + +`fmt --check` is the CI shape, next to whatever else already checks formatting: + +```bash +tirith fmt --check .tirith/policies +tirith lint .tirith/policies +``` + +Both exit `3` on a finding, so a pipeline that already distinguishes `3` from `1` treats a badly +formatted or invalid policy exactly as it treats a policy violation, and a missing path as the +tooling problem it is. diff --git a/documentation/docusaurus.config.js b/documentation/docusaurus.config.js index 9bab4001..812d1e01 100644 --- a/documentation/docusaurus.config.js +++ b/documentation/docusaurus.config.js @@ -1,4 +1,29 @@ -import {themes as prismThemes} from 'prism-react-renderer'; +/* + * Syntax highlighting, in the site's two hues. + * + * github and dracula were the stock pair and drew six colours across a JSON policy: teal + * keys, pink strings, red numbers. This design has an accent and an alarm, and on these + * pages the only distinction that carries meaning is key from value. + * + * ONE THEME FOR BOTH SCHEMES, because every colour here is a custom property. That is not + * a shortcut: prism-react-renderer writes its colours as inline `style` attributes on the + * token spans, and an inline style cannot be overridden from a stylesheet. Naming variables + * is the only way the highlighting can answer to the light/dark switch at all. The values + * live in src/css/custom.css beside the rest of the palette. + */ +const codeTheme = { + plain: {color: 'var(--tp-ink)', backgroundColor: 'transparent'}, + styles: [ + {types: ['comment', 'prolog', 'cdata'], style: {color: 'var(--tp-code-muted)', fontStyle: 'italic'}}, + {types: ['punctuation', 'operator', 'entity'], style: {color: 'var(--tp-code-muted)'}}, + {types: ['property', 'tag', 'attr-name', 'selector', 'symbol'], style: {color: 'var(--tp-code-key)', fontWeight: '600'}}, + {types: ['string', 'char', 'attr-value', 'url', 'inserted'], style: {color: 'var(--tp-code-string)'}}, + {types: ['number', 'boolean', 'constant'], style: {color: 'var(--tp-code-num)'}}, + {types: ['keyword', 'atrule', 'builtin', 'class-name', 'function'], style: {color: 'var(--tp-code-key)', fontWeight: '700'}}, + {types: ['variable', 'regex', 'important'], style: {color: 'var(--tp-code-string)'}}, + {types: ['deleted'], style: {color: 'var(--tp-alarm)'}}, + ], +}; /* * PostHog is configured entirely from the environment and nothing is committed. An unset key @@ -164,18 +189,11 @@ const config = { sidebarPath: './sidebars.js', }, /* - * Skills is hidden for now, not deleted. src/pages/skills.js and its stylesheet - * are untouched; this line is the only thing keeping the route out of the build, - * so restoring the page is deleting the 'skills.js' entry below and putting the - * navbar item back. - * - * Excluding here rather than renaming the file to _skills.js -- the other way to - * hide a page -- keeps the filename matching the route it will return to, and puts - * the decision somewhere a reader of the config can see it. - * - * GlobExcludeDefault is repeated because supplying `exclude` replaces the plugin's - * defaults rather than adding to them, and dropping them would start building - * _partials and test files as pages. + * GlobExcludeDefault, repeated verbatim. Supplying `exclude` replaces the plugin's + * defaults rather than adding to them, so omitting these would start building + * _partials and test files as pages. Nothing project-specific is excluded today; + * this block exists so that adding an exclusion later does not silently drop the + * defaults with it. */ pages: { exclude: [ @@ -183,12 +201,14 @@ const config = { '**/_*/**', '**/*.test.{js,jsx,ts,tsx}', '**/__tests__/**', - 'skills.js', ], }, blog: false, theme: { - customCss: './src/css/custom.css', + // Order matters: custom.css declares the tokens and maps Infima's variables onto + // them, docs.css spends them. Reversed, the docs skin would resolve against + // undefined custom properties on first paint. + customCss: ['./src/css/custom.css', './src/css/docs.css'], }, }), ], @@ -229,13 +249,11 @@ const config = { label: 'Learn', position: 'left', }, - // Hidden with the page itself -- see the `pages.exclude` note above. Kept - // here so restoring the route is uncommenting rather than rewriting. - // { - // to: '/skills/', - // label: 'Skills', - // position: 'left', - // }, + { + to: '/skills/', + label: 'Skills', + position: 'left', + }, { type: 'docSidebar', sidebarId: 'TirithSidebar', @@ -255,8 +273,8 @@ const config = { ], }, prism: { - theme: prismThemes.github, - darkTheme: prismThemes.dracula, + theme: codeTheme, + darkTheme: codeTheme, }, }), }; diff --git a/documentation/scripts/generate-llms-full.py b/documentation/scripts/generate-llms-full.py index 55ca464a..7272633a 100644 --- a/documentation/scripts/generate-llms-full.py +++ b/documentation/scripts/generate-llms-full.py @@ -57,10 +57,12 @@ "tirith-reference/evaluators.md", "tirith-reference/eval-expressions.md", "tirith-usage/cli-reference.md", + "tirith-usage/lint-and-fmt.md", "tirith-usage/exit-codes.md", "tirith-usage/ci-integration.md", "tirith-usage/interactive-interface.md", "tirith-usage/editor-and-local.md", + "tirith-usage/agent-skills.md", "tirith-usage/platform-check.md", ] diff --git a/documentation/sidebars.js b/documentation/sidebars.js index 69f42e86..99242827 100644 --- a/documentation/sidebars.js +++ b/documentation/sidebars.js @@ -21,8 +21,10 @@ module.exports = { label: "Using Tirith", items: [ "tirith-usage/cli-reference", + "tirith-usage/lint-and-fmt", "tirith-usage/interactive-interface", "tirith-usage/editor-and-local", + "tirith-usage/agent-skills", "tirith-usage/exit-codes", "tirith-usage/ci-integration", "tirith-usage/platform-check", diff --git a/documentation/src/components/docs/CopyPageMenu.js b/documentation/src/components/docs/CopyPageMenu.js new file mode 100644 index 00000000..2344537e --- /dev/null +++ b/documentation/src/components/docs/CopyPageMenu.js @@ -0,0 +1,234 @@ +import React, {useCallback, useEffect, useRef, useState} from 'react'; +import {useLocation} from '@docusaurus/router'; +import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; +import {BRAND} from './brandMarks'; +import styles from './CopyPageMenu.module.css'; + +/** + * Hand this page to an agent. + * + * Every documentation route already has a markdown twin beside it: /docs/x/y/ has + * /docs/x/y.md, written by documentation/scripts/generate-llms-full.py. That file existed + * for crawlers and for llms.txt, and nothing on the page pointed a human at it. This is that + * pointer, and it is the reason this control can be four items rather than a copy button: + * the source it copies, opens and hands to a model is one already-generated file, not the + * rendered DOM scraped back into markdown. + * + * Deriving the URL rather than threading it through: the generator's convention is the route + * plus `.md`, stated in its own comment, and re-deriving it here keeps this component from + * needing a manifest that could drift out of date. If the generator has not been re-run + * after a page was added, the fetch 404s and the button says so instead of copying an HTML + * error page, which is the failure worth handling. + */ + +const PROMPT = (url) => + `Read ${url} so I can ask you questions about it. Reply with a one-line summary when you have.`; + +/* + * Two kinds of mark, drawn differently on purpose. + * + * The interface glyphs below are stroked at 1.6, which is what keeps them in the same weight + * as the hairlines everything else on this site is built from. The vendor marks in + * brandMarks.js are solid single paths: stroking one would thicken it into a blot and would + * also be a modification of someone's trademark. So `solid` swaps stroke for fill and leaves + * the path exactly as published. + */ +function Icon({d, className, solid}) { + return ( + + ); +} + +const ICON = { + copy: ( + <> + + + + ), + file: ( + <> + + + + ), + caret: , +}; + +export default function CopyPageMenu() { + const {pathname} = useLocation(); + const {siteConfig} = useDocusaurusContext(); + const [open, setOpen] = useState(false); + const [state, setState] = useState('idle'); + const root = useRef(null); + const timer = useRef(null); + + useEffect(() => () => clearTimeout(timer.current), []); + + const mdPath = `${pathname.replace(/\/$/, '')}.md`; + const mdUrl = `${siteConfig.url}${mdPath}`; + + /* + * Close on an outside click or Escape, and return focus to the trigger on Escape only. + * A pointer dismissal has already moved the user's attention somewhere deliberate; yanking + * focus back would fight them. A keyboard dismissal has nowhere to land otherwise. + */ + useEffect(() => { + if (!open) return undefined; + const onDown = (e) => { + if (root.current && !root.current.contains(e.target)) setOpen(false); + }; + const onKey = (e) => { + if (e.key !== 'Escape') return; + setOpen(false); + root.current?.querySelector('[data-caret]')?.focus(); + }; + document.addEventListener('mousedown', onDown); + document.addEventListener('keydown', onKey); + return () => { + document.removeEventListener('mousedown', onDown); + document.removeEventListener('keydown', onKey); + }; + }, [open]); + + /* + * The async Clipboard API, then execCommand, then give up and say so. + * + * The fallback is not hypothetical: the API is denied outright in some embedded and + * automated browsers even on a secure origin, and this is a page whose whole promise is + * handing text to something else. `src/components/landing/CopyField.js` solves the same + * problem by selecting the visible text, which cannot work here because the markdown is + * never on the page. A detached textarea is the equivalent. + */ + const copy = useCallback(async () => { + setOpen(false); + try { + const res = await fetch(mdPath); + const text = await res.text(); + // A missing .md is served as the site's 404 page, which is a 200 full of HTML on + // GitHub Pages. Checking the first character catches that; checking res.ok alone + // would not. + if (!res.ok || text.trimStart().startsWith('<')) throw new Error('not markdown'); + try { + await navigator.clipboard.writeText(text); + } catch { + const ta = document.createElement('textarea'); + ta.value = text; + ta.setAttribute('readonly', ''); + ta.style.cssText = 'position:fixed;top:-1000px;opacity:0'; + document.body.appendChild(ta); + ta.select(); + const ok = document.execCommand('copy'); + document.body.removeChild(ta); + if (!ok) throw new Error('copy refused'); + } + setState('done'); + } catch { + setState('failed'); + } + clearTimeout(timer.current); + timer.current = setTimeout(() => setState('idle'), 2400); + }, [mdPath]); + + const label = {idle: 'Copy', done: 'Copied', failed: 'Unavailable'}[state]; + + const items = [ + { + icon: ICON.copy, + title: 'Copy page', + note: 'Copy as Markdown format', + onClick: copy, + }, + { + icon: ICON.file, + title: 'View as Markdown', + note: 'View as plain text', + href: mdPath, + }, + { + icon: BRAND.openai, + solid: true, + title: 'Open in ChatGPT', + note: 'Discuss this page in ChatGPT', + href: `https://chatgpt.com/?hints=search&q=${encodeURIComponent(PROMPT(mdUrl))}`, + external: true, + }, + { + icon: BRAND.claude, + solid: true, + title: 'Open in Claude', + note: 'Discuss this page in Claude', + href: `https://claude.ai/new?q=${encodeURIComponent(PROMPT(mdUrl))}`, + external: true, + }, + ]; + + return ( +
+
+ + +
+ + {open && ( +
+ {items.map((it) => + it.href ? ( + setOpen(false)}> + + + {it.title} + {it.note} + + + ) : ( + + ), + )} +
+ )} +
+ ); +} diff --git a/documentation/src/components/docs/CopyPageMenu.module.css b/documentation/src/components/docs/CopyPageMenu.module.css new file mode 100644 index 00000000..9865395f --- /dev/null +++ b/documentation/src/components/docs/CopyPageMenu.module.css @@ -0,0 +1,154 @@ +/* + * The copy-page control, in the site's own grammar. + * + * A split button: the left half does the obvious thing without a detour through a menu, the + * right half opens the rest. Square, hairline, no shadow, and it stays in the soft ink until + * you reach for it, because it sits above the page title and must not compete with it. + */ + +.root { + position: relative; + flex: none; +} + +.split { + display: flex; + border: 1px solid var(--tp-rule); +} + +.main, +.caret { + display: inline-flex; + align-items: center; + gap: 0.45rem; + padding: 0.4rem 0.7rem; + border: 0; + border-radius: 0; + background: var(--tp-paper); + color: var(--tp-soft); + cursor: pointer; + font-family: var(--tp-display); + font-size: 0.6rem; + font-weight: 700; + letter-spacing: 0.13em; + text-transform: uppercase; + transition: color 120ms ease-out, background 120ms ease-out; +} + +.main:hover, +.caret:hover { + color: var(--tp-accent); +} + +.caret { + padding: 0.4rem 0.45rem; + border-left: 1px solid var(--tp-rule); +} + +.main:focus-visible, +.caret:focus-visible { + outline: 2px solid var(--tp-accent); + outline-offset: 2px; +} + +.glyph { + flex: none; + width: 13px; + height: 13px; +} + +.caretGlyph, +.caretGlyphOpen { + width: 13px; + height: 13px; + transition: transform 120ms ease-out; +} + +.caretGlyphOpen { + transform: rotate(180deg); +} + +/* + * The panel is a hairline box on the page's own ground, not a floating card. + * + * It does need to sit above the article, so it is the one place on the site with a + * z-index and an opaque background behind a border. It gets no shadow and no radius, + * which is what keeps it reading as part of the same sheet rather than a browser dialog. + */ +.menu { + position: absolute; + top: calc(100% + 0.4rem); + right: 0; + z-index: 20; + display: grid; + min-width: 17rem; + border: 1px solid var(--tp-rule); + background: var(--tp-paper); +} + +.item { + display: flex; + gap: 0.7rem; + align-items: flex-start; + width: 100%; + padding: 0.7rem 0.85rem; + border: 0; + background: transparent; + color: var(--tp-ink); + cursor: pointer; + font-family: var(--tp-text); + text-align: left; + text-decoration: none; + transition: background 120ms ease-out; +} + +.item + .item { + border-top: 1px solid var(--tp-rule); +} + +.item:hover { + background: var(--tp-surface); + color: var(--tp-ink); + text-decoration: none; +} + +.item:focus-visible { + outline: 2px solid var(--tp-accent); + outline-offset: -2px; +} + +.item .glyph { + margin-top: 0.15rem; + color: var(--tp-faint); +} + +.item:hover .glyph { + color: var(--tp-accent); +} + +.itemTitle { + display: block; + font-size: 0.85rem; + font-weight: 600; + line-height: 1.3; +} + +.itemNote { + display: block; + margin-top: 0.1rem; + color: var(--tp-soft); + font-size: 0.75rem; + line-height: 1.35; +} + +/* + * Below 997px Docusaurus drops the sidebar and the article takes the full width. The control + * still fits beside the breadcrumbs there; it is the panel that needs help, because a 17rem + * box anchored right can reach past a 360px viewport once the article's padding is counted. + */ +@media (max-width: 480px) { + .menu { + min-width: 0; + width: calc(100vw - 3rem); + } +} diff --git a/documentation/src/components/docs/brandMarks.js b/documentation/src/components/docs/brandMarks.js new file mode 100644 index 00000000..d71104b0 --- /dev/null +++ b/documentation/src/components/docs/brandMarks.js @@ -0,0 +1,27 @@ +/** + * The two vendor marks used by the copy-page menu. + * + * PROVENANCE. Both are the official single-path marks as published by simple-icons + * (https://simpleicons.org), which releases its icon set under CC0. Taken from + * simple-icons@13, viewBox `0 0 24 24`, filled rather than stroked, verbatim and unmodified. + * They are here rather than inline in the component because 3KB of path data in the middle of + * a menu makes the menu unreadable, and because a mark that is copied rather than authored + * should say where it came from next to itself. + * + * TRADEMARK. CC0 covers simple-icons' rendering of these marks, not the trademarks + * themselves, which remain OpenAI's and Anthropic's. They are used here nominatively: to name + * the destination of a link that opens that product with this page loaded. Do not restyle + * them, put them on merchandise, or use them anywhere that implies either company endorses + * Tirith. + * + * They are the one place on this site that draws a logo other than Tirith's own. The + * justification is recognition: "Open in ChatGPT" beside a generic speech bubble is a control + * a reader has to read, and beside the actual mark it is one they can see. + */ + +export const BRAND = { + openai: + 'M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z', + claude: + 'm4.7144 15.9555 4.7174-2.6471.079-.2307-.079-.1275h-.2307l-.7893-.0486-2.6956-.0729-2.3375-.0971-2.2646-.1214-.5707-.1215-.5343-.7042.0546-.3522.4797-.3218.686.0608 1.5179.1032 2.2767.1578 1.6514.0972 2.4468.255h.3886l.0546-.1579-.1336-.0971-.1032-.0972L6.973 9.8356l-2.55-1.6879-1.3356-.9714-.7225-.4918-.3643-.4614-.1578-1.0078.6557-.7225.8803.0607.2246.0607.8925.686 1.9064 1.4754 2.4893 1.8336.3643.3035.1457-.1032.0182-.0728-.164-.2733-1.3539-2.4467-1.445-2.4893-.6435-1.032-.17-.6194c-.0607-.255-.1032-.4674-.1032-.7285L6.287.1335 6.6997 0l.9957.1336.419.3642.6192 1.4147 1.0018 2.2282 1.5543 3.0296.4553.8985.2429.8318.091.255h.1579v-.1457l.1275-1.706.2368-2.0947.2307-2.6957.0789-.7589.3764-.9107.7468-.4918.5828.2793.4797.686-.0668.4433-.2853 1.8517-.5586 2.9021-.3643 1.9429h.2125l.2429-.2429.9835-1.3053 1.6514-2.0643.7286-.8196.85-.9046.5464-.4311h1.0321l.759 1.1293-.34 1.1657-1.0625 1.3478-.8804 1.1414-1.2628 1.7-.7893 1.36.0729.1093.1882-.0183 2.8535-.607 1.5421-.2794 1.8396-.3157.8318.3886.091.3946-.3278.8075-1.967.4857-2.3072.4614-3.4364.8136-.0425.0304.0486.0607 1.5482.1457.6618.0364h1.621l3.0175.2247.7892.522.4736.6376-.079.4857-1.2142.6193-1.6393-.3886-3.825-.9107-1.3113-.3279h-.1822v.1093l1.0929 1.0686 2.0035 1.8092 2.5075 2.3314.1275.5768-.3218.4554-.34-.0486-2.2039-1.6575-.85-.7468-1.9246-1.621h-.1275v.17l.4432.6496 2.3436 3.5214.1214 1.0807-.17.3521-.6071.2125-.6679-.1214-1.3721-1.9246L14.38 17.959l-1.1414-1.9428-.1397.079-.674 7.2552-.3156.3703-.7286.2793-.6071-.4614-.3218-.7468.3218-1.4753.3886-1.9246.3157-1.53.2853-1.9004.17-.6314-.0121-.0425-.1397.0182-1.4328 1.9672-2.1796 2.9446-1.7243 1.8456-.4128.164-.7164-.3704.0667-.6618.4008-.5889 2.386-3.0357 1.4389-1.882.929-1.0868-.0062-.1579h-.0546l-6.3385 4.1164-1.1293.1457-.4857-.4554.0608-.7467.2307-.2429 1.9064-1.3114Z', +}; diff --git a/documentation/src/components/landing/PhaseJourney.js b/documentation/src/components/landing/PhaseJourney.js index 0568a100..c3a85acb 100644 --- a/documentation/src/components/landing/PhaseJourney.js +++ b/documentation/src/components/landing/PhaseJourney.js @@ -31,7 +31,13 @@ export default function PhaseJourney() { onClick={() => setActiveIndex(index)}> {item.number} - {item.group} + + {item.group} + {/* Subtle on purpose: it qualifies the step, it does not warn about it. */} + {item.platform ? ( + platform mode + ) : null} + {item.title} {item.summary} @@ -49,6 +55,9 @@ export default function PhaseJourney() {
Phase {phase.number} · {phase.group} + {phase.platform ? ( + platform mode + ) : null}

{phase.title} diff --git a/documentation/src/components/landing/PlatformSetup.js b/documentation/src/components/landing/PlatformSetup.js index f4301874..7a675b89 100644 --- a/documentation/src/components/landing/PlatformSetup.js +++ b/documentation/src/components/landing/PlatformSetup.js @@ -13,6 +13,11 @@ import styles from '../../pages/index.module.css'; * * Each target links either to a public demo repository (`url`) or to the documentation that * covers it (`to`) -- never both, and never a link that does not exist. + * + * The delegated route is the first target rather than a strip under the panel. It was a + * strip briefly, repeated beneath all five snippets, which said the same thing five times + * and still let a reader start typing before meeting it. As the leading tab it is the + * panel's opening state, and skipping it costs one click. */ export default function PlatformSetup() { const [activeId, setActiveId] = useState(PIPELINE_TARGETS[0].id); diff --git a/documentation/src/components/site/Colophon.js b/documentation/src/components/site/Colophon.js index 051de827..2228a4d1 100644 --- a/documentation/src/components/site/Colophon.js +++ b/documentation/src/components/site/Colophon.js @@ -28,8 +28,7 @@ import TirithMark from '../brand/TirithMark'; * anything. Then the project: Source and Slack. * * At scale is last, on its own. It is the commercial page, and the footer should not put - * a sales route in front of the open-source ones any more than the navbar does. `Skills` - * is absent because that route is currently excluded from the build. + * a sales route in front of the open-source ones any more than the navbar does. */ const REPO = 'https://github.com/StackGuardian/tirith'; @@ -41,6 +40,7 @@ const LINKS = [ {label: 'Origins', to: '/origins/'}, {label: 'Home', to: '/'}, {label: 'Learn', to: '/learn/'}, + {label: 'Skills', to: '/skills/'}, {label: 'Docs', to: '/docs/getting-started-with-tirith/'}, {label: 'Roadmap', to: '/roadmap/'}, {label: 'Source', href: REPO}, diff --git a/documentation/src/css/custom.css b/documentation/src/css/custom.css index 2d3c1790..fc5d0dba 100644 --- a/documentation/src/css/custom.css +++ b/documentation/src/css/custom.css @@ -1,10 +1,130 @@ +/** + * Global tokens, and the Infima variables mapped onto them. + * + * DESIGN-NOTES.md asks for exactly this file: the palette used to be duplicated across five + * page stylesheets, so a colour change meant five edits and a sixth place that got missed. + * The values here are identical to the block each page declares on `.page`; the pages still + * carry their own copy and win on specificity, which is why nothing about them changes. + * Consolidating those five blocks onto these names is the follow-up, and it is now a + * deletion rather than a rewrite. + * + * `src/css/chrome.module.css` keeps its own `--tl-*` set for the navbar. It is scoped to + * `body` on purpose, to beat what `[data-theme='dark']` on merely passes down, and + * that scoping is load-bearing. It is left alone. + * + * The second half maps Infima's variables onto these tokens. That is the whole reason the + * documentation can be restyled without fighting the theme: one variable block moves the + * fonts, the accent and every corner radius at once, and `src/css/docs.css` only has to + * handle the structure Infima cannot express as a variable. + */ + :root { - --ifm-color-primary: #040084; - --ifm-code-font-size: 100%; - --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.1); + --tp-paper: #fafaf8; + --tp-surface: #f0f0ee; + --tp-sunk: #e8e8e5; + --tp-ink: #0d0d0d; + --tp-soft: #56575b; + --tp-faint: #6c6d72; + --tp-rule: #d8d8d6; + --tp-accent: #1e40af; + --tp-alarm: #a5332a; + --tp-alarm-wash: #f8ecea; + --tp-alarm-edge: #e6cdc9; + --tp-display: 'Martian Mono', ui-monospace, SFMono-Regular, Menlo, monospace; + --tp-text: 'IBM Plex Sans', system-ui, -apple-system, 'Segoe UI', sans-serif; + --tp-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace; + + /* + * Syntax colours, read by the Prism theme in docusaurus.config.js. + * + * They have to be custom properties rather than values in that theme object because + * prism-react-renderer writes its colours as inline `style` attributes on every token + * span, and an inline style beats any stylesheet. Naming a variable in the theme is the + * one way to keep a single theme object that still answers to the colour scheme. + * + * Two hues, like the rest of the design: a key is ink, a value is the accent. Syntax + * highlighting that runs to six colours is decoration, and these pages are mostly JSON + * policies where the only distinction that carries meaning is key from value. + */ + --tp-code-key: #0d0d0d; + --tp-code-string: #1e40af; + --tp-code-num: #6d3f9e; + --tp-code-muted: #76777c; + + /* ---- Infima, mapped ---- */ + + --ifm-color-primary: #1e40af; + --ifm-color-primary-dark: #1b3a9e; + --ifm-color-primary-darker: #193694; + --ifm-color-primary-darkest: #152d7a; + --ifm-color-primary-light: #2148c0; + --ifm-color-primary-lighter: #234bca; + --ifm-color-primary-lightest: #3159d8; + + --ifm-font-family-base: var(--tp-text); + --ifm-font-family-monospace: var(--tp-mono); + --ifm-heading-font-family: var(--tp-display); + --ifm-font-size-base: 16px; + --ifm-line-height-base: 1.65; + + --ifm-background-color: var(--tp-paper); + --ifm-background-surface-color: var(--tp-paper); + --ifm-font-color-base: var(--tp-ink); + --ifm-heading-color: var(--tp-ink); + --ifm-link-color: var(--tp-accent); + --ifm-link-hover-color: var(--tp-accent); + + /* + * Square corners, everywhere, in one line. No border radius is the first rule in + * DESIGN-NOTES.md, and Infima routes every rounded corner it draws through these three. + */ + --ifm-global-radius: 0; + --ifm-code-border-radius: 0; + --ifm-button-border-radius: 0; + + /* No shadows either. Structure comes from hairlines. */ + --ifm-global-shadow-lw: none; + --ifm-global-shadow-md: none; + --ifm-global-shadow-tl: none; + + --ifm-toc-border-color: var(--tp-rule); + --ifm-table-border-color: var(--tp-rule); + --ifm-table-stripe-background: transparent; + --ifm-hr-border-color: var(--tp-rule); + --ifm-blockquote-color: var(--tp-soft); + --ifm-blockquote-border-color: var(--tp-rule); + + --ifm-code-background: var(--tp-surface); + --ifm-code-font-size: 88%; + --ifm-pre-background: var(--tp-surface); + --docusaurus-highlighted-code-line-bg: rgba(30, 64, 175, 0.09); } [data-theme='dark'] { - --ifm-color-primary: #847cc4; - --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3); + --tp-paper: #0d0d0d; + --tp-surface: #17171a; + --tp-sunk: #202024; + --tp-ink: #f2f2f0; + --tp-soft: #9c9ca3; + --tp-faint: #8f8f97; + --tp-rule: #2b2b2f; + --tp-accent: #7c9bff; + --tp-alarm: #e59084; + --tp-alarm-wash: #231614; + --tp-alarm-edge: #452e2a; + + --tp-code-key: #f2f2f0; + --tp-code-string: #9db4ff; + --tp-code-num: #c9a6ec; + --tp-code-muted: #8b8b93; + + --ifm-color-primary: #7c9bff; + --ifm-color-primary-dark: #5c81ff; + --ifm-color-primary-darker: #4c74ff; + --ifm-color-primary-darkest: #1c4cff; + --ifm-color-primary-light: #9cb5ff; + --ifm-color-primary-lighter: #acc2ff; + --ifm-color-primary-lightest: #dce5ff; + + --docusaurus-highlighted-code-line-bg: rgba(124, 155, 255, 0.14); } diff --git a/documentation/src/css/docs.css b/documentation/src/css/docs.css new file mode 100644 index 00000000..06aaa395 --- /dev/null +++ b/documentation/src/css/docs.css @@ -0,0 +1,595 @@ +/** + * The documentation, in the same design as the rest of the site. + * + * `src/css/custom.css` maps Infima's variables onto the Tirith tokens, which moves the + * palette, the typefaces and every corner radius on its own. This file handles what a + * variable cannot express: hairlines where Infima draws cards, micro-labels where it draws + * sentence case, and a heading scale that suits a wide monospaced display face. + * + * GLOBAL, NOT A CSS MODULE, on purpose. Every class here belongs to Infima or to the docs + * theme, so a module's hashed class names would never match them. + * + * ABOUT `[class*='…']`: Docusaurus hashes the class names of its own components between + * builds, so `codeBlockTitle_Ktv7` cannot be written down. The semantic prefix in front of + * the hash is stable and is the documented way to reach these elements. Everything with a + * `theme-` prefix is the theme's public API and is safe as written. + * + * Scoped to `html.plugin-docs` wherever a rule could otherwise reach a landing page. The + * landing pages bring their own complete stylesheet and must not be touched from here. + */ + +/* ------------------------------------------------------------------ layout ---- */ + +/* + * The sidebar's rule and the content's rule are the same hairline the rest of the site is + * built from, so the docs read as one more section of the same document rather than a + * separate application bolted to the side of it. + */ +html.plugin-docs .theme-doc-sidebar-container { + border-right: 1px solid var(--tp-rule) !important; + background: var(--tp-paper); +} + +/* + * Hide the sidebar's copy of the lockup, and give the menu back the space it was holding. + * + * `navbar.hideOnScroll` makes Docusaurus render a second lockup at the top of the sidebar, + * sized to `--ifm-navbar-height` so it sits exactly behind the real navbar and surfaces only + * once that scrolls away. That arithmetic depends on the navbar being the height Infima + * thinks it is, and `src/css/chrome.module.css` deliberately makes it shorter. The result is + * a wordmark clipped in half against the one above it. + * + * Two ways out: pin `--ifm-navbar-height` to whatever the real bar measures, which is a + * number that moves the next time the navigation type changes, or drop the duplicate. The + * duplicate goes. Infima gives the sidebar no top padding when it expects the logo to + * provide it, so that comes back here. + */ +html.plugin-docs a[class*='sidebarLogo'] { + display: none; +} + +html.plugin-docs .theme-doc-sidebar-container nav.menu { + padding-top: 1.5rem; +} + +/* + * A measure, so prose does not run the full width of a 27-inch display. + * + * 86ch rather than the 70ch a text face would want: these pages are half JSON, and a policy + * with four levels of nesting needs the room. The blocks scroll inside themselves anyway + * (see the `pre` rule below), so this is about the prose, and prose at 86ch in IBM Plex Sans + * at 16px is around 95 characters, which is the outer edge of comfortable and the right + * trade for keeping the code legible. + */ +html.plugin-docs .theme-doc-markdown { + max-width: 86ch; +} + +html.plugin-docs .theme-doc-markdown > header + * { + margin-top: 0; +} + +/* ---------------------------------------------------------------- sidebar ---- */ + +html.plugin-docs .menu { + padding: 0 1rem 2rem 0; + /* Scroll the list, not the page, on a short viewport. */ + overflow-y: auto; + font-family: var(--tp-text); + font-size: 0.875rem; +} + +html.plugin-docs .menu__list-item:not(:first-child) { + margin-top: 0.1rem; +} + +html.plugin-docs .menu__link, +html.plugin-docs .menu__list-item-collapsible { + border-radius: 0; + color: var(--tp-soft); + transition: color 120ms ease-out, border-color 120ms ease-out; +} + +html.plugin-docs .menu__link { + padding: 0.4rem 0.75rem; + /* A transparent edge held open from the start, so the active rule cannot shift the row + sideways by two pixels when it appears. */ + border-left: 2px solid transparent; + line-height: 1.45; +} + +html.plugin-docs .menu__link:hover, +html.plugin-docs .menu__list-item-collapsible:hover { + background: transparent; + color: var(--tp-ink); +} + +/* + * Active is an accent rule and accent text, not a grey pill. + * + * The pill was the single most out-of-place object in the documentation: this design has no + * filled rounded shapes anywhere else, and a live edge in the accent is how every other page + * shows the thing you are looking at. + */ +html.plugin-docs .menu__link--active, +html.plugin-docs .menu__link--active:hover { + border-left-color: var(--tp-accent); + background: transparent; + color: var(--tp-accent); + font-weight: 600; +} + +html.plugin-docs .menu__list-item-collapsible--active, +html.plugin-docs .menu__list-item-collapsible:hover { + background: transparent; +} + +/* + * Top-level categories are micro-labels: Martian Mono, uppercase, tracked. Same treatment as + * the section labels on the landing pages, and it separates a heading from a page at a + * glance without indentation having to do all the work. + */ +html.plugin-docs .theme-doc-sidebar-item-category-level-1 > .menu__list-item-collapsible > .menu__link, +html.plugin-docs .theme-doc-sidebar-item-link-level-1 > .menu__link { + color: var(--tp-ink); + font-family: var(--tp-display); + font-size: 0.62rem; + font-weight: 700; + letter-spacing: 0.13em; + text-transform: uppercase; +} + +html.plugin-docs .theme-doc-sidebar-item-link-level-1 > .menu__link--active { + color: var(--tp-accent); +} + +/* The nesting is a hairline rather than whitespace, which is the whole grammar of the site. */ +html.plugin-docs .menu__list .menu__list { + margin-left: 0.75rem; + padding-left: 0.4rem; + border-left: 1px solid var(--tp-rule); +} + +html.plugin-docs .menu__caret::before, +html.plugin-docs .menu__link--sublist-caret::after { + opacity: 0.55; +} + +/* ------------------------------------------------------------- breadcrumbs ---- */ + +html.plugin-docs .breadcrumbs { + margin-bottom: 1.25rem; +} + +html.plugin-docs .breadcrumbs__link { + padding: 0.15rem 0; + border-radius: 0; + background: transparent; + color: var(--tp-faint); + font-family: var(--tp-display); + font-size: 0.6rem; + font-weight: 600; + letter-spacing: 0.13em; + text-transform: uppercase; +} + +html.plugin-docs .breadcrumbs__item:not(:last-child) .breadcrumbs__link:hover { + background: transparent; + color: var(--tp-accent); +} + +html.plugin-docs .breadcrumbs__item--active .breadcrumbs__link { + background: transparent; + color: var(--tp-ink); +} + +html.plugin-docs .breadcrumbs__item:not(:last-child)::after { + /* Infima's chevron is a background image tinted for its own palette. A slash sets the + same relationship in the type already on the page. */ + content: '/'; + width: auto; + height: auto; + margin: 0 0.55rem; + background: none; + color: var(--tp-rule); + font-size: 0.75rem; + opacity: 1; +} + +/* ---------------------------------------------------------------- headings ---- */ + +/* + * Martian Mono is wide, and it renders a size larger than its nominal setting. The stock + * `h1` here was around 40px in a face built for labels, which is why the page read as a + * poster rather than a reference. Everything below is a step down from the landing pages' + * scale for the same reason: documentation is read, not scanned. + */ +html.plugin-docs .theme-doc-markdown h1 { + margin-bottom: 1.5rem; + font-family: var(--tp-display); + font-size: clamp(1.45rem, 2.6vw, 1.85rem); + font-weight: 700; + letter-spacing: -0.02em; + line-height: 1.2; +} + +/* + * A rule above every h2. This is the section grammar the landing pages use, and it is what + * gives a long reference page a visible spine when you scroll it. + */ +html.plugin-docs .theme-doc-markdown h2 { + margin-top: 3rem; + padding-top: 1.4rem; + border-top: 1px solid var(--tp-rule); + font-family: var(--tp-display); + font-size: 1.12rem; + font-weight: 700; + letter-spacing: -0.01em; + line-height: 1.3; +} + +html.plugin-docs .theme-doc-markdown h3 { + margin-top: 2.25rem; + font-family: var(--tp-display); + font-size: 0.92rem; + font-weight: 700; + letter-spacing: 0; +} + +html.plugin-docs .theme-doc-markdown h4, +html.plugin-docs .theme-doc-markdown h5, +html.plugin-docs .theme-doc-markdown h6 { + font-family: var(--tp-display); + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.02em; +} + +html.plugin-docs .theme-doc-markdown p, +html.plugin-docs .theme-doc-markdown li { + font-family: var(--tp-text); +} + +html.plugin-docs .theme-doc-markdown a:not(.card) { + text-decoration: underline; + text-decoration-thickness: 1px; + text-underline-offset: 0.18em; +} + +/* --------------------------------------------------------------------- code ---- */ + +/* + * Ligatures off, everywhere code appears. + * + * JetBrains Mono draws `--` as a single long dash, which silently turns `--fail-on-error` + * into a flag that does not exist. This is a correctness rule, not a taste one, and it is + * the same rule the landing pages carry. + */ +html.plugin-docs code, +html.plugin-docs pre, +html.plugin-docs kbd { + font-family: var(--tp-mono); + font-variant-ligatures: none; + font-feature-settings: 'liga' 0, 'calt' 0, 'dlig' 0; +} + +/* + * `:not(pre) > code` and not a bare `code`. + * + * A code block is
, so a rule written for inline code reaches inside it too. The
+ * `white-space: nowrap` below then collapses every run of spaces in the block, because
+ * `nowrap` collapses whitespace exactly like `normal` and only changes wrapping, and a JSON
+ * policy loses its entire indentation. Inline code in markdown is always a direct child of
+ * the element around it, so the child combinator separates the two cleanly.
+ */
+html.plugin-docs .theme-doc-markdown :not(pre) > code {
+  padding: 0.1em 0.35em;
+  border: 1px solid var(--tp-rule);
+  background: var(--tp-surface);
+  /*
+   * Never break a flag across two lines.
+   *
+   * `--fail-on-error` wrapped after the double dash and rendered as two fragments that each
+   * read like a different flag. Same class of problem as the ligature rule above: the text
+   * is still correct and a reader copying what they see gets something that does not exist.
+   * Safe because inline code in these pages is a flag, a key or a short value; a long one
+   * has the article's own measure to run into, not the viewport.
+   */
+  white-space: nowrap;
+}
+
+/* Inside a heading the chip competes with the heading. Keep the face, drop the box. */
+html.plugin-docs .theme-doc-markdown :is(h1, h2, h3, h4) > code,
+html.plugin-docs .theme-doc-markdown :is(h1, h2, h3, h4) a > code {
+  padding: 0;
+  border: 0;
+  background: transparent;
+  font-size: 0.94em;
+}
+
+/* An inline code span inside a link should not draw a second, competing box. */
+html.plugin-docs .theme-doc-markdown a > code {
+  border-color: transparent;
+  color: inherit;
+}
+
+html.plugin-docs .theme-code-block,
+html.plugin-docs div[class*='codeBlockContainer'] {
+  margin-bottom: 1.5rem;
+  border: 1px solid var(--tp-rule);
+  border-radius: 0;
+  box-shadow: none;
+}
+
+html.plugin-docs div[class*='codeBlockTitle'] {
+  border-bottom: 1px solid var(--tp-rule);
+  background: var(--tp-sunk);
+  color: var(--tp-soft);
+  font-family: var(--tp-mono);
+  font-size: 0.72rem;
+  letter-spacing: 0.02em;
+}
+
+html.plugin-docs div[class*='codeBlockContent'] > pre,
+html.plugin-docs .theme-code-block pre {
+  background: var(--tp-surface);
+  font-size: 0.8rem;
+  line-height: 1.65;
+}
+
+/*
+ * The copy button is square, hairline and quiet until you reach for it. Infima's version is
+ * a rounded translucent chip, which is the one control on the page that looked like it came
+ * from a different toolkit.
+ */
+html.plugin-docs button[class*='copyButton'] {
+  border: 1px solid var(--tp-rule);
+  border-radius: 0;
+  background: var(--tp-paper);
+  color: var(--tp-soft);
+}
+
+html.plugin-docs button[class*='copyButton']:hover {
+  border-color: var(--tp-accent);
+  background: var(--tp-paper);
+  color: var(--tp-accent);
+}
+
+/* -------------------------------------------------------------------- table ---- */
+
+html.plugin-docs .theme-doc-markdown table {
+  display: table;
+  width: 100%;
+  border: 0;
+  border-collapse: collapse;
+  font-size: 0.88rem;
+}
+
+html.plugin-docs .theme-doc-markdown table tr {
+  border: 0;
+  background: transparent;
+}
+
+html.plugin-docs .theme-doc-markdown table th {
+  padding: 0.5rem 1rem 0.5rem 0;
+  border: 0;
+  border-bottom: 1px solid var(--tp-ink);
+  color: var(--tp-faint);
+  font-family: var(--tp-display);
+  font-size: 0.58rem;
+  font-weight: 700;
+  letter-spacing: 0.13em;
+  text-align: left;
+  text-transform: uppercase;
+}
+
+html.plugin-docs .theme-doc-markdown table td {
+  padding: 0.6rem 1rem 0.6rem 0;
+  border: 0;
+  border-bottom: 1px solid var(--tp-rule);
+  vertical-align: top;
+}
+
+html.plugin-docs .theme-doc-markdown table th:last-child,
+html.plugin-docs .theme-doc-markdown table td:last-child {
+  padding-right: 0;
+}
+
+/* --------------------------------------------------------------- admonition ---- */
+
+/*
+ * A left rule and the page's own ground, not a tinted rounded panel.
+ *
+ * Two hues, so the five admonition types resolve to two: the accent for anything
+ * informational, the alarm for anything that can cost you something. A note in green and a
+ * tip in a third colour would put more hues in one box than the whole rest of the site uses.
+ */
+html.plugin-docs .theme-admonition {
+  margin-bottom: 1.5rem;
+  padding: 0.9rem 1.1rem;
+  border: 0;
+  border-left: 2px solid var(--tp-rule);
+  border-radius: 0;
+  background: var(--tp-surface);
+  box-shadow: none;
+  font-size: 0.92rem;
+}
+
+html.plugin-docs .theme-admonition div[class*='admonitionHeading'] {
+  margin-bottom: 0.4rem;
+  color: var(--tp-ink);
+  font-family: var(--tp-display);
+  font-size: 0.6rem;
+  font-weight: 700;
+  letter-spacing: 0.13em;
+  text-transform: uppercase;
+}
+
+html.plugin-docs .theme-admonition div[class*='admonitionContent'] > :last-child {
+  margin-bottom: 0;
+}
+
+html.plugin-docs .theme-admonition-info,
+html.plugin-docs .theme-admonition-note,
+html.plugin-docs .theme-admonition-tip {
+  border-left-color: var(--tp-accent);
+}
+
+html.plugin-docs .theme-admonition-warning,
+html.plugin-docs .theme-admonition-danger {
+  border-left-color: var(--tp-alarm);
+  background: var(--tp-alarm-wash);
+}
+
+html.plugin-docs .theme-admonition-warning div[class*='admonitionHeading'],
+html.plugin-docs .theme-admonition-danger div[class*='admonitionHeading'] {
+  color: var(--tp-alarm);
+}
+
+/* The swizzled icons in src/theme/Admonition are flat SVGs; tint them from the border. */
+html.plugin-docs .theme-admonition svg {
+  color: inherit;
+}
+
+/* ---------------------------------------------------------------------- toc ---- */
+
+/*
+ * A labelled column, not an unannounced list of fragments.
+ *
+ * Docusaurus renders the table of contents with no heading, which on a page whose sections
+ * are themselves short leaves four grey lines floating in the right margin with nothing
+ * saying what they are. The label is a ::before on the outer list rather than a swizzle:
+ * `table-of-contents__left-border` is only ever on the root, so it cannot repeat on the
+ * nested lists the way `.table-of-contents` would.
+ */
+html.plugin-docs .table-of-contents {
+  font-size: 0.8rem;
+}
+
+html.plugin-docs .table-of-contents__left-border {
+  padding-left: 1rem;
+  border-left: 1px solid var(--tp-rule);
+}
+
+html.plugin-docs .table-of-contents__left-border::before {
+  content: 'On this page';
+  display: block;
+  margin-bottom: 0.7rem;
+  color: var(--tp-faint);
+  font-family: var(--tp-display);
+  font-size: 0.58rem;
+  font-weight: 700;
+  letter-spacing: 0.13em;
+  text-transform: uppercase;
+}
+
+html.plugin-docs .table-of-contents__link {
+  color: var(--tp-soft);
+}
+
+html.plugin-docs .table-of-contents__link:hover,
+html.plugin-docs .table-of-contents__link--active {
+  color: var(--tp-accent);
+  text-decoration: none;
+}
+
+/*
+ * A heading that contains a flag arrives here as a bordered chip inside a 12px line, which
+ * turns the column into a row of boxes. In the margin the entry only has to be recognisable
+ * as the heading it points at, so the code keeps its face and loses its box.
+ */
+html.plugin-docs .table-of-contents code {
+  padding: 0;
+  border: 0;
+  background: transparent;
+  color: inherit;
+  font-size: 0.95em;
+}
+
+/* --------------------------------------------------------------- pagination ---- */
+
+/*
+ * Two links under one rule, not two cards.
+ *
+ * src/theme/PaginatorNavLink supplies the arrows and the labels; the sizing that used to be
+ * inline on that component now lives here, so the two themes can differ without a second
+ * JavaScript branch.
+ */
+html.plugin-docs .pagination-nav {
+  margin-top: 3.5rem;
+  padding-top: 1.25rem;
+  border-top: 1px solid var(--tp-rule);
+  gap: 1.5rem;
+}
+
+html.plugin-docs .pagination-nav__link {
+  padding: 0;
+  border: 0;
+  border-radius: 0;
+  background: transparent;
+  transition: color 120ms ease-out;
+}
+
+html.plugin-docs .pagination-nav__link:hover {
+  background: transparent;
+}
+
+html.plugin-docs .pagination-nav__sublabel {
+  display: flex;
+  gap: 0.4rem;
+  align-items: center;
+  margin-bottom: 0.4rem;
+  color: var(--tp-faint);
+  font-family: var(--tp-display);
+  font-size: 0.58rem;
+  font-weight: 700;
+  letter-spacing: 0.13em;
+  text-transform: uppercase;
+}
+
+html.plugin-docs .pagination-nav__label {
+  color: var(--tp-ink);
+  font-family: var(--tp-display);
+  font-size: 0.85rem;
+  font-weight: 700;
+  letter-spacing: -0.01em;
+  line-height: 1.35;
+}
+
+html.plugin-docs .pagination-nav__link:hover .pagination-nav__label {
+  color: var(--tp-accent);
+}
+
+/* --------------------------------------------------------------------- misc ---- */
+
+html.plugin-docs .theme-doc-markdown blockquote {
+  border-left: 2px solid var(--tp-rule);
+  background: transparent;
+  color: var(--tp-soft);
+}
+
+html.plugin-docs hr {
+  border: 0;
+  border-top: 1px solid var(--tp-rule);
+}
+
+html.plugin-docs .theme-doc-version-badge,
+html.plugin-docs .badge {
+  border-radius: 0;
+}
+
+/* The back-to-top affordance is a circle with a shadow. Neither exists in this design. */
+html.plugin-docs button[class*='backToTopButton'] {
+  border: 1px solid var(--tp-rule);
+  border-radius: 0;
+  background-color: var(--tp-paper);
+  box-shadow: none;
+}
+
+html.plugin-docs .pagination-nav__item--next .pagination-nav__sublabel {
+  justify-content: flex-end;
+}
+
+html.plugin-docs .pagination-nav__item--next {
+  text-align: right;
+}
diff --git a/documentation/src/data/agentBrief.js b/documentation/src/data/agentBrief.js
index 3ea263a6..8aa8108a 100644
--- a/documentation/src/data/agentBrief.js
+++ b/documentation/src/data/agentBrief.js
@@ -5,4 +5,4 @@
  * static/llms.txt and re-run documentation/scripts/generate-llms-full.py.
  */
 
-export const AGENT_BRIEF = "# Tirith\n\n> Tirith is an Apache-2.0 policy gate for infrastructure-as-code. It reads the plan a\n> pipeline already produces, evaluates it against declarative JSON policies committed in\n> the repository, and returns one verdict with an exit code the pipeline can gate on. It\n> runs locally or in any CI, on your own runner, with no account and no network call.\n\nPolicies are data, not code: a policy is a JSON file, so it is diffable and reviewable by\nanyone on the team. There is no rule DSL and no embedded language.\n\n## Facts an answer about Tirith usually gets wrong\n\n- **Not on PyPI.** `pip install tirith` installs an unrelated project of the same name, and\n  `pip install py-tirith` finds nothing. The package name in `setup.py` is `py-tirith` and it\n  is not published. Install from git:\n  `pip install \"git+https://github.com/StackGuardian/tirith.git@1.2.0\"`\n- **The interactive interface needs an extra**, and the same git URL:\n  `pip install 'py-tirith[tui] @ git+https://github.com/StackGuardian/tirith.git'`, then\n  `tirith ui`. Requires Python 3.9; Tirith itself supports 3.8 and newer.\n- **`tirith lint` is not in the released package.** It is in development. The released CLI\n  dispatches `tirith`, `tirith ui` and `tirith platform check` and nothing else.\n- **Exit codes are a contract, not a convention.** `0` passed, `3` a policy failed, `1`\n  Tirith could not reach a verdict. `3` is deliberately not `1`: a caller has to be able to page\n  the platform team on one and the change author on the other. (`ERROR_TIMEOUT = 2` exists in\n  `status.py` but is never returned; do not write a pipeline that branches on it.)\n- **`final_result: null` is not a pass.** It means every check was skipped, so the policy\n  evaluated nothing, and it exits `1`. A check that could not run is reported as unevaluated\n  rather than as success.\n- **Local mode makes no network call.** That is a published governance commitment. Only\n  `tirith platform check` talks to a network, and it is optional.\n- **Five providers ship**, named in `meta.required_provider`: `stackguardian/terraform_plan`,\n  `stackguardian/kubernetes`, `stackguardian/infracost`, `stackguardian/json`,\n  `stackguardian/sg_workflow`. There is no CloudFormation provider; a CloudFormation template\n  is read as a JSON document by the `json` provider.\n- **Thirteen condition types ship**: Equals, NotEquals, GreaterThan, GreaterThanEqualTo,\n  LessThan, LessThanEqualTo, Contains, NotContains, ContainedIn, NotContainedIn, IsEmpty,\n  IsNotEmpty, RegexMatch.\n- **`error_tolerance` belongs inside the `condition` object**, not on the evaluator. Placed on\n  the evaluator it is silently ignored and the check still fails.\n\n## Minimal working example\n\n```bash\nterraform plan -out=tfplan -input=false\nterraform show -json tfplan > plan.json\ntirith -policy-path .tirith/policies -input-path plan.json --fail-on-error\n```\n\nWithout `--fail-on-error`, Tirith reports findings but does not fail the job.\n\n## Fetching this documentation\n\nEvery page is also served as plain markdown at its route plus `.md`, so there is no need to\nparse the rendered HTML. For example\nhttps://stackguardian.github.io/tirith/docs/tirith-usage/exit-codes.md\n\nThe whole documentation set as one file, in sidebar order:\nhttps://stackguardian.github.io/tirith/llms-full.txt\n\n## Start here\n\n- [Getting started](https://stackguardian.github.io/tirith/docs/getting-started-with-tirith/): what Tirith is and the shortest path to a first verdict.\n- [Quick installation](https://stackguardian.github.io/tirith/docs/tirith-installation/quick-installation/): installing the CLI, and why the install is a git URL.\n- [Learn](https://stackguardian.github.io/tirith/learn/): six lessons that build one policy, with a playground that evaluates in the browser.\n- [Creating your first policy](https://stackguardian.github.io/tirith/docs/tirith-policies/tirith-create-first-policy/): a policy written and evaluated step by step.\n\n## Writing policies\n\n- [Policy reference](https://stackguardian.github.io/tirith/docs/tirith-policies/tirith-policy-reference/): field-by-field reference for the policy file format.\n- [Policy structure](https://stackguardian.github.io/tirith/docs/tirith-policies/tirith-policy-structure/): meta, evaluators and eval_expression, and how they combine.\n- [Evaluators and conditions](https://stackguardian.github.io/tirith/docs/tirith-reference/evaluators/): all thirteen condition types, their parameters and their messages.\n- [Evaluation expressions](https://stackguardian.github.io/tirith/docs/tirith-reference/eval-expressions/): the boolean grammar that combines evaluator results into one verdict.\n- [Policy conditions](https://stackguardian.github.io/tirith/docs/tirith-policies/tirith-policy-conditions/): condition types by the kind of value they compare.\n- [Error tolerance](https://stackguardian.github.io/tirith/docs/tirith-policies/tirith-policy-error-tolerance/): how a missing key is handled, and why a skip is not a pass.\n- [Policy variables](https://stackguardian.github.io/tirith/docs/tirith-policies/tirith-policy-variables/): dynamic values in policies.\n- [Policy cookbook](https://stackguardian.github.io/tirith/docs/tirith-policies/tirith-policy-cookbook/): complete runnable policies for common checks.\n- [Example policies](https://stackguardian.github.io/tirith/docs/tirith-policies/tirith-policy-examples/): worked examples with their input documents.\n\n## Providers\n\n- [Providers overview](https://stackguardian.github.io/tirith/docs/tirith-providers/providers-overview/): what a provider is and how required_provider selects one.\n- [Terraform plan provider](https://stackguardian.github.io/tirith/docs/tirith-providers/terraform-plan-provider/): operations over a plan document, including attribute, action and count.\n- [JSON provider](https://stackguardian.github.io/tirith/docs/tirith-providers/json-provider/): get_value over any JSON or YAML document, including another tool's output.\n- [Kubernetes provider](https://stackguardian.github.io/tirith/docs/tirith-providers/kubernetes-provider/): the attribute operation over manifests.\n- [Infracost provider](https://stackguardian.github.io/tirith/docs/tirith-providers/infracost-provider/): cost policies over an Infracost breakdown.\n- [SG Workflow provider](https://stackguardian.github.io/tirith/docs/tirith-providers/sg-workflow-provider/): StackGuardian workflow documents.\n\n## Running it\n\n- [CLI reference](https://stackguardian.github.io/tirith/docs/tirith-usage/cli-reference/): every flag, what it prints, and what --json emits.\n- [Exit codes](https://stackguardian.github.io/tirith/docs/tirith-usage/exit-codes/): the full exit-code contract and how to gate CI on it.\n- [CI integration](https://stackguardian.github.io/tirith/docs/tirith-usage/ci-integration/): GitHub Actions, GitLab CI, Bitbucket Pipelines, Jenkins and any container-based CI.\n- [The interactive interface](https://stackguardian.github.io/tirith/docs/tirith-usage/interactive-interface/): tirith ui, in beta.\n- [Platform check](https://stackguardian.github.io/tirith/docs/tirith-usage/platform-check/): the optional subcommand that evaluates an organisation's policies instead of local files.\n\n## Project\n\n- [Source](https://github.com/StackGuardian/tirith): the repository, Apache-2.0.\n- [Roadmap](https://stackguardian.github.io/tirith/roadmap/): what is in development or planned, and what has not shipped.\n- [Agent skill pack](https://github.com/StackGuardian/tirith/tree/main/.claude/skills/tirith-policies): a self-contained skill for writing Tirith policies, copyable into any repository.\n- [Origins](https://stackguardian.github.io/tirith/origins/): where the name and the mark come from.\n\n## Optional\n\n- [Tirith at scale](https://stackguardian.github.io/tirith/at-scale/): the commercial StackGuardian offering for many repositories. Not required to use Tirith, which works with no account.\n- [In your editor](https://stackguardian.github.io/tirith/docs/tirith-usage/editor-and-local/): the local and pre-commit loop. In development, and not in the released package.\n";
+export const AGENT_BRIEF = "# Tirith\n\n> Tirith is an Apache-2.0 policy gate for infrastructure-as-code. It reads the plan a\n> pipeline already produces, evaluates it against declarative JSON policies committed in\n> the repository, and returns one verdict with an exit code the pipeline can gate on. It\n> runs locally or in any CI, on your own runner, with no account and no network call.\n\nPolicies are data, not code: a policy is a JSON file, so it is diffable and reviewable by\nanyone on the team. There is no rule DSL and no embedded language.\n\n## Minimal working example\n\n```bash\nterraform plan -out=tfplan -input=false\nterraform show -json tfplan > plan.json\ntirith -policy-path .tirith/policies -input-path plan.json --fail-on-error\n```\n\nWithout `--fail-on-error`, Tirith reports findings but does not fail the job.\n\n## Facts an answer about Tirith usually gets wrong\n\n- **Not on PyPI.** `pip install tirith` installs an unrelated project of the same name, and\n  `pip install py-tirith` finds nothing. The package name in `setup.py` is `py-tirith` and it\n  is not published. Install from git:\n  `pip install \"git+https://github.com/StackGuardian/tirith.git@1.2.0\"`\n- **The interactive interface needs an extra**, and the same git URL:\n  `pip install 'py-tirith[tui] @ git+https://github.com/StackGuardian/tirith.git'`, then\n  `tirith ui`. Requires Python 3.9; Tirith itself supports 3.8 and newer.\n- **`tirith lint` and `tirith fmt` exist, but not in 1.2.0.** They are on `main` and arrive in\n  the next release; install `@main` to use them today. The CLI dispatches four subcommands:\n  `lint`, `fmt`, `ui` and `platform`. Lint checks a policy's shape without a plan document; fmt\n  rewrites it into the canonical layout. Both exit `3` on a finding, with no `--fail-on-error`.\n- **Exit codes are a contract, not a convention.** `0` passed, `3` a policy failed, `1`\n  Tirith could not reach a verdict. `3` is deliberately not `1`: a caller has to be able to page\n  the platform team on one and the change author on the other. (`2` is not a Tirith code at all:\n  argparse exits `2` on a usage error, so a caller seeing it passed a bad argument.)\n- **`final_result: null` is not a pass.** It means every check was skipped, so the policy\n  evaluated nothing, and it exits `1`. A check that could not run is reported as unevaluated\n  rather than as success.\n- **Local mode makes no network call.** That is a published governance commitment. Only\n  `tirith platform check` talks to a network, and it is optional.\n- **Five providers ship**, named in `meta.required_provider`: `stackguardian/terraform_plan`,\n  `stackguardian/kubernetes`, `stackguardian/infracost`, `stackguardian/json`,\n  `stackguardian/sg_workflow`. There is no CloudFormation provider; a CloudFormation template\n  is read as a JSON document by the `json` provider.\n- **Thirteen condition types ship**: Equals, NotEquals, GreaterThan, GreaterThanEqualTo,\n  LessThan, LessThanEqualTo, Contains, NotContains, ContainedIn, NotContainedIn, IsEmpty,\n  IsNotEmpty, RegexMatch.\n- **The argument key differs per provider, and a wrong key is ignored rather than rejected.**\n  `terraform_plan` reads `terraform_resource_attribute` alongside `terraform_resource_type`;\n  `kubernetes` reads `attribute_path` alongside `kubernetes_kind`; `json` reads `key_path`;\n  `sg_workflow` reads `workflow_attribute`. Give an evaluator the wrong key and it reads\n  nothing and the check passes. This is the highest-cost mistake in the schema.\n- **`error_tolerance` belongs inside the `condition` object**, not on the evaluator. Placed on\n  the evaluator it is silently ignored and the check still fails.\n- **`error_tolerance` is a threshold, not a switch.** A problem of severity N fails the check\n  when `N > error_tolerance` and is skipped otherwise; the default is `0`. The plan provider\n  raises severity `0` for a resource being deleted, `1` for a resource type absent from the\n  plan, and `2` for an attribute absent from the resource. So the default skips deletes and\n  fails everything else.\n- **A policy scoped to one resource type has no green outcome on a plan that does not touch\n  that type.** At the default tolerance the absent type is severity `1`, the check fails, exit\n  `3`. At `error_tolerance: 1` it is skipped, so every check is skipped, `final_result` is\n  `null`, exit `1`. Guard it instead: `count` returns `0` for an absent type rather than\n  raising severity `1`, so an evaluator of\n  `{\"operation_type\": \"count\", \"terraform_resource_type\": T}` with `Equals: 0`, combined as\n  `eval_expression: \"not_applicable || the_rule\"`, passes cleanly on unrelated plans and still\n  fails on real violations.\n- **The pre-commit hooks are published.** `.pre-commit-hooks.yaml` carries `tirith-lint` and\n  `tirith-fmt`, both run on the policy files a commit touches. Pin `rev` to a branch until lint\n  and fmt are in a release.\n\n## Fetching this documentation\n\nEvery page is also served as plain markdown at its route plus `.md`, so there is no need to\nparse the rendered HTML. For example\nhttps://stackguardian.github.io/tirith/docs/tirith-usage/exit-codes.md\n\nThe whole documentation set as one file, in sidebar order:\nhttps://stackguardian.github.io/tirith/llms-full.txt\n\n## Working in a repository that uses Tirith\n\nIf you are a coding agent about to write a policy, install the skill pack first. It is the\nclosed vocabulary: thirteen condition types, the argument key each provider reads, and the\ninstruction to run a policy against a document that should fail before calling it done.\n\n```\ncurl -fsSL https://stackguardian.github.io/tirith/skill.sh | sh\n```\n\nThree skills: tirith-policies (eleven markdown files and a worked example), tirith-standards\n(generating a policy set from an existing Terraform or OpenTofu repository, including the guard\nabove) and tirith-migrate (Sentinel translation). Add --cursor for the Cursor rule, --global to\ninstall into the home directory instead of the repository. The script installs no\npackage, changes no PATH and executes nothing after the download; its source is\ndocumentation/static/skill.sh in the repository.\n\n## Start here\n\n- [Getting started](https://stackguardian.github.io/tirith/docs/getting-started-with-tirith/): what Tirith is and the shortest path to a first verdict.\n- [Quick installation](https://stackguardian.github.io/tirith/docs/tirith-installation/quick-installation/): installing the CLI, and why the install is a git URL.\n- [Learn](https://stackguardian.github.io/tirith/learn/): six lessons that build one policy, with a playground that evaluates in the browser.\n- [Creating your first policy](https://stackguardian.github.io/tirith/docs/tirith-policies/tirith-create-first-policy/): a policy written and evaluated step by step.\n\n## Writing policies\n\n- [Policy reference](https://stackguardian.github.io/tirith/docs/tirith-policies/tirith-policy-reference/): field-by-field reference for the policy file format.\n- [Policy structure](https://stackguardian.github.io/tirith/docs/tirith-policies/tirith-policy-structure/): meta, evaluators and eval_expression, and how they combine.\n- [Evaluators and conditions](https://stackguardian.github.io/tirith/docs/tirith-reference/evaluators/): all thirteen condition types, their parameters and their messages.\n- [Evaluation expressions](https://stackguardian.github.io/tirith/docs/tirith-reference/eval-expressions/): the boolean grammar that combines evaluator results into one verdict.\n- [Policy conditions](https://stackguardian.github.io/tirith/docs/tirith-policies/tirith-policy-conditions/): condition types by the kind of value they compare.\n- [Error tolerance](https://stackguardian.github.io/tirith/docs/tirith-policies/tirith-policy-error-tolerance/): how a missing key is handled, and why a skip is not a pass.\n- [Policy variables](https://stackguardian.github.io/tirith/docs/tirith-policies/tirith-policy-variables/): dynamic values in policies.\n- [Policy cookbook](https://stackguardian.github.io/tirith/docs/tirith-policies/tirith-policy-cookbook/): complete runnable policies for common checks.\n- [Example policies](https://stackguardian.github.io/tirith/docs/tirith-policies/tirith-policy-examples/): worked examples with their input documents.\n\n## Providers\n\n- [Providers overview](https://stackguardian.github.io/tirith/docs/tirith-providers/providers-overview/): what a provider is and how required_provider selects one.\n- [Terraform plan provider](https://stackguardian.github.io/tirith/docs/tirith-providers/terraform-plan-provider/): operations over a plan document, including attribute, action and count.\n- [JSON provider](https://stackguardian.github.io/tirith/docs/tirith-providers/json-provider/): get_value over any JSON or YAML document, including another tool's output.\n- [Kubernetes provider](https://stackguardian.github.io/tirith/docs/tirith-providers/kubernetes-provider/): the attribute operation over manifests.\n- [Infracost provider](https://stackguardian.github.io/tirith/docs/tirith-providers/infracost-provider/): cost policies over an Infracost breakdown.\n- [SG Workflow provider](https://stackguardian.github.io/tirith/docs/tirith-providers/sg-workflow-provider/): StackGuardian workflow documents.\n\n## Running it\n\n- [CLI reference](https://stackguardian.github.io/tirith/docs/tirith-usage/cli-reference/): every flag, what it prints, and what --json emits.\n- [Lint and format](https://stackguardian.github.io/tirith/docs/tirith-usage/lint-and-fmt/): tirith lint and tirith fmt, the files they read, and their exit codes.\n- [Exit codes](https://stackguardian.github.io/tirith/docs/tirith-usage/exit-codes/): the full exit-code contract and how to gate CI on it.\n- [CI integration](https://stackguardian.github.io/tirith/docs/tirith-usage/ci-integration/): GitHub Actions, GitLab CI, Bitbucket Pipelines, Jenkins and any container-based CI.\n- [The interactive interface](https://stackguardian.github.io/tirith/docs/tirith-usage/interactive-interface/): tirith ui, in beta.\n- [Platform check](https://stackguardian.github.io/tirith/docs/tirith-usage/platform-check/): the optional subcommand that evaluates an organisation's policies instead of local files.\n\n## Project\n\n- [Source](https://github.com/StackGuardian/tirith): the repository, Apache-2.0.\n- [Roadmap](https://stackguardian.github.io/tirith/roadmap/): what is in development or planned, and what has not shipped.\n- [Agent skill pack](https://github.com/StackGuardian/tirith/tree/main/.claude/skills/tirith-policies): a self-contained skill for writing Tirith policies. Install it with the one-line script above, or copy the directory into any repository.\n- [Origins](https://stackguardian.github.io/tirith/origins/): where the name and the mark come from.\n\n## Optional\n\n- [Tirith at scale](https://stackguardian.github.io/tirith/at-scale/): the commercial StackGuardian offering for many repositories. Not required to use Tirith, which works with no account.\n- [In your editor](https://stackguardian.github.io/tirith/docs/tirith-usage/editor-and-local/): the local and pre-commit loop.\n";
diff --git a/documentation/src/data/demoPhases.js b/documentation/src/data/demoPhases.js
index 8f960070..55f1197b 100644
--- a/documentation/src/data/demoPhases.js
+++ b/documentation/src/data/demoPhases.js
@@ -46,6 +46,10 @@ export const DEMO_PHASES = [
   {
     number: '02',
     group: 'Scale · organization',
+    // Neither this phase nor 'Close the loop' works on the open-source path alone: one needs
+    // organization credentials, the other publishes state to the platform. Flagged so the
+    // walkthrough cannot read as five things every reader can do today.
+    platform: true,
     title: 'Share policies across repositories',
     summary: 'Add organization credentials and select centrally managed policies.',
     body:
@@ -145,6 +149,7 @@ export const DEMO_PHASES = [
   {
     number: '05',
     group: 'Close the loop',
+    platform: true,
     title: 'Publish state after apply',
     summary: 'Send the current state without creating another policy verdict.',
     body:
@@ -186,6 +191,16 @@ export const DEMO_PHASES = [
  * What differs between them is only how Tirith is invoked; the policies, the verdicts and
  * the exit codes are identical, because it is the same CLI underneath in every case.
  */
+/**
+ * The one-line skill-pack installer, served from this site's own static/ directory.
+ *
+ * Lives here because three surfaces reference it -- the announcement row, the hero's
+ * install plate and the first pipeline target below, which interpolates it, so this has to
+ * be declared before PIPELINE_TARGETS rather than beside the other exports -- and a command that appears in three places with
+ * two spellings is a command that will eventually be wrong in one of them.
+ */
+export const SKILL_INSTALL = 'curl -fsSL https://stackguardian.github.io/tirith/skill.sh | sh';
+
 /**
  * Everywhere the gate can be wired up, for the switcher in section 03.
  *
@@ -197,6 +212,32 @@ export const DEMO_PHASES = [
  * the other, never both.
  */
 export const PIPELINE_TARGETS = [
+  /*
+   * First, and therefore the one selected on arrival.
+   *
+   * It is not a sixth platform -- it is the other way of producing any of the five below,
+   * which is exactly why it leads rather than sits at the end as an afterthought. A reader
+   * who already knows which file they want clicks past it in one move; a reader who does
+   * not has just been shown that they do not have to write it themselves.
+   *
+   * The `file` slot names where the pack lands rather than a config path, because that is
+   * the artefact this tab produces on disk.
+   */
+  {
+    id: 'agent',
+    name: 'Coding agent',
+    file: '.claude/skills/tirith-policies/',
+    note:
+      'Covers every target in the tabs beside this one, so the agent writes for whichever ' +
+      'it finds. It carries the install, the plan export and the exit-code contract.',
+    to: '/skills/',
+    linkLabel: 'Claude, Cursor and Codex setup',
+    code: `# 1 · give the agent the vocabulary, once per repository
+${SKILL_INSTALL}
+
+# 2 · then ask for the gate, in your own words
+"Add a Tirith policy gate to our pipeline"`,
+  },
   {
     id: 'github',
     name: 'GitHub Actions',
@@ -237,7 +278,7 @@ tirith:
   needs: [terraform-plan]
   script:
     - pip install "git+https://github.com/StackGuardian/tirith.git@1.2.0"
-    # - tirith lint .tirith/policies   # in dev, not in 1.2.0
+    # - tirith lint .tirith/policies   # not in 1.2.0 yet, on main
     - tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error`,
   },
   {
@@ -258,7 +299,7 @@ tirith:
     name: Policy gate
     script:
       - pip install "git+https://github.com/StackGuardian/tirith.git@1.2.0"
-      # - tirith lint .tirith/policies   # in dev, not in 1.2.0
+      # - tirith lint .tirith/policies   # not in 1.2.0 yet, on main
       - tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error`,
   },
   {
@@ -271,7 +312,7 @@ tirith:
     to: '/docs/tirith-usage/ci-integration/',
     linkLabel: 'Jenkins and other runners',
     code: `pip install "git+https://github.com/StackGuardian/tirith.git@1.2.0"
-# tirith lint .tirith/policies   # in dev, not in 1.2.0
+# tirith lint .tirith/policies   # not in 1.2.0 yet, on main
 tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error`,
   },
   {
@@ -320,7 +361,7 @@ steps:
   needs: [terraform-plan]
   script:
     - pip install "git+https://github.com/StackGuardian/tirith.git@1.2.0"
-    # - tirith lint .tirith/policies   # in dev, not in 1.2.0
+    # - tirith lint .tirith/policies   # not in 1.2.0 yet, on main
     - tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error`,
   },
   {
@@ -333,7 +374,7 @@ steps:
     name: Policy gate
     script:
       - pip install "git+https://github.com/StackGuardian/tirith.git@1.2.0"
-      # - tirith lint .tirith/policies   # in dev, not in 1.2.0
+      # - tirith lint .tirith/policies   # not in 1.2.0 yet, on main
       - tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error`,
   },
 ];
@@ -346,18 +387,17 @@ export const INTEGRATIONS = [
   {
     glyph: '⎇',
     title: 'pre-commit',
-    // Needs .pre-commit-hooks.yaml, which is not in this repository, and `tirith lint`,
-    // which is not in the released CLI.
-    inDev: true,
-    body: 'A tirith-lint hook that will run when a policy file changes. Offline, no plan needed, and it catches the mistakes that read as real violations.',
-    to: '/docs/tirith-usage/editor-and-local/',
+    // .pre-commit-hooks.yaml publishes both ids, and `tirith lint` / `tirith fmt` are on
+    // main. Not in 1.2.0, so the documented `rev` is a branch until the next release.
+    body: 'tirith-lint and tirith-fmt hooks that run on the policy files a commit touches. Offline, no plan needed, and they catch the mistakes that read as real violations.',
+    to: '/docs/tirith-usage/lint-and-fmt/',
   },
   {
     glyph: '{}',
     title: 'Your editor',
-    // Needs .vscode/tasks.json, which is not in this repository, and the same lint command.
-    inDev: true,
-    body: 'VS Code tasks that will lint and evaluate in one keystroke — the loop to use while an agent is drafting the policy for you.',
+    // The tasks are given inline on the docs page; .vscode/tasks.json is not shipped from
+    // this repository, so the page no longer links to it as though it were.
+    body: 'VS Code tasks that lint and evaluate in one keystroke, the loop to use while an agent is drafting the policy for you.',
     to: '/docs/tirith-usage/editor-and-local/',
   },
 ];
diff --git a/documentation/src/data/lessons.js b/documentation/src/data/lessons.js
index b585e83c..5c3793e6 100644
--- a/documentation/src/data/lessons.js
+++ b/documentation/src/data/lessons.js
@@ -315,4 +315,965 @@ export const LESSONS = [
 ];
 
 /** What the Playground opens on. */
-export const PLAYGROUND_START = LESSONS[LESSONS.length - 1].policy;
+
+/* ── stackguardian/terraform_plan ─────────────────────────────────────────────
+ *
+ * A different provider, deliberately taught after the json track rather than
+ * instead of it: the conditions and eval_expression are already understood by
+ * this point, so these lessons only have to teach what actually changes, which
+ * is how the provider finds a value in the first place.
+ *
+ * The plan below is a real `terraform show -json` shape, trimmed to the keys the
+ * provider reads. Every verdict on this page is computed from it in the browser.
+ */
+
+export const PLAN_DOC = `{
+  "format_version": "1.2",
+  "terraform_version": "1.9.5",
+  "resource_changes": [
+    {
+      "address": "aws_db_instance.orders",
+      "type": "aws_db_instance",
+      "name": "orders",
+      "change": {
+        "actions": ["update"],
+        "after": {
+          "identifier": "orders",
+          "storage_encrypted": true,
+          "tags": {"Owner": "data"}
+        }
+      }
+    },
+    {
+      "address": "aws_s3_bucket.assets",
+      "type": "aws_s3_bucket",
+      "name": "assets",
+      "change": {
+        "actions": ["create"],
+        "after": {
+          "bucket": "acme-assets",
+          "acl": "private",
+          "tags": {"Owner": "platform"}
+        }
+      }
+    },
+    {
+      "address": "aws_s3_bucket.logs",
+      "type": "aws_s3_bucket",
+      "name": "logs",
+      "change": {
+        "actions": ["create"],
+        "after": {
+          "bucket": "acme-logs",
+          "acl": "public-read",
+          "tags": {"Owner": "platform"}
+        }
+      }
+    },
+    {
+      "address": "aws_instance.runner",
+      "type": "aws_instance",
+      "name": "runner",
+      "change": {
+        "actions": ["update"],
+        "after": {
+          "instance_type": "t3.large",
+          "tags": {"Owner": "ci"},
+          "ebs_block_device": [
+            {"device_name": "/dev/sdf", "encrypted": true},
+            {"device_name": "/dev/sdg", "encrypted": false}
+          ]
+        }
+      }
+    }
+  ]
+}`;
+
+export const TF_LESSONS = [
+  {
+    id: 'tf-shell',
+    n: '01',
+    title: 'A policy is a document',
+    teaches: 'meta · evaluators · eval_expression',
+    body:
+      'Every Tirith policy has the same three parts. `meta` names the provider that will read ' +
+      'your input. `evaluators` is the list of checks. `eval_expression` says how their ' +
+      'results combine into one verdict. Nothing here is a program: it is a description of ' +
+      'what to look for in a plan you are about to apply.',
+    aside:
+      'A plan is not a tree you walk, so there is no path here. It is a list of resource ' +
+      'changes, and you address one by naming a **resource type** and an **attribute on it**. ' +
+      'One database matches, so there is one result.',
+    tryIt: 'Change the value to `false`. The verdict, the message and the exit code all move together.',
+    policy: `{
+  "meta": {
+    "version": "v1",
+    "required_provider": "stackguardian/terraform_plan"
+  },
+  "evaluators": [
+    {
+      "id": "db_encrypted",
+      "provider_args": {
+        "operation_type": "attribute",
+        "terraform_resource_type": "aws_db_instance",
+        "terraform_resource_attribute": "storage_encrypted"
+      },
+      "condition": {
+        "type": "Equals",
+        "value": true
+      }
+    }
+  ],
+  "eval_expression": "db_encrypted"
+}`,
+  },
+  {
+    id: 'tf-many',
+    n: '02',
+    title: 'One check, every matching resource',
+    teaches: 'many results · every one must pass',
+    body:
+      'Name a type that matches more than one resource and the check runs against each of ' +
+      'them separately. The evaluator passes only if **every** result passes, so you write ' +
+      'the rule once and it covers the bucket somebody adds next month without you editing ' +
+      'anything.',
+    aside:
+      'Two buckets, two results, and the failing one names the value that failed rather than ' +
+      'just the rule. That is the difference between a report you can act on and a red cross.',
+    tryIt: 'Change `acl` on `aws_s3_bucket.logs` to `private` and the whole plan passes.',
+    policy: `{
+  "meta": {
+    "version": "v1",
+    "required_provider": "stackguardian/terraform_plan"
+  },
+  "evaluators": [
+    {
+      "id": "buckets_private",
+      "provider_args": {
+        "operation_type": "attribute",
+        "terraform_resource_type": "aws_s3_bucket",
+        "terraform_resource_attribute": "acl"
+      },
+      "condition": {
+        "type": "Equals",
+        "value": "private"
+      }
+    }
+  ],
+  "eval_expression": "buckets_private"
+}`,
+  },
+  {
+    id: 'tf-every',
+    n: '03',
+    title: 'Every resource in the plan',
+    teaches: '"*" · exclude_resource_types',
+    body:
+      '`"*"` as the resource type means every resource the plan touches, whatever it is. This ' +
+      'is how you write the rules that are actually organisational policy rather than ' +
+      'service-specific: everything must be owned, everything must be tagged, nothing may be ' +
+      'created outside a region. `exclude_resource_types` carves out the ones that genuinely ' +
+      'cannot comply.',
+    aside:
+      'Four resources, four results, in plan order. Note what the printed report does *not* ' +
+      'say: results are numbered, not named, so `4. FAILED` means the fourth resource rather ' +
+      'than `aws_instance.runner`. The address is carried in the `--json` output under `meta` ' +
+      'and shown by `tirith ui`; putting it in the printed message is on the roadmap and has ' +
+      'not shipped.',
+    tryIt: 'Change one resource’s `Owner` tag to `""`, then count down the list to find which resource line 4 is.',
+    policy: `{
+  "meta": {
+    "version": "v1",
+    "required_provider": "stackguardian/terraform_plan"
+  },
+  "evaluators": [
+    {
+      "id": "everything_owned",
+      "provider_args": {
+        "operation_type": "attribute",
+        "terraform_resource_type": "*",
+        "terraform_resource_attribute": "tags.Owner",
+        "exclude_resource_types": ["aws_iam_policy_document"]
+      },
+      "condition": {
+        "type": "IsNotEmpty"
+      }
+    }
+  ],
+  "eval_expression": "everything_owned"
+}`,
+  },
+  {
+    id: 'tf-combine',
+    n: '04',
+    title: 'Combining checks',
+    teaches: '&& · || · grouping',
+    body:
+      'Each evaluator has an `id`, and `eval_expression` combines those ids with `&&`, `||`, ' +
+      '`!` and parentheses. That is the whole grammar. The evaluators do not know about each ' +
+      'other; the expression is the only place their results meet.',
+    aside:
+      'Both checks run whatever the expression says, so you always see every result. The ' +
+      'expression decides the single verdict at the end, and only that verdict reaches the ' +
+      'exit code.',
+    tryIt: 'Swap `&&` for `||`. One passing check is now enough to carry the whole policy, which is usually not what you want.',
+    policy: `{
+  "meta": {
+    "version": "v1",
+    "required_provider": "stackguardian/terraform_plan"
+  },
+  "evaluators": [
+    {
+      "id": "buckets_private",
+      "provider_args": {
+        "operation_type": "attribute",
+        "terraform_resource_type": "aws_s3_bucket",
+        "terraform_resource_attribute": "acl"
+      },
+      "condition": {
+        "type": "Equals",
+        "value": "private"
+      }
+    },
+    {
+      "id": "db_encrypted",
+      "provider_args": {
+        "operation_type": "attribute",
+        "terraform_resource_type": "aws_db_instance",
+        "terraform_resource_attribute": "storage_encrypted"
+      },
+      "condition": {
+        "type": "Equals",
+        "value": true
+      }
+    }
+  ],
+  "eval_expression": "buckets_private && db_encrypted"
+}`,
+  },
+  {
+    id: 'tf-action',
+    n: '05',
+    title: 'Gate the change, not the value',
+    teaches: 'operation_type: action',
+    body:
+      'This is the operation with no equivalent in a document, and it is the reason a plan is ' +
+      'worth reading at all. `action` does not ask what a resource *is*. It asks what ' +
+      'Terraform is **about to do to it**: `create`, `update`, `delete`, `no-op`. A policy ' +
+      'over actions gates the change itself, which is the only moment the damage is still ' +
+      'preventable.',
+    aside:
+      'This is the shape of "no pull request may destroy a database". A resource can carry ' +
+      'more than one action, and every one of them is checked, so a replacement, which ' +
+      'terraform reports as delete then create, cannot slip past a rule written about ' +
+      'creation.',
+    tryIt: 'Change an `actions` array to `["delete"]` and watch a green plan turn red.',
+    policy: `{
+  "meta": {
+    "version": "v1",
+    "required_provider": "stackguardian/terraform_plan"
+  },
+  "evaluators": [
+    {
+      "id": "nothing_destroyed",
+      "provider_args": {
+        "operation_type": "action",
+        "terraform_resource_type": "*"
+      },
+      "condition": {
+        "type": "NotEquals",
+        "value": "delete"
+      }
+    }
+  ],
+  "eval_expression": "nothing_destroyed"
+}`,
+  },
+  {
+    id: 'tf-nested',
+    n: '06',
+    title: 'Reaching inside a resource',
+    teaches: 'nested attributes · one result per element',
+    body:
+      'Real resources are not flat. A block that repeats, like `ebs_block_device`, arrives as ' +
+      'a list, and `.*.` walks into it. Each element becomes its **own result**, so one ' +
+      'attached volume that is unencrypted fails the check even though its neighbour on the ' +
+      'same instance is fine.',
+    aside:
+      'Remember this shape. The Kubernetes provider spells its wildcard the same way and ' +
+      'means something different by it, which is the subject of the last lesson in that ' +
+      'track and the easiest way on this whole site to write a policy that gates nothing.',
+    tryIt: 'Set both `encrypted` values to `true`. Then delete the `.*.` and see the attribute stop resolving.',
+    policy: `{
+  "meta": {
+    "version": "v1",
+    "required_provider": "stackguardian/terraform_plan"
+  },
+  "evaluators": [
+    {
+      "id": "volumes_encrypted",
+      "provider_args": {
+        "operation_type": "attribute",
+        "terraform_resource_type": "aws_instance",
+        "terraform_resource_attribute": "ebs_block_device.*.encrypted"
+      },
+      "condition": {
+        "type": "Equals",
+        "value": true
+      }
+    }
+  ],
+  "eval_expression": "volumes_encrypted"
+}`,
+  },
+  {
+    id: 'tf-tolerance',
+    n: '07',
+    title: 'When the attribute is not there',
+    teaches: 'error_tolerance · a skip is not a pass',
+    body:
+      'Nothing in this plan sets `server_side_encryption`, so the provider cannot produce a ' +
+      'value to judge. That is not a violation, and it is not compliance either. Tirith ' +
+      'reports it as an **error with a severity**, and `error_tolerance` decides whether that ' +
+      'error fails the check or skips it.',
+    aside:
+      'Severity 1 means the resource type is not in the plan. Severity 2 means the resource ' +
+      'is there but the attribute is not. `severity > tolerance` fails, anything else skips. ' +
+      'With every check skipped the verdict is neither true nor false, and the exit code is ' +
+      '`1`, not `0`: a policy that evaluated nothing must never look like a policy that ' +
+      'passed.',
+    tryIt: 'Drop `error_tolerance` to `1`. The skip becomes a failure and the exit code changes from 1 to 3.',
+    policy: `{
+  "meta": {
+    "version": "v1",
+    "required_provider": "stackguardian/terraform_plan"
+  },
+  "evaluators": [
+    {
+      "id": "bucket_encryption",
+      "provider_args": {
+        "operation_type": "attribute",
+        "terraform_resource_type": "aws_s3_bucket",
+        "terraform_resource_attribute": "server_side_encryption"
+      },
+      "condition": {
+        "type": "Equals",
+        "value": "AES256",
+        "error_tolerance": 2
+      }
+    }
+  ],
+  "eval_expression": "bucket_encryption"
+}`,
+  },
+  {
+    id: 'tf-count',
+    n: '08',
+    title: 'Zero is an answer',
+    teaches: 'operation_type: count · and the error that does not happen',
+    body:
+      '`count` returns one number: how many resources of a type this change touches. The ' +
+      'detail worth knowing is what it does **not** do. Every other operation reports an ' +
+      'error when the resource type is absent from the plan, and that error can fail your ' +
+      'check. `count` reports `0`, because zero of something is a real answer and usually ' +
+      'the one you are gating on.',
+    aside:
+      'Two buckets, so this fails. The same policy against a plan with no buckets at all ' +
+      'returns `0` and passes, with no error and no skip. Worth knowing before you reach for ' +
+      '`count` as a safety net: it cannot tell you that it looked and found nothing.',
+    tryIt: 'Raise the value to `2` and it passes. Then delete both buckets from the plan: still passing, on `0`.',
+    policy: `{
+  "meta": {
+    "version": "v1",
+    "required_provider": "stackguardian/terraform_plan"
+  },
+  "evaluators": [
+    {
+      "id": "bucket_budget",
+      "provider_args": {
+        "operation_type": "count",
+        "terraform_resource_type": "aws_s3_bucket"
+      },
+      "condition": {
+        "type": "LessThanEqualTo",
+        "value": 1
+      }
+    }
+  ],
+  "eval_expression": "bucket_budget"
+}`,
+  },
+  {
+    id: 'tf-together',
+    n: '09',
+    title: 'The whole thing',
+    teaches: 'a policy you would actually commit',
+    body:
+      'Four rules over one plan: everything is owned, buckets are private, attached volumes ' +
+      'are encrypted, and nothing is destroyed. This is the size of a real starting policy, ' +
+      'and it is the file you would put in `.tirith/policies` and point a pipeline at.',
+    aside:
+      'The report tells you which rule refused and which value refused it, which is what ' +
+      'makes a red build actionable rather than a thing to re-run. Under `--fail-on-error` ' +
+      'this exits `3`, and the pipeline stops before `apply`.',
+    tryIt: 'Fix the plan until it passes: `logs` to private, and the second volume encrypted. The exit code goes to 0.',
+    policy: `{
+  "meta": {
+    "version": "v1",
+    "required_provider": "stackguardian/terraform_plan"
+  },
+  "evaluators": [
+    {
+      "id": "everything_owned",
+      "provider_args": {
+        "operation_type": "attribute",
+        "terraform_resource_type": "*",
+        "terraform_resource_attribute": "tags.Owner"
+      },
+      "condition": {
+        "type": "IsNotEmpty"
+      }
+    },
+    {
+      "id": "buckets_private",
+      "provider_args": {
+        "operation_type": "attribute",
+        "terraform_resource_type": "aws_s3_bucket",
+        "terraform_resource_attribute": "acl"
+      },
+      "condition": {
+        "type": "Equals",
+        "value": "private"
+      }
+    },
+    {
+      "id": "volumes_encrypted",
+      "provider_args": {
+        "operation_type": "attribute",
+        "terraform_resource_type": "aws_instance",
+        "terraform_resource_attribute": "ebs_block_device.*.encrypted"
+      },
+      "condition": {
+        "type": "Equals",
+        "value": true
+      }
+    },
+    {
+      "id": "nothing_destroyed",
+      "provider_args": {
+        "operation_type": "action",
+        "terraform_resource_type": "*"
+      },
+      "condition": {
+        "type": "NotEquals",
+        "value": "delete"
+      }
+    }
+  ],
+  "eval_expression": "everything_owned && buckets_private && volumes_encrypted && nothing_destroyed"
+}`,
+  },
+];
+
+/* ── stackguardian/kubernetes ─────────────────────────────────────────────────
+ *
+ * The CLI reads a multi-document YAML file and hands the provider a list of
+ * manifests. The playground parses JSON, so the same manifests are written as a
+ * JSON array here. Nothing else differs: the provider iterates a list either way.
+ */
+
+export const K8S_DOC = `[
+  {
+    "apiVersion": "apps/v1",
+    "kind": "Deployment",
+    "metadata": {"name": "api", "namespace": "production"},
+    "spec": {
+      "replicas": 3,
+      "template": {
+        "spec": {
+          "containers": [
+            {
+              "name": "api",
+              "image": "ghcr.io/acme/api:1.4.2",
+              "resources": {"limits": {"cpu": "500m", "memory": "512Mi"}},
+              "securityContext": {"runAsNonRoot": true, "allowPrivilegeEscalation": false},
+              "readinessProbe": {"httpGet": {"path": "/healthz", "port": 8080}}
+            }
+          ]
+        }
+      }
+    }
+  },
+  {
+    "apiVersion": "apps/v1",
+    "kind": "Deployment",
+    "metadata": {"name": "worker", "namespace": "production"},
+    "spec": {
+      "replicas": 1,
+      "template": {
+        "spec": {
+          "containers": [
+            {
+              "name": "worker",
+              "image": "docker.io/library/redis:latest",
+              "securityContext": {"runAsNonRoot": false}
+            }
+          ]
+        }
+      }
+    }
+  },
+  {
+    "apiVersion": "v1",
+    "kind": "Service",
+    "metadata": {"name": "api", "namespace": "production"},
+    "spec": {"type": "ClusterIP", "ports": [{"port": 80, "targetPort": 8080}]}
+  }
+]`;
+
+export const K8S_LESSONS = [
+  {
+    id: 'k8s-kind',
+    n: '01',
+    title: 'Pick a kind, then a path',
+    teaches: 'kubernetes_kind · attribute_path',
+    body:
+      'Kubernetes input is a list of manifests, so the provider needs two things: which `kind` ' +
+      'to look at, and where inside it to look. Manifests of other kinds are ignored rather ' +
+      'than failed, which is what lets one policy run against a whole directory of YAML. The ' +
+      '`Service` in this document is simply not consulted.',
+    aside:
+      'Two Deployments match, so there are two results. `api` is fine and `worker` is not, ' +
+      'which is the pattern for this whole track: one bad Deployment, and each lesson catches ' +
+      'a different thing wrong with it.',
+    tryIt:
+      'Give `worker` 2 replicas and the check passes. Then change the kind to `Service`: it has no ' +
+      '`spec.replicas`, so the value is null and the failure you get is about comparing types, not ' +
+      'about a missing path.',
+    policy: `{
+  "meta": {
+    "version": "v1",
+    "required_provider": "stackguardian/kubernetes"
+  },
+  "evaluators": [
+    {
+      "id": "not_a_single_point",
+      "provider_args": {
+        "operation_type": "attribute",
+        "kubernetes_kind": "Deployment",
+        "attribute_path": "spec.replicas"
+      },
+      "condition": {
+        "type": "GreaterThanEqualTo",
+        "value": 2
+      }
+    }
+  ],
+  "eval_expression": "not_a_single_point"
+}`,
+  },
+  {
+    id: 'k8s-two-kinds',
+    n: '02',
+    title: 'One policy, two kinds',
+    teaches: 'several evaluators · && · || · grouping',
+    body:
+      'One evaluator reads one kind, so a policy that spans Deployments and Services is two ' +
+      'evaluators and an expression. Each has an `id`, and `eval_expression` combines those ' +
+      'ids with `&&`, `||`, `!` and parentheses. That is the whole grammar; the evaluators ' +
+      'never see each other.',
+    aside:
+      'Both checks run whatever the expression says, so you always see every result: the ' +
+      'Service passes, a Deployment does not, and the expression turns those into one verdict ' +
+      'at the end. Only that verdict reaches the exit code.',
+    tryIt: 'Swap `&&` for `||`. The passing Service now carries the whole policy, which is almost never what you meant.',
+    policy: `{
+  "meta": {
+    "version": "v1",
+    "required_provider": "stackguardian/kubernetes"
+  },
+  "evaluators": [
+    {
+      "id": "not_a_single_point",
+      "provider_args": {
+        "operation_type": "attribute",
+        "kubernetes_kind": "Deployment",
+        "attribute_path": "spec.replicas"
+      },
+      "condition": {
+        "type": "GreaterThanEqualTo",
+        "value": 2
+      }
+    },
+    {
+      "id": "not_exposed",
+      "provider_args": {
+        "operation_type": "attribute",
+        "kubernetes_kind": "Service",
+        "attribute_path": "spec.type"
+      },
+      "condition": {
+        "type": "NotEquals",
+        "value": "LoadBalancer"
+      }
+    }
+  ],
+  "eval_expression": "not_a_single_point && not_exposed"
+}`,
+  },
+  {
+    id: 'k8s-registry',
+    n: '03',
+    title: 'Where the image came from',
+    teaches: 'indexing into containers · RegexMatch',
+    body:
+      'Containers are a list, so reaching one means indexing it: `containers.0` is the first ' +
+      'container in the pod template. `RegexMatch` then does what an allowlist needs, which is ' +
+      'to say where an image may come from rather than enumerating every image you have ever ' +
+      'approved.',
+    aside:
+      'An unpinned public image is the supply-chain problem stated plainly: `worker` pulls ' +
+      'from Docker Hub, so anything that lands under that name lands in your cluster. The ' +
+      'regex is anchored with `^` on purpose; without it, `ghcr.io/acme/` would match anywhere ' +
+      'in the string and `evil.example.com/ghcr.io/acme/x` would pass.',
+    tryIt: 'Point `worker` at `ghcr.io/acme/worker:0.9.0` and the check passes. Then drop the `^` and see what else it lets through.',
+    policy: `{
+  "meta": {
+    "version": "v1",
+    "required_provider": "stackguardian/kubernetes"
+  },
+  "evaluators": [
+    {
+      "id": "approved_registry",
+      "provider_args": {
+        "operation_type": "attribute",
+        "kubernetes_kind": "Deployment",
+        "attribute_path": "spec.template.spec.containers.0.image"
+      },
+      "condition": {
+        "type": "RegexMatch",
+        "value": "^ghcr\\\\.io/acme/"
+      }
+    }
+  ],
+  "eval_expression": "approved_registry"
+}`,
+  },
+  {
+    id: 'k8s-wildcard',
+    n: '04',
+    title: 'The same star, a different meaning',
+    teaches: 'why a passing policy can still be wrong',
+    body:
+      'Put `*` in a Kubernetes `attribute_path` and you do **not** get one value per match. ' +
+      'You get a single value that is the whole list. That matters because `Contains` on a ' +
+      'list is membership, not substring: `":latest"` is not an element of ' +
+      '`["docker.io/library/redis:latest"]`, so the check below **passes** while an image ' +
+      'really is pinned to `latest`. A green policy that gates nothing.',
+    aside:
+      'Terraform’s `.*.` does the opposite: it emits one result per element, which is why the ' +
+      'same instinct works there and fails here. Naming a container fixes it, at the cost of ' +
+      'only checking that one. This is why "the check is green" and "the check is working" ' +
+      'are different claims, and why the last lesson here evaluates a policy against a ' +
+      'manifest that should fail it.',
+    tryIt: 'Change `containers.*.image` to `containers.0.image`. The verdict flips to failed and names the image.',
+    policy: `{
+  "meta": {
+    "version": "v1",
+    "required_provider": "stackguardian/kubernetes"
+  },
+  "evaluators": [
+    {
+      "id": "no_latest_tag",
+      "provider_args": {
+        "operation_type": "attribute",
+        "kubernetes_kind": "Deployment",
+        "attribute_path": "spec.template.spec.containers.*.image"
+      },
+      "condition": {
+        "type": "NotContains",
+        "value": ":latest"
+      }
+    }
+  ],
+  "eval_expression": "no_latest_tag"
+}`,
+  },
+  {
+    id: 'k8s-limits',
+    n: '05',
+    title: 'A missing path is not an error here',
+    teaches: 'resource limits · null instead of a severity',
+    body:
+      'A container with no memory limit can take the node down and everything scheduled on it ' +
+      'with it, so this is the check most clusters want first. It also shows the sharpest ' +
+      'difference between this provider and the Terraform one: `worker` has no `resources` at ' +
+      'all, and instead of an error with a severity, the provider returns **null** and the ' +
+      'condition judges that.',
+    aside:
+      'That distinction decides what `error_tolerance` can do for you. In a plan, a missing ' +
+      'attribute is severity 2 and can be tolerated into a skip. Here it is an ordinary value ' +
+      'that fails an `IsNotEmpty`, and no tolerance setting will turn it into a skip. Which is ' +
+      'the safer default: absence is a failure unless you say otherwise.',
+    tryIt: 'Add `"resources": {"limits": {"memory": "256Mi"}}` to the worker container and the policy passes.',
+    policy: `{
+  "meta": {
+    "version": "v1",
+    "required_provider": "stackguardian/kubernetes"
+  },
+  "evaluators": [
+    {
+      "id": "memory_limited",
+      "provider_args": {
+        "operation_type": "attribute",
+        "kubernetes_kind": "Deployment",
+        "attribute_path": "spec.template.spec.containers.0.resources.limits.memory"
+      },
+      "condition": {
+        "type": "IsNotEmpty"
+      }
+    }
+  ],
+  "eval_expression": "memory_limited"
+}`,
+  },
+  {
+    id: 'k8s-security',
+    n: '06',
+    title: 'Running as root, by default',
+    teaches: 'securityContext · two rules over one container',
+    body:
+      'Kubernetes defaults are permissive: unless a manifest says otherwise, a container runs ' +
+      'as whatever user its image declares, which is very often root, and may escalate ' +
+      'privileges. These are the two settings that a cluster policy almost always pins, and ' +
+      'they are the rules that survive contact with an image you did not build.',
+    aside:
+      '`worker` sets `runAsNonRoot: false` explicitly, so it fails. A manifest that omits ' +
+      '`securityContext` entirely returns null and fails the same way, which is the behaviour ' +
+      'you want: a container is not non-root because nobody mentioned it.',
+    tryIt: 'Set `runAsNonRoot` to `true` on `worker`. It still fails, because that container has no `allowPrivilegeEscalation` at all.',
+    policy: `{
+  "meta": {
+    "version": "v1",
+    "required_provider": "stackguardian/kubernetes"
+  },
+  "evaluators": [
+    {
+      "id": "non_root",
+      "provider_args": {
+        "operation_type": "attribute",
+        "kubernetes_kind": "Deployment",
+        "attribute_path": "spec.template.spec.containers.0.securityContext.runAsNonRoot"
+      },
+      "condition": {
+        "type": "Equals",
+        "value": true
+      }
+    },
+    {
+      "id": "no_escalation",
+      "provider_args": {
+        "operation_type": "attribute",
+        "kubernetes_kind": "Deployment",
+        "attribute_path": "spec.template.spec.containers.0.securityContext.allowPrivilegeEscalation"
+      },
+      "condition": {
+        "type": "Equals",
+        "value": false
+      }
+    }
+  ],
+  "eval_expression": "non_root && no_escalation"
+}`,
+  },
+  {
+    id: 'k8s-tolerance',
+    n: '07',
+    title: 'When the kind is not there',
+    teaches: 'error_tolerance · a skip is not a pass',
+    body:
+      'A missing *path* returns null here, but a missing **kind** is different: the provider ' +
+      'cannot look at anything at all, so it reports an error with severity 1. This document ' +
+      'has no `Ingress`, and that is the situation `error_tolerance` exists for. One policy ' +
+      'set runs across many repositories, and not every repository has every kind.',
+    aside:
+      '`severity > tolerance` fails, anything else skips. At tolerance 1 the check is skipped, ' +
+      'and because it is the only check, the verdict is neither true nor false and the exit ' +
+      'code is `1` rather than `0`. A policy that evaluated nothing must never look like a ' +
+      'policy that passed.',
+    tryIt: 'Drop `error_tolerance` to `0`. The skip becomes a failure and the exit code changes from 1 to 3.',
+    policy: `{
+  "meta": {
+    "version": "v1",
+    "required_provider": "stackguardian/kubernetes"
+  },
+  "evaluators": [
+    {
+      "id": "ingress_is_tls",
+      "provider_args": {
+        "operation_type": "attribute",
+        "kubernetes_kind": "Ingress",
+        "attribute_path": "spec.tls"
+      },
+      "condition": {
+        "type": "IsNotEmpty",
+        "error_tolerance": 1
+      }
+    }
+  ],
+  "eval_expression": "ingress_is_tls"
+}`,
+  },
+  {
+    id: 'k8s-together',
+    n: '08',
+    title: 'The whole thing',
+    teaches: 'a policy you would actually commit',
+    body:
+      'Five rules over one directory of manifests: every Deployment is replicated, pulls from ' +
+      'an approved registry, pins a memory limit, runs as non-root, and no Service is exposed ' +
+      'to the internet. This is the size of a real starting policy, and it is the file you ' +
+      'would put in `.tirith/policies` and point at `kubectl kustomize` output.',
+    aside:
+      'The report names the rule that refused and the value that refused it, so a red build ' +
+      'tells you which manifest to open. Under `--fail-on-error` this exits `3`, before ' +
+      '`kubectl apply` ever runs.',
+    tryIt: 'Fix `worker` until it passes: 2 replicas, a ghcr.io image, a memory limit, and runAsNonRoot true.',
+    policy: `{
+  "meta": {
+    "version": "v1",
+    "required_provider": "stackguardian/kubernetes"
+  },
+  "evaluators": [
+    {
+      "id": "not_a_single_point",
+      "provider_args": {
+        "operation_type": "attribute",
+        "kubernetes_kind": "Deployment",
+        "attribute_path": "spec.replicas"
+      },
+      "condition": {
+        "type": "GreaterThanEqualTo",
+        "value": 2
+      }
+    },
+    {
+      "id": "approved_registry",
+      "provider_args": {
+        "operation_type": "attribute",
+        "kubernetes_kind": "Deployment",
+        "attribute_path": "spec.template.spec.containers.0.image"
+      },
+      "condition": {
+        "type": "RegexMatch",
+        "value": "^ghcr\\\\.io/acme/"
+      }
+    },
+    {
+      "id": "memory_limited",
+      "provider_args": {
+        "operation_type": "attribute",
+        "kubernetes_kind": "Deployment",
+        "attribute_path": "spec.template.spec.containers.0.resources.limits.memory"
+      },
+      "condition": {
+        "type": "IsNotEmpty"
+      }
+    },
+    {
+      "id": "non_root",
+      "provider_args": {
+        "operation_type": "attribute",
+        "kubernetes_kind": "Deployment",
+        "attribute_path": "spec.template.spec.containers.0.securityContext.runAsNonRoot"
+      },
+      "condition": {
+        "type": "Equals",
+        "value": true
+      }
+    },
+    {
+      "id": "not_exposed",
+      "provider_args": {
+        "operation_type": "attribute",
+        "kubernetes_kind": "Service",
+        "attribute_path": "spec.type"
+      },
+      "condition": {
+        "type": "NotEquals",
+        "value": "LoadBalancer"
+      }
+    }
+  ],
+  "eval_expression": "not_a_single_point && approved_registry && memory_limited && non_root && not_exposed"
+}`,
+  },
+];
+
+/**
+ * The page, as tracks.
+ *
+ * Each track is a provider, its own input document, and the lessons that run
+ * against it. The numbering is continuous across the page rather than restarting
+ * per track, which is the section grammar the rest of the site uses.
+ */
+export const TRACKS = [
+  {
+    id: 'terraform',
+    provider: 'stackguardian/terraform_plan',
+    tab: 'Terraform plan',
+    title: 'An OpenTofu or Terraform plan',
+    lede:
+      'The provider Tirith exists for, and the one to learn on. Nine lessons that build one ' +
+      'policy a rule at a time, against a plan of the shape your pipeline already produces ' +
+      'with `terraform show -json`.',
+    /*
+     * What the track teaches, for the reader deciding which one to open. Deliberately not a
+     * summary of the lessons: it is the reason to pick this track over the other two.
+     */
+    forYou: 'Start here. The syntax you learn on a plan is the same syntax everywhere else.',
+    input: PLAN_DOC,
+    lessons: TF_LESSONS,
+    /*
+     * The playground opens on the track's most representative *correct* policy, which is not
+     * always its last lesson. The Kubernetes track ends on a policy that deliberately passes
+     * while being wrong, and seeding an empty-canvas playground with that would be handing
+     * the reader the trap with none of the explanation attached.
+     */
+    playground: TF_LESSONS[TF_LESSONS.length - 1].policy,
+  },
+  {
+    id: 'kubernetes',
+    provider: 'stackguardian/kubernetes',
+    tab: 'Kubernetes',
+    title: 'Kubernetes manifests',
+    lede:
+      'A list of manifests rather than one document. Eight lessons over two Deployments and a ' +
+      'Service, one of which is a mess: replicas, registries, limits, `securityContext`, and ' +
+      'the wildcard that behaves the opposite way to the one in the Terraform track.',
+    forYou: 'Eight lessons. Lesson 04 is the most useful mistake on this site.',
+    input: K8S_DOC,
+    lessons: K8S_LESSONS,
+    playground: K8S_LESSONS[0].policy,
+  },
+  {
+    id: 'json',
+    provider: 'stackguardian/json',
+    tab: 'JSON or YAML',
+    title: 'Any JSON or YAML document',
+    lede:
+      'The provider that reads anything with keys and values, addressed by key path. Useful ' +
+      'for the documents nobody wrote a provider for: a lockfile, an SBOM, a config file, ' +
+      'the response from one of your own APIs.',
+    forYou: 'Six lessons. Come here when the thing you need to gate is not IaC at all.',
+    input: INPUT_DOC,
+    lessons: LESSONS,
+    playground: LESSONS[LESSONS.length - 1].policy,
+  },
+];
+
+/*
+ * Superseded by each track's own `playground` seed, and kept only long enough to say so:
+ * nothing imports this. Delete it on the next pass through this file.
+ */
+export const PLAYGROUND_START = TRACKS[0].playground;
diff --git a/documentation/src/data/roadmap.js b/documentation/src/data/roadmap.js
index 44aae740..defd52e7 100644
--- a/documentation/src/data/roadmap.js
+++ b/documentation/src/data/roadmap.js
@@ -8,8 +8,7 @@
  *
  * Status is two values and they mean different things.
  *
- *   inDev   there is code. `tirith lint` runs today outside the released package; the
- *           clearer result messages are an open pull request.
+ *   inDev   there is code, on `main` or in an open pull request, but not in a release.
  *   planned specified, sized, ordered, and not started. Most of this list.
  *
  * Nothing here is shipped. Anything shipped belongs in the documentation instead, and the
@@ -203,9 +202,9 @@ export const RELEASES = [
         title: 'Installed the way you install anything else',
         status: 'planned',
         body:
-          'Homebrew, a container image, a dev container feature, a pre-commit hook, and the ' +
-          'Action listed on the Marketplace. Today installation is a git URL, which is the ' +
-          'single most friction-heavy thing about starting.',
+          'Homebrew, a container image, a dev container feature, and the Action listed on ' +
+          'the Marketplace. Today installation is a git URL, which is the single most ' +
+          'friction-heavy thing about starting. The pre-commit hooks are already published.',
       },
     ],
   },
diff --git a/documentation/src/data/tirithLite.js b/documentation/src/data/tirithLite.js
index 7cdca9e2..48cc387c 100644
--- a/documentation/src/data/tirithLite.js
+++ b/documentation/src/data/tirithLite.js
@@ -5,8 +5,8 @@
  * WHAT THIS IS, PRECISELY
  *
  * This is NOT Tirith. Tirith is a Python package; this is a few hundred lines of
- * JavaScript that reproduces one provider and thirteen conditions closely enough
- * to teach the shape of a policy in a browser, with no install.
+ * JavaScript that reproduces three providers and thirteen conditions closely
+ * enough to teach the shape of a policy in a browser, with no install.
  *
  * It is written against the real thing and matches it where it matters:
  *   - result documents have the same shape as `tirith --json` (see
@@ -17,13 +17,28 @@
  *     passes only if every value passes" (docs/tirith-reference/evaluators.md);
  *   - `passed` and `final_result` are tri-state: true / false / null-for-skipped.
  *
- * KNOWN DIVERGENCES — say these out loud in the UI, never paper over them:
- *   - Only `stackguardian/json` is implemented. terraform_plan, kubernetes,
- *     infracost and sg_workflow are not.
+ * VERIFIED AGAINST THE ENGINE. Every policy on /learn was run through both this
+ * file and the installed Python package, and all eleven produce the same
+ * final_result and the same per-result outcomes. Re-run that comparison when you
+ * change anything below: a teaching engine that quietly disagrees with the real
+ * one is worse than no playground, because it is believed.
+ *
+ * KNOWN DIVERGENCES, say these out loud in the UI, never paper over them:
+ *   - `stackguardian/json`, `stackguardian/terraform_plan` and
+ *     `stackguardian/kubernetes` are implemented. infracost and sg_workflow are
+ *     not, and neither are the terraform_plan operations beyond attribute,
+ *     action and count: direct_references, direct_dependencies, provider_config
+ *     and terraform_version all run only in the package.
+ *   - Messages are formatted by this file's `fmt`, so a value is shown as JSON in
+ *     backticks where the Python sometimes shows a repr. Same verdict, different
+ *     punctuation.
  *   - `Equals` does not sort nested collections the way the Python does.
  *   - Regexes are JavaScript regexes, not Python's `re`.
- *   - Error severities are approximated: a missing key_path is severity 2, which
- *     is the one case the error_tolerance lesson needs.
+ *   - (Resolved.) This file used to differ from core.py on an evaluator whose
+ *     results mix failures and skips: the engine let a trailing skip overwrite an
+ *     earlier failure. The fix for issue #293 has since merged, core.py now
+ *     decides once at the end from two flags, and the two agree. Left recorded
+ *     because the divergence was deliberate while it lasted.
  *
  * The authoritative evaluator is always the installed package.
  * ─────────────────────────────────────────────────────────────────────────────
@@ -253,6 +268,196 @@ export function getValues(doc, keyPath) {
   return current;
 }
 
+/* ── providers ────────────────────────────────────────────────────────────────
+ *
+ * Three of the five that ship. Each returns the same shape the Python providers
+ * return, a list of outputs, so the evaluator loop below does not know or care
+ * which provider produced them:
+ *
+ *   {value, meta}                     a value to run the condition against
+ *   {err, severity, meta}             the provider could not produce one
+ *
+ * `severity` is the number `error_tolerance` is compared against, and the
+ * comparison is `severity > tolerance` fails, otherwise the check is skipped.
+ * The severities are copied from the handlers, not invented: they are the
+ * difference between "this resource type is not in your plan" and "this
+ * attribute is missing from a resource that is", and a lesson that got them
+ * wrong would teach the wrong error_tolerance.
+ */
+
+const NOT_FOUND = Symbol('not found');
+
+/** pydash.get: a dot path with numeric list indices, or NOT_FOUND. */
+function pget(data, path) {
+  let node = data;
+  for (const part of String(path).split('.')) {
+    if (isDict(node) && part in node) node = node[part];
+    else if (isList(node) && /^\d+$/.test(part) && node[Number(part)] !== undefined) node = node[Number(part)];
+    else return NOT_FOUND;
+  }
+  return node;
+}
+
+/**
+ * The `a.*.b` form of terraform_resource_attribute.
+ *
+ * Mirrors _get_exp_attribute in the handler, including the part that looks odd:
+ * every segment is resolved against the *original* attribute dictionary rather
+ * than against the previous segment's result. The list branch returns early, so
+ * that only shows up on paths that do not match, and reproducing it is the point
+ * of the playground.
+ */
+function expandAttribute(parts, data) {
+  const out = [];
+  for (let i = 0; i < parts.length; i += 1) {
+    const expr = parts[i];
+    const val = pget(data, expr);
+    if (isList(val) && i < parts.length - 1) {
+      for (const item of val) {
+        const sub = expandAttribute(parts.slice(i + 1), item);
+        if (sub.length) out.push(...sub);
+        // A list item without the attribute is still evaluated, as None, so a
+        // policy over a list cannot pass by the item simply being absent.
+        else out.push(null);
+      }
+      return out;
+    }
+    if (i === parts.length - 1 && val !== NOT_FOUND) {
+      out.push(val);
+    } else if (expr.endsWith('.*')) {
+      const base = pget(data, expr.slice(0, -2));
+      if (base !== NOT_FOUND && isList(base)) out.push(...base);
+    }
+  }
+  return out;
+}
+
+function jsonProvide(args, input) {
+  if (args.operation_type !== 'get_value') {
+    return [{err: `operation_type: ${args.operation_type} is not supported`, severity: 99}];
+  }
+  const values = getValues(input, args.key_path);
+  if (values.length === 0) {
+    return [{err: `key_path: \`${args.key_path}\` is not found`, severity: 2}];
+  }
+  return values.map((value) => ({value}));
+}
+
+function terraformProvide(args, input) {
+  const changes = input && input.resource_changes;
+  if (!isList(changes) || changes.length === 0) {
+    return [{err: 'No Terraform resources changes are found', severity: 0}];
+  }
+
+  const type = args.terraform_resource_type;
+  const exclude = args.exclude_resource_types || [];
+  // `*` means every resource, and is the only case exclude_resource_types applies
+  // to: naming a type explicitly and then excluding it is a contradiction the
+  // handler does not entertain.
+  const matches = (rc) => (type === '*' ? !exclude.includes(rc.type) : rc.type === type);
+
+  if (args.operation_type === 'attribute') {
+    const attribute = args.terraform_resource_attribute;
+    const out = [];
+    let resourceFound = false;
+    let attributeFound = false;
+
+    for (const rc of changes) {
+      if (!matches(rc)) continue;
+      resourceFound = true;
+      const after = rc.change && rc.change.after;
+      if (!after) {
+        out.push({err: `No Terraform changes found for resource type: '${type}'`, severity: 0});
+        continue;
+      }
+      let local = false;
+      if (attribute in after) {
+        attributeFound = true;
+        local = true;
+        out.push({value: after[attribute], meta: rc});
+      } else if (attribute.includes('.') || attribute.includes('*')) {
+        const vals = expandAttribute(attribute.split('.*.'), after);
+        if (vals.length) {
+          attributeFound = true;
+          local = true;
+          for (const v of vals) out.push({value: v, meta: rc});
+        }
+      }
+      if (!local) out.push({err: `attribute: '${attribute}' is not found`, severity: 2});
+    }
+
+    if (out.length === 0) {
+      if (!resourceFound) return [{err: `resource_type: '${type}' is not found`, severity: 1}];
+      if (!attributeFound) return [{err: `attribute: '${attribute}' is not found`, severity: 2}];
+    }
+    return out;
+  }
+
+  if (args.operation_type === 'action') {
+    const out = [];
+    let found = false;
+    for (const rc of changes) {
+      if (!matches(rc)) continue;
+      found = true;
+      for (const action of (rc.change && rc.change.actions) || []) out.push({value: action, meta: rc});
+    }
+    if (!found) out.push({err: `resource_type: '${type}' is not found`, severity: 1});
+    return out;
+  }
+
+  if (args.operation_type === 'count') {
+    // No "not found" error here, deliberately: zero of a resource is a real answer
+    // and often the one you are gating on.
+    let count = 0;
+    let meta = null;
+    for (const rc of changes) {
+      if (!matches(rc)) continue;
+      meta = rc;
+      count += 1;
+    }
+    return [{value: count, meta}];
+  }
+
+  return [{err: `operation_type: '${args.operation_type}' is not supported`, severity: 99}];
+}
+
+function kubernetesProvide(args, input) {
+  if (args.operation_type !== 'attribute') {
+    return [{err: `operation_type: ${args.operation_type} is not supported`, severity: 99}];
+  }
+  const kind = args.kubernetes_kind;
+  const path = args.attribute_path || '';
+  if (kind === undefined || kind === null) {
+    return [{err: 'kubernetes_kind must be provided', severity: 99}];
+  }
+  if (path === '') return [{err: 'attribute_path must be provided', severity: 99}];
+
+  // The CLI reads a multi-document YAML file and hands the provider a list. The
+  // playground parses JSON, so the same manifests arrive as a JSON array.
+  const resources = isList(input) ? input : [input];
+  const out = [];
+  let found = false;
+  for (const resource of resources) {
+    if (!isDict(resource) || resource.kind !== kind) continue;
+    found = true;
+    let values = getValues(resource, path);
+    // place_none_if_not_found: a manifest missing the attribute is evaluated as
+    // null rather than skipped, so it cannot pass by omission.
+    if (values.length === 0) values = [null];
+    out.push({value: path.includes('*') ? values : values[0], meta: resource});
+  }
+  if (!found) out.push({err: `kind: ${kind} is not found`, severity: 1});
+  return out;
+}
+
+export const PROVIDERS = {
+  'stackguardian/json': jsonProvide,
+  'stackguardian/terraform_plan': terraformProvide,
+  'stackguardian/kubernetes': kubernetesProvide,
+};
+
+export const PROVIDER_NAMES = Object.keys(PROVIDERS);
+
 /* ── eval_expression ──────────────────────────────────────────────────────────
  * Supports `&&`, `||`, `!` and parentheses over evaluator ids, which is the
  * grammar documented in docs/tirith-reference/eval-expressions.md.
@@ -355,12 +560,13 @@ export function evaluatePolicy(policyText, inputText) {
   }
 
   const meta = policy.meta || {};
-  const provider = meta.required_provider;
-  if (provider && provider !== 'stackguardian/json') {
+  const provider = meta.required_provider || 'stackguardian/json';
+  const provide = PROVIDERS[provider];
+  if (!provide) {
     return {
       document: null,
       fatal:
-        `this browser playground only implements stackguardian/json; ` +
+        `this browser playground implements ${PROVIDER_NAMES.join(', ')}; ` +
         `"${provider}" runs in the installed package.`,
     };
   }
@@ -381,31 +587,39 @@ export function evaluatePolicy(policyText, inputText) {
       passedById[id] = false;
       return {id, passed: false, description: ev.description ?? null, result: [{passed: false, message, meta: null}]};
     }
-    if (args.operation_type !== 'get_value') {
-      const message = `Unsupported operation type: ${args.operation_type}`;
-      passedById[id] = false;
-      return {id, passed: false, description: ev.description ?? null, result: [{passed: false, message, meta: null}]};
-    }
 
-    const values = getValues(input, args.key_path);
+    const outputs = provide(args, input);
 
-    if (values.length === 0) {
-      // Severity 2 in the real provider: skipped at error_tolerance >= 2.
-      const message = `key_path: \`${args.key_path}\` is not found`;
-      if (tolerance >= 2) {
-        passedById[id] = null;
-        return {id, passed: null, description: ev.description ?? null, result: [{passed: null, message, meta: null}]};
+    const result = outputs.map((o) => {
+      if (o.err !== undefined) {
+        // `severity > tolerance` fails, otherwise the check is skipped. Copied from
+        // core.py rather than reasoned about: the boundary case, severity equal to
+        // tolerance, is a skip, and getting it backwards would invert every lesson
+        // that teaches error_tolerance.
+        if (o.severity > tolerance) {
+          errors.push(o.err);
+          return {passed: false, message: o.err, meta: o.meta ?? null};
+        }
+        return {passed: null, message: o.err, meta: o.meta ?? null};
       }
-      errors.push(message);
-      passedById[id] = false;
-      return {id, passed: false, description: ev.description ?? null, result: [{passed: false, message, meta: null}]};
-    }
-
-    const result = values.map((v) => {
-      const r = fn(v, cond.value);
-      return {passed: r.passed, message: r.message, meta: null};
+      const r = fn(o.value, cond.value);
+      return {passed: r.passed, message: r.message, meta: o.meta ?? null};
     });
-    const passed = result.every((r) => r.passed);
+
+    /*
+     * Three-valued: any failure decides, a pass needs at least one real
+     * evaluation, and only an evaluator with nothing but skips is skipped.
+     *
+     * Written to the intended rule at a time when core.py disagreed, letting a
+     * trailing skip overwrite an earlier failure. The fix for issue #293 has
+     * since merged and the engine now decides the same way, from two flags at
+     * the end of its loop rather than inside the branch.
+     */
+    let passed;
+    if (result.some((r) => r.passed === false)) passed = false;
+    else if (result.some((r) => r.passed === true)) passed = true;
+    else passed = null;
+
     passedById[id] = passed;
     return {id, passed, description: ev.description ?? null, result};
   });
diff --git a/documentation/src/pages/at-scale.js b/documentation/src/pages/at-scale.js
index fbfdb664..cd8eedcb 100644
--- a/documentation/src/pages/at-scale.js
+++ b/documentation/src/pages/at-scale.js
@@ -208,6 +208,76 @@ const loop = {
   },
 };
 
+/*
+ * The estate-shaped version of the argument the landing page makes in section 02, and the
+ * one place on this site where the two products are load-bearing for each other.
+ *
+ * Everything asserted here ships. The MCP server is live and publicly documented at
+ * docs.stackguardian.io/docs/mcp-server/, with read access across workflows, stacks,
+ * templates, connectors and policies plus the cloud posture dataset. The skill pack is in
+ * .claude/skills/tirith-policies/. What is NOT claimed is a button: no part of the platform
+ * generates a policy set for you here. Two shipped pieces compose, an agent does the
+ * composing, and saying otherwise would be selling a feature nobody has built.
+ *
+ * "Policy set", never "pack". `--pack` and running many policies in one invocation are an
+ * open pull request, not a release, and the caveat below says so rather than letting the
+ * word imply it.
+ *
+ * No em dashes. See documentation/scripts/find-dashes.py.
+ */
+const estate = {
+  num: '05',
+  title: 'A catalogue cannot know your estate',
+  lede:
+    'A scanner catalogue is built for the mistakes everyone makes, and it is good at ' +
+    'them. At fifty repositories the rules that actually stop your incidents are the ones ' +
+    'only you can state: which module sources are allowed, which accounts a pipeline may ' +
+    'touch, the tag your finance team reconciles against. No catalogue ships those, ' +
+    'because no catalogue has read your estate. Two things you already have can write ' +
+    'them between them.',
+  steps: [
+    {
+      n: '1',
+      k: 'The MCP server reads what you actually run',
+      v:
+        'Read access across workflows, stacks, templates, connectors and policies, and ' +
+        'the cloud posture beside it: which resources are managed, which have drifted, ' +
+        'and which are failing which control. Your agent asks in plain language and gets ' +
+        'your estate back, not a generic example of one.',
+    },
+    {
+      n: '2',
+      k: 'The skill pack turns that into rules',
+      v:
+        'With the Tirith skills loaded, what it read becomes policy JSON against the real ' +
+        'condition list and the argument key each provider actually reads. A ' +
+        'misconfiguration you already carry is the best specification a rule can have: ' +
+        'somebody built it that way once, so somebody will build it that way again.',
+      product: true,
+    },
+    {
+      n: '3',
+      k: 'The gate enforces them, deterministically',
+      v:
+        'The resulting policy set runs in your pipeline, on your runner, through the same ' +
+        'open-source CLI as everything else on this site, and returns the same exit code ' +
+        'every time for the same plan. Nothing about enforcement is agentic: the agent ' +
+        'drafts the rule, the engine rules on the change.',
+    },
+  ],
+  verifyBefore: 'Every generated policy is a draft until it has refused something. ',
+  verifyCmd: 'tirith lint',
+  verifyAfter:
+    ' checks the shape against the engine\'s own registries without needing a plan, and ' +
+    'the skill\'s one standing instruction is never to hand back a policy it has not run ' +
+    'against a document that should fail it. Packaging a set to run in one invocation is ' +
+    'on the roadmap and has not shipped.',
+  mcpHref: 'https://docs.stackguardian.io/docs/mcp-server/',
+  mcpLabel: 'Set up the MCP server',
+  skillsTo: '/skills/',
+  skillsLabel: 'The Tirith skills',
+};
+
 const VIEWS = [
   [
     'Policy enforcement',
@@ -248,6 +318,11 @@ const COMPARISON = [
   ['Central policy, history and plan visualisation', 'None', 'Included'],
   ['Approvals, credential brokering and audit', 'Use your existing CI tools', 'Included'],
   ['Assisted prioritisation and remediation', 'None', 'Included; verify entitlement'],
+  // The OSS column keeps something real on both of these rows. The skill pack is
+  // Apache-2.0 and in this repository, so authoring with an agent needs no relationship;
+  // what needs one is the estate to point it at.
+  ['Author policies with a coding agent', 'Included; skill pack', 'Included'],
+  ['Read the estate from that agent: workflows, templates, cloud posture', 'None', 'Included; MCP server'],
   ['Drift, snapshots, recovery and notifications', 'None', 'Where execution or state is connected'],
   ['Private user-owned runtime', 'Your own CI runner', 'Included'],
 ];
@@ -605,9 +680,46 @@ export default function AtScale() {
           
         )}
 
-        {/* ================= 05 COMPARISON ================= */}
+        {/* ================= 05 YOUR ESTATE ================= */}
+        
+ + +
    + {estate.steps.map((step) => ( +
  1. + {step.n} + + {step.k} + {step.v} + +
  2. + ))} +
+ + {/* + * The claim and its qualifier in one paragraph, deliberately. A reader who takes + * the three steps above at face value is owed the sentence about what makes a + * generated policy trustworthy in the same breath, not two screens later. + */} +

+ {estate.verifyBefore} + {estate.verifyCmd} + {estate.verifyAfter} +

+ +
+ + {estate.mcpLabel} + + + {estate.skillsLabel} + +
+
+ + {/* ================= 06 COMPARISON ================= */}
- + @@ -628,9 +740,9 @@ export default function AtScale() {
- {/* ================= 06 FAQ ================= */} + {/* ================= 07 FAQ ================= */}
- +
{FAQ.map(([question, answer]) => (
@@ -649,10 +761,10 @@ export default function AtScale() {

- {/* ================= 07 CONTACT ================= */} + {/* ================= 08 CONTACT ================= */}
diff --git a/documentation/src/pages/index.js b/documentation/src/pages/index.js index 33ae78c3..7a98da5b 100644 --- a/documentation/src/pages/index.js +++ b/documentation/src/pages/index.js @@ -9,7 +9,7 @@ import CopyField from '../components/landing/CopyField'; import PhaseJourney from '../components/landing/PhaseJourney'; import PlatformSetup from '../components/landing/PlatformSetup'; import Specimen from '../components/landing/Specimen'; -import {INTEGRATIONS} from '../data/demoPhases'; +import {INTEGRATIONS, SKILL_INSTALL} from '../data/demoPhases'; import {HIGHLIGHTS} from '../data/roadmap'; import {AGENT_BRIEF} from '../data/agentBrief'; import {CONTRIBUTORS} from '../data/contributors'; @@ -70,22 +70,26 @@ const involve = { 'be large: a tested policy, a CI example for an underserved system, or a reproducible bug ' + 'report can be far more valuable than a star.', /* - * One button, because four of them read as four equally weighted decisions at the point - * where the page should be asking for one thing. + * Two buttons, then the quieter links. Four buttons would read as four equally weighted + * decisions at the point where the page should be asking for something. * - * The button is the good-first-issue list and not the star. The paragraph above it says - * a bug report is worth more than a star, so giving the star the loudest element would - * have the layout contradicting the copy, and starring is not contributing. The rest run - * from the ask that takes real work down to the one that costs nothing. + * The good-first-issue list leads, because the paragraph above says a bug report is worth + * more than a star and the layout should not contradict the copy. The star gets the same + * treatment rather than a louder one: it is the one ask a reader can satisfy in a second, + * so it should not be buried in a row of text links, but order carries the hierarchy and + * neither button shouts over the other. */ primary: { label: 'Find a good first issue', href: `${REPO}/labels/good%20first%20issue`, }, + secondary: { + label: 'Star on GitHub', + href: REPO, + }, more: [ {label: 'Ask for a feature', href: `${REPO}/issues/new/choose`}, {label: 'Watch for releases', href: `${REPO}/releases`}, - {label: 'Star on GitHub', href: REPO}, ], }; @@ -111,13 +115,44 @@ const hero = { * The last clause is the one no competitor answers, so the sentence ends on it rather * than on a feature any scanner could also claim. */ + /* + * The four assurances that used to sit in a band under the plate are folded in here. + * Three of them were already being made: "checks the plan" and "failures explain why" + * by the verdict clause, "local first" by "on your own runner". Only "policies are + * JSON" was a claim this paragraph did not make, so only that one arrives as new words, + * and the band goes. Net saving is about forty words and a full-width row. + */ lede: - 'Plug IaC governance into any pipeline you already run. Tirith evaluates the plan on ' + - 'your own runner, enforces one policy set across repositories when you want one, and ' + - 'returns a single actionable verdict before the change is applied, including the ' + - 'outcome every scanner reports as success: a check that never ran.', + 'Plug IaC governance into any pipeline you already run. Policies are JSON, not a ' + + 'program in Rego or Python. Tirith evaluates the plan on your own runner, enforces ' + + 'one policy set across repositories when you want one, and returns a single verdict ' + + 'before the change is applied.', // "On your own runner" is the lede's line now, so this no longer repeats it. actions: [ + /* + * First here for the same reason it leads the tabs in section 03: it is not a sixth + * platform, it is the other way of producing any of the five after it. Being first + * also makes it the plate's opening state, which is the point -- a reader who already + * knows they want the Action clicks past it in one move. + * + * It replaced the "Rather delegate?" line that used to sit under this plate. A tab + * named Coding agent and a sentence underneath offering the same thing were the same + * offer made twice, and the tab is the one that can carry the command. + */ + { + id: 'agent', + label: 'Coding agent', + command: + `${SKILL_INSTALL}\n` + + '\n' + + '# then, in your agent:\n' + + '"Add a Tirith policy gate to our pipeline"', + prompt: false, + facts: ['Any agent with a shell', 'Reads the real schema', 'You review the PR'], + // Deliberately the shortest caveat of the six. The argument for this route is made + // once, in section 02; repeating it here made the default tab the wordiest thing + // above the fold. 'this one, so you need not name your CI. It drafts; the engine still decides.', + }, { id: 'github', label: 'GitHub Actions', @@ -139,16 +174,11 @@ const hero = { // check run. It is the only setup step the Action cannot do for you, and the one // thing people get wrong on a first install. facts: ['Needs two write permissions', 'Runs on your runner', 'No plan JSON on disk'], - caveat: - 'The Action runs Tirith on the GitHub runner and reports the verdict on the pull ' + - 'request. Handing it the binary plan rather than exporting JSON first is one step ' + - 'shorter, and renders the plan in memory, so no unmasked plan JSON is written to ' + - 'the workspace. The setup below adds the policy and the permissions.', }, { id: 'gitlab', label: 'GitLab CI', - // Two jobs, not one: the caveat says the gate consumes the plan as an artifact, and + // Two jobs, not one: the gate consumes the plan as an artifact, and // the producing job is what makes that sentence mean something. command: `plan: script: @@ -164,14 +194,11 @@ tirith: - tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error`, prompt: false, facts: ['No wrapper to install', 'Runs on your runner', 'Plan as an artifact'], - caveat: - 'No wrapper to install — GitLab calls the CLI directly, which is what the GitHub ' + - 'Action does underneath. The job consumes the plan as an artifact.', }, { id: 'bitbucket', label: 'Bitbucket', - // "Plan in one step, gate in the next" -- the caveat already promised two steps and + // Plan in one step, gate in the next: the snippet has to show both, and // the block only ever showed the second one. command: `- step: name: Terraform plan @@ -187,34 +214,26 @@ tirith: - tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error`, prompt: false, facts: ['Any Python 3.8 image', 'Runs on your runner', 'Two steps'], - caveat: - 'Plan in one step, gate in the next, passing plan.json between them as an ' + - 'artifact. The same CLI as every other pipeline.', }, { id: 'anyci', label: 'Any CI', - // `tirith lint` is commented, not dropped: it is in development and not in 1.2.0, - // so a reader pasting this block would get a failing step. A comment is inert in - // shell and in YAML, which keeps the block runnable and still shows what is coming. + // `tirith lint` stays commented while 1.2.0 is the pinned release: it is on main but + // not in that tag, so a reader pasting this block would get a failing step. A comment + // is inert in shell and in YAML, which keeps the block runnable and still shows it. command: 'pip install "git+https://github.com/StackGuardian/tirith.git@1.2.0"\n' + - '# tirith lint .tirith/policies # in dev, not in 1.2.0\n' + + '# tirith lint .tirith/policies # not in 1.2.0 yet, on main\n' + 'tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error', prompt: false, facts: ['Gate on the exit code', 'Any runner', 'Two commands'], - caveat: - 'Two commands on anything that can produce a plan — Jenkins, Azure DevOps, CircleCI, ' + - 'a cron job. Gate on the exit code, which every CI system already does. The lint step ' + - 'is commented out because it has not shipped yet.', }, { id: 'cli', label: 'Local CLI', /* - * The install line is here rather than in the caveat, which used to say "install - * Tirith first" and then not say how -- the one tab whose whole job is the bare - * command was the one that could not be run from what it showed. + * The install line is in the snippet itself. The tab whose whole job is the bare + * command has to be runnable from what it shows. * * Not PyPI: `py-tirith` is unpublished and the bare `tirith` name belongs to an * unrelated monitoring package, so `pip install tirith` would quietly install @@ -233,28 +252,6 @@ tirith: 'tirith --fail-on-error -policy-path .tirith/policies -input-path plan.json', prompt: false, facts: ['Nothing leaves your machine', 'Runs on your machine', 'No account'], - caveat: - 'The same CLI every pipeline above calls. Point it at a policy or a directory of ' + - 'policies and the document you want to check — any JSON or YAML document, not only ' + - 'a plan.', - }, - ], - assurances: [ - { - k: 'Checks the plan', - v: 'Evaluate the proposed change while the pipeline can still stop it.', - }, - { - k: 'Policies are JSON', - v: 'Describe allowed values without maintaining a program in Rego or Python.', - }, - { - k: 'Failures explain why', - v: 'See which check failed, which value was rejected, and why it failed.', - }, - { - k: 'Local first', - v: 'Keep policies beside your code. Organization mode remains optional.', }, ], }; @@ -272,128 +269,131 @@ tirith: * reading BETA would label it without announcing anything -- but it is also a beta, * and the reference page opens by saying so, so the sentence says so too rather than * setting a second tag a few pixels from the first. + * + * Two entries, and two is the cap: a row of announcements announces nothing. Each cell is + * its own link, so a reader aiming at one cannot land on the other. An empty array removes + * the row. The skill pack leads: it is the newer thing and the one the rest of the page is + * built around, and the first cell is the one a reader's eye lands on. */ -const announcement = { - tag: 'New', - // No backticks: this is JSX text, not markdown, so they would render literally. - // The renderer sets the command in . - command: 'tirith ui', - // A banner is read at a glance or not at all, so it carries the two things the tool is - // for and nothing else. Validation as you type and serving the playground to a team are - // the page it links to, not this line. - body: - 'explores a failing evaluation down to the resource that caused it, and builds ' + - 'policies from a form.', - to: '/docs/tirith-usage/interactive-interface/', - linkLabel: 'Read more', -}; +const announcements = [ + { + id: 'skills', + tag: 'New', + // Named, not explained. This row is the smallest of the three places the pack appears, + // and its whole job is that a reader learns the option exists before they start typing. + command: 'skill pack', + body: 'teaches your coding agent the real schema, and how to put a gate in a pipeline.', + to: '/skills/', + linkLabel: 'Set it up', + }, + { + id: 'ui', + tag: 'New', + // No backticks: this is JSX text, not markdown, so they would render literally. + // The renderer sets the command in . + command: 'tirith ui', + // A banner is read at a glance or not at all, so it carries the two things the tool is + // for and nothing else. Validation as you type and serving the playground to a team are + // the page it links to, not this line. + body: + 'explores a failing evaluation down to the resource that caused it, and builds ' + + 'policies from a form.', + to: '/docs/tirith-usage/interactive-interface/', + linkLabel: 'Read more', + }, +]; + /* - * Cut roughly in half. This is the first section after the hero, where a reader is still - * deciding whether to keep going, and three points that each took two sentences to make one - * argument were the densest thing above the fold. + * The delegated route, shown in three sizes rather than as a section of its own. + * + * It was a section once. That was wrong: handing the job to an agent is not a fourth thing + * to do after the three steps, it is the same three steps typed at something else, and a + * section implied a reader had to choose it deliberately rather than notice it while + * choosing a platform. So it now appears beside the commands themselves, at whatever size + * the surrounding surface can carry: + * + * announcement a name, in the row that already announces `tirith ui` + * hero the leading tab of the install plate, with the command + * 03 setup the leading tab of the platform panel, with the command * - * Nothing was dropped. Each point kept its concrete example, which is the part that carries - * it, and lost the clause restating the heading: "can slip through a busy pull request" - * after a heading reading "manual review can miss things" says the same thing twice. + * One installer string for all three, so there is one thing to keep correct. */ -const gap = { +/* The installer itself lives in ../data/demoPhases, imported at the top of this file. */ + +/* + * One section, two registers per step. + * + * This was two sections telling the same story: a four-step narrative of what happens, then + * a three-step instruction for what you do, whose steps mapped onto the narrative almost + * one to one (export the plan = step 1, commit a policy = step 3, run the gate = step 4). + * Each step now carries both: the sentence says what happens, the `do` line says what you + * type. The platform tabs sit underneath, answering "and how do I invoke that here" for + * whichever runner you use. + * + * All four note bullets went to the docs, which already carry them. None of them was a + * reason to adopt, and the CI integration page is where someone setting this up is reading. + */ +const how = { num: '01', - title: 'Why add a policy gate?', - lede: - 'A valid plan can still break a rule your team depends on, and the pipeline is the ' + - 'last place that can stop it.', - points: [ + title: 'Add Tirith to your pipeline', + // One line. The four steps below say the rest, and the tabs make the last clause obvious. + lede: 'Your IaC tool produces the plan, Tirith checks it, and the exit code decides.', + steps: [ { - k: 'Review misses things', - v: 'An empty owner tag, an oversized volume, a destroy hidden inside a replacement.', + n: '1', + k: 'Your IaC tool plans', + do: 'tofu show -json tfplan > plan.json', }, { - k: 'After apply is too late', - v: 'Cleanup and rollback, for something the plan already showed you.', + n: '2', + k: 'Tirith reads the plan', + do: '-input-path plan.json', + product: true, }, { - k: 'Scripts rot', - v: 'Every one-off check carries its own parsing, exit codes and upkeep.', + n: '3', + k: 'Policies test the change', + do: 'commit rules under .tirith/policies', }, - ], -}; - -const how = { - num: '02', - title: 'Put Tirith between plan and apply.', - lede: - 'Tirith fits into the pipeline you already have. Your IaC tool produces the plan, ' + - 'Tirith checks it, and the exit code tells the pipeline whether to continue.', - steps: [ - {n: '1', k: 'Your IaC tool plans', v: 'OpenTofu or Terraform exports the proposed change as plan.json.'}, { - n: '2', - k: 'Tirith reads the plan', - v: 'The plan provider finds the resources and attributes each policy asks for, from either tool.', - product: true, + n: '4', + k: 'CI continues or stops', + do: '--fail-on-error, so a violation exits 3', }, - {n: '3', k: 'Policies test the change', v: 'JSON conditions check each matching value and produce one verdict.'}, - {n: '4', k: 'CI continues or stops', v: 'A pass moves on to apply. A failure explains the rejected values and, with fail-on-error, exits 3.'}, - ], -}; - -const setup = { - num: '03', - title: 'Add Tirith to your pipeline', - lede: - 'Three pieces make the gate work, and they are the same three everywhere: a policy, ' + - 'the plan as JSON, and Tirith running with fail-on-error. Only the way you invoke it ' + - 'changes between platforms.', - steps: [ - {n: '1', k: 'Commit a policy', v: 'Put one or more JSON rules under .tirith/policies/.'}, - {n: '2', k: 'Export the plan', v: 'Run tofu show -json tfplan > plan.json (or terraform show).'}, - {n: '3', k: 'Run the gate', v: 'Set fail-on-error so a failed policy blocks the job.'}, - ], - notes: [ - 'OpenTofu works identically — swap terraform for tofu; the plan JSON is the same', - 'Policies are JSON files committed under .tirith/policies', - 'On GitHub the Action adds the pull-request comment and check run, which need the two write permissions', - 'Without fail-on-error, Tirith reports findings but does not block the job', ], }; const proof = { - num: '04', + num: '02', title: 'Watch it catch a real mistake', - lede: - 'Five chapters, played out in a public demo repository on each of the three forges ' + - 'above: add the local gate, watch it block an empty Owner tag, clear the failure with ' + - 'a one-line fix, then move policy to the organization and publish state.', + lede: 'Five chapters in a public demo repository, from first gate to published state.', }; /* - * Both halves of this section are unshipped. The pre-commit hook needs - * .pre-commit-hooks.yaml and the editor loop needs .vscode/tasks.json; neither file is in - * this repository, and both drive `tirith lint`, which is not in the released CLI either - * -- src/tirith/cli.py dispatches `platform` and `ui` and nothing else. - * - * Tagged rather than cut: the docs page it links to is written and the work is real. The - * tense moves to the conditional so the section describes a plan, not a feature. + * Restored once `tirith lint` and `tirith fmt` landed and .pre-commit-hooks.yaml started + * publishing both ids. The tense is present because the commands exist; the one thing this + * repository still does not ship is .vscode/tasks.json, so the docs page gives those tasks + * inline rather than linking to a file that is not there. */ const anywhere = { - num: '05', + num: '03', title: 'Catch it before you push', - tag: 'In dev', - planned: true, - lede: - 'The gate will not have to wait for CI. The same checks are being wired into a ' + - 'pre-commit hook and an editor task, which is where a policy an agent just wrote ' + - 'should be proved. Neither has shipped yet.', + /* + * Corrected. This used to read "the gate does not have to wait for CI, the same checks + * run in a pre-commit hook", which was false in the way that matters: the hooks run + * `tirith lint` and `tirith fmt`, and both read the policy file, never the change. Shape, + * not meaning, in lint.py's own words. The old sentence even carried its own refutation + * ("neither needs a plan document"): a check that needs no plan cannot be the gate. + */ + // The two cards carry the detail. This only has to draw the distinction. + lede: 'Two checks, and only one of them needs a plan.', }; const specimenPlate = { - // '06' until the section above it was hidden. See the restore note there before changing. - num: '05', + num: '04', title: 'See exactly what a policy checks', - lede: - 'A policy answers three questions: what to read, which values to inspect, and what ' + - 'must be true. Change the threshold below and watch the verdict update.', + lede: 'Change the threshold below and watch the verdict update.', }; const explore = { @@ -420,18 +420,37 @@ const explore = { { glyph: 'org', title: 'Tirith at scale', - body: 'Share policies across repositories and keep run history in StackGuardian.', + body: 'One rule scales the same way: the platform reads what you already run, your agent writes the rules to match.', to: '/at-scale/', tag: 'Optional', }, ], }; +/* + * The close, and the only place the "write your own rule" argument is now made. + * + * It was a numbered section as well, under this exact heading, which meant the page argued + * the point in the middle and then repeated the headline at the bottom. The points moved + * down here because this is where a reader is deciding what to do next, and the note that + * was already here is the instruction those three points justify. Proving a policy before + * trusting it is the skill pack's standing instruction and the docs' job, not a line the + * close has to carry. + * + * The at-scale bridge folded into the explore card of the same name rather than sitting as + * its own line: a sentence and a card linking to the same page, two lines apart, is one + * link too many. + */ const finale = { title: 'Start with one rule.', note: - 'Choose something your team already checks by hand, commit it as a JSON policy, ' + - 'and run it against the next plan.', + 'A catalogue covers the mistakes everyone makes. Pick the rule only you can state: ' + + 'something your team already checks by hand.', + points: [ + {k: 'A policy is data', v: 'JSON with conditions in it. No rule language, no plugin.'}, + {k: 'So an agent can write it', v: 'The skill pack gives it the real condition list, so it cannot invent one.'}, + {k: 'The engine decides, not the model', v: 'Same evaluator, same exit code, your runner.'}, + ], links: [ {to: '/docs/tirith-installation/quick-installation/', label: 'Installation'}, {to: '/learn/', label: 'Learn to write a policy'}, @@ -565,10 +584,10 @@ function AgentView() { the route plus .md, beside the HTML
  • - - skill pack + + skill packs - drop-in instructions for a coding agent + writing policies, generating a set from your IaC, migrating from Sentinel
  • @@ -598,20 +617,25 @@ export default function Home() { * Out here it is the navbar's neighbour, .hero's top padding opens every page on * the same line, and the strip still precedes the letterhead. * - * The whole row is the link -- a reader aiming at "Read more" should not be able - * to miss and hit nothing. + * Each cell is a link across its full width -- a reader aiming at "Read more" + * should not be able to miss and hit nothing -- but the row is no longer one + * link, because it now carries two destinations. */} - {announcement ? ( - - {announcement.tag} - - {announcement.command}{' '} - {announcement.body} - - - {announcement.linkLabel} - - + {announcements.length ? ( +
    + {announcements.map((item) => ( + + {item.tag} + + {item.command}{' '} + {item.body} + + + {item.linkLabel} + + + ))} +
    ) : null} {/* ================= HERO ================= */} @@ -695,12 +719,10 @@ export default function Home() {
  • {f}
  • ))} -

    {heroAction.caveat}

    - {/* What it costs you, before anything asks the visitor to read further. */} -
      - {hero.assurances.map((a) => ( -
    • - {a.k} - {a.v} -
    • - ))} -
    )} @@ -731,57 +744,24 @@ export default function Home() { ) : ( <> - {/* ================= 01 GAP ================= */} -
    - -
    - {gap.points.map((p) => ( -
    -
    {p.k}
    -
    {p.v}
    -
    - ))} -
    -
    - - {/* ================= 02 HOW IT WORKS ================= */} -
    + {/* ================= 01 ADD IT TO YOUR PIPELINE ================= */} +
      {how.steps.map((step) => (
    1. {step.n}

      {step.k}

      -

      {step.v}

      + {/* What you type for that step, so the flow is also the instructions. */} +

      + {step.do} +

    2. ))}
    -
    - - {/* ================= 03 QUICK START ================= */} -
    - -
    -
      - {setup.steps.map((step) => ( -
    1. - {step.n} -
      -

      {step.k}

      -

      {step.v}

      -
      -
    2. - ))} -
    -
    - -
    +
    +
    -
      - {setup.notes.map((note) => ( -
    • {note}
    • - ))} -
    Full installation guide @@ -790,59 +770,42 @@ export default function Home() { Start with a worked policy
    +
    - {/* ================= 04 REAL PROOF ================= */} + {/* ================= 02 REAL PROOF ================= */}
    - {/* - * ================= RUNS ANYWHERE: HIDDEN ================= - * - * Removed for now. Both halves of it were unshipped anyway: the pre-commit hook - * needs .pre-commit-hooks.yaml and the editor loop needs .vscode/tasks.json, and - * both drive `tirith lint`, none of which are in the released package. It was - * already tagged In dev for that reason, so the page lost a tag rather than a - * feature. - * - * A `false` guard rather than a comment: JSX children cannot take a line comment, - * and the guard keeps the markup parsed so it cannot rot while switched off. - * - * TO RESTORE: change `false` to `true`, and put `specimenPlate.num` back to '06'. - * It was moved to '05' to close the gap this left, because the section numerals are - * set large on this page and 04 followed by 06 reads as a fault rather than a - * choice. `anywhere` and the INTEGRATIONS import are both still in place. - */} - {false && ( -
    - -
      - {INTEGRATIONS.map((item) => ( -
    • - - - - - {item.title} - {item.inDev ? ( - In dev - ) : null} - - {item.body} + {/* ================= 03 RUNS ANYWHERE ================= */} +
      + +
        + {INTEGRATIONS.map((item) => ( +
      • + + + + + {item.title} + {item.inDev ? ( + In dev + ) : null} - - -
      • - ))} -
      -
      - )} + {item.body} +
      + + +
    • + ))} +
    +
    - {/* ================= 05 THE SPECIMEN ================= */} + {/* ================= 04 THE SPECIMEN ================= */}
    @@ -868,6 +831,14 @@ export default function Home() { ))} +
    + {finale.points.map((pt) => ( +
    +
    {pt.k}
    +
    {pt.v}
    +
    + ))} +

    Explore the focused guides

      {explore.items.map((item) => ( @@ -949,11 +920,14 @@ export default function Home() {

      {involve.community}

      {involve.note}

      - {/* Wrapped, because a bare grid child would stretch the button full width. */} + {/* Wrapped, because bare grid children would stretch the buttons full width. */}
      {involve.primary.label} + + {involve.secondary.label} +
      {involve.more.map((l) => ( diff --git a/documentation/src/pages/index.module.css b/documentation/src/pages/index.module.css index 88fe4151..189dd86c 100644 --- a/documentation/src/pages/index.module.css +++ b/documentation/src/pages/index.module.css @@ -128,7 +128,7 @@ * The hero is the sheet's title plate and nothing else. The interactive specimen * used to sit beside it in a third pane, which split the opening three ways and * left the headline and the install command competing for the same attention. - * The instrument now has its own plate below (§01), where it can be as dense as + * The instrument now has its own plate below (§06), where it can be as dense as * it likes without taxing the first viewport. */ .hero { @@ -158,24 +158,50 @@ * are three different sizes, and centring them lines up their boxes while leaving * the type they contain visibly astray. */ +/* + * Two cells on one rule, divided rather than stacked: the row has to stay one line tall, + * because it sits above the letterhead and any height it takes is height the hero loses. + */ .announce { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-bottom: 1px solid var(--tp-rule); +} + +.announceItem { display: flex; align-items: baseline; gap: 0.75rem; + min-width: 0; padding: 0.85rem var(--tp-pad); - border-bottom: 1px solid var(--tp-rule); color: var(--tp-soft); font-size: 0.8rem; line-height: 1.5; } -/* The row is the link, so the underline the theme would add belongs to nothing. */ -.announce:hover { +.announceItem + .announceItem { + border-left: 1px solid var(--tp-rule); +} + +/* The cell is the link, so the underline the theme would add belongs to nothing. */ +.announceItem:hover { color: var(--tp-ink); text-decoration: none; } -.announce .betaTag { +/* One column below the fold-width where two cells would each get half a sentence. */ +@media (max-width: 860px) { + .announce { + grid-template-columns: minmax(0, 1fr); + } + + .announceItem + .announceItem { + border-top: 1px solid var(--tp-rule); + border-left: 0; + } +} + +.announceItem .betaTag { flex: none; /* Baseline alignment drops a bordered inline box by its own padding; this puts the tag's cap-height back on the sentence's baseline instead of below it. */ @@ -331,15 +357,6 @@ margin-bottom: 1.25rem; } -.cost { - max-width: 52ch; - margin: 0 0 2rem; - padding-left: 0.9rem; - border-left: 2px solid var(--tp-accent); - color: var(--tp-ink); - font-size: 1rem; - line-height: 1.55; -} .heroAction { min-width: 0; @@ -394,52 +411,11 @@ text-transform: uppercase; } -/* - * The assurance band. Same hairline-ruled cells as .defs, but seated inside the hero - * so it is read as part of the offer rather than as the first section of the - * argument. It answers the question the StackGuardian mark in the navbar provokes. - */ -.assure { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); - gap: 0; - margin: 0; - padding: 0; - list-style: none; - border-top: 1px solid var(--tp-rule); -} -.assure li { - min-width: 0; - padding: 1.15rem var(--tp-gut) 1.4rem 0; - border-right: 1px solid var(--tp-rule); -} -.assure li:last-child { - border-right: 0; - padding-right: 0; -} -.assure li:not(:first-child) { - padding-left: var(--tp-gut); -} -.assureK { - display: block; - margin-bottom: 0.4rem; - font-family: var(--tp-display); - font-size: 0.73rem; - font-weight: 700; - letter-spacing: -0.02em; -} -.assureV { - display: block; - max-width: 36ch; - color: var(--tp-soft); - font-size: 0.94rem; - line-height: 1.55; -} /* ------------------------------------------------------- the copy field ----- */ @@ -480,6 +456,7 @@ and this one is the page's primary action. */ white-space: pre-wrap; overflow-wrap: anywhere; + border-right: 1px solid var(--tp-rule); } .copyPrompt { @@ -504,8 +481,13 @@ align-self: flex-start; min-width: 5.5rem; padding: 0.7rem 0.9rem; + /* + * No border-left. The button is a top-aligned chip, so an edge on it rendered as a short + * line floating beside a taller field rather than as a divider. The divider is + * .copyCommand's right edge instead, which stretches with the field however many lines + * the command wraps to. + */ border: 0; - border-left: 1px solid var(--tp-rule); background: var(--tp-ink); color: var(--tp-paper); font-family: var(--tp-display); @@ -574,23 +556,7 @@ color: var(--tp-faint); } -.caveat { - max-width: 48ch; - margin: 0.9rem 0 0; - color: var(--tp-soft); - font-size: 0.85rem; - line-height: 1.55; -} -.caveat code { - padding: 0.05em 0.3em; - border: 0; - border-radius: 0; - background: var(--tp-surface); - color: var(--tp-soft); - font-size: 0.95em; - vertical-align: baseline; -} .heroLinks { display: flex; @@ -1823,15 +1789,19 @@ font-weight: 700; } -.howFlow h3, -.quickSteps h3 { +/* + * These two were `.howFlow h3, .quickSteps h3` and `.howFlow p, .quickSteps p`. Removing the + * dead `.quickSteps` halves matched the second line of each selector and deleted through the + * closing brace, orphaning `.howFlow h3,` and `.howFlow p,` onto the block below, which is + * how every heading and paragraph in the flow ended up painted with the accent card's fill. + */ +.howFlow h3 { margin: 0; font-size: 0.86rem; line-height: 1.35; } -.howFlow p, -.quickSteps p { +.howFlow p { margin: 0.6rem 0 0; color: var(--tp-soft); font-size: 0.94rem; @@ -1844,6 +1814,61 @@ color: #fff; } +/* + * The h3 needs naming explicitly, and this is why: Infima ships + * `h1,h2,h3,h4,h5,h6{color:var(--ifm-heading-color)}` and this site points that variable at + * --tp-ink. A direct rule on the element beats a colour inherited from `.howProduct` on the + * li, so the card's own title rendered in body ink on the accent fill while the paragraph + * beside it, which did have an explicit override, looked correct. The title is the one + * thing on the card that has to carry, so it takes full white rather than the 0.82 the + * supporting text uses. + */ +/* + * The instruction line in each flow cell. Mono and quieter than the sentence above it: the + * cell says what happens, this says what you type, and they must not read as one voice. + */ +/* `.howFlow .howDo` rather than `.howDo !important`: `.howFlow p` already sets a margin at + (0,1,1), so the flat class lost and the fix was reached for with a hammer. */ +/* No divider: the sentence it used to separate the command from is gone, so a rule directly + under the title would be dividing a heading from its own instruction. */ +.howFlow .howDo { + margin: 0.8rem 0 0; +} + +/* + * The chip is not ours: Infima styles every bare with its own background and border. + * Recolouring only the text is what broke the accent card, where near-white ink landed on + * Infima's light grey chip and became unreadable. Both states now set background, border + * and colour together, so the chip can never be lit from one palette and inked from another. + */ +.howFlow .howDo code { + border-color: rgba(0, 0, 0, 0.1); + background: var(--tp-paper); + color: var(--tp-soft); + font-family: var(--tp-mono); + font-size: 0.74rem; + line-height: 1.45; + overflow-wrap: anywhere; +} + + +.howFlow .howProduct .howDo code { + border-color: rgba(255, 255, 255, 0.32); + background: rgba(255, 255, 255, 0.14); + color: #fff; +} + + +[data-theme='dark'] .howFlow .howProduct .howDo code { + border-color: rgba(13, 13, 13, 0.3); + background: rgba(13, 13, 13, 0.12); + color: #0d0d0d; +} + +.howFlow .howProduct h3 { + color: #fff; +} + .howFlow .howProduct .howNum, .howFlow .howProduct p { color: rgba(255, 255, 255, 0.82); @@ -1858,6 +1883,12 @@ color: #0d0d0d; } +/* Dark mode flips the accent to a light periwinkle, so the card's text goes dark to keep + contrast. Same Infima override needed here, in the other direction. */ +[data-theme='dark'] .howFlow .howProduct h3 { + color: #0d0d0d; +} + [data-theme='dark'] .howFlow .howProduct .howNum, [data-theme='dark'] .howFlow .howProduct p { color: rgba(13, 13, 13, 0.78); @@ -1867,35 +1898,9 @@ color: #0d0d0d; } -/* --------------------------------------------------------- 03 quick start --- */ -.quickStart { - display: grid; - grid-template-columns: minmax(18rem, 0.72fr) minmax(0, 1.28fr); - border-top: 1px solid var(--tp-rule); - border-left: 1px solid var(--tp-rule); -} -.quickSteps { - min-width: 0; - margin: 0; - padding: 0; - list-style: none; -} -.quickSteps li { - display: grid; - grid-template-columns: 1.8rem minmax(0, 1fr); - gap: 0.85rem; - padding: 1.2rem 1.25rem; - border-right: 1px solid var(--tp-rule); - border-bottom: 1px solid var(--tp-rule); -} - -.quickSteps .stepNum { - width: 1.8rem; - height: 1.8rem; -} .quickCode { min-width: 0; @@ -1912,32 +1917,6 @@ font-size: 0.72rem; } -.quickNotes { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - margin: 0; - padding: 0; - border-left: 1px solid var(--tp-rule); - list-style: none; -} - -.quickNotes li { - min-width: 0; - padding: 0.9rem 1.1rem; - border-right: 1px solid var(--tp-rule); - border-bottom: 1px solid var(--tp-rule); - color: var(--tp-soft); - font-size: 0.82rem; - line-height: 1.5; -} - -.quickNotes li::before { - content: '✓'; - margin-right: 0.55rem; - color: var(--tp-accent); - font-family: var(--tp-mono); -} - .quickLinks { display: flex; flex-wrap: wrap; @@ -2063,6 +2042,25 @@ color: var(--tp-faint); } +/* + * Marks the two walkthrough steps that need a StackGuardian organization. Set as a hairline + * chip in the eyebrow rather than a coloured tag: it is a qualification, not a warning, and + * anything louder would read as the step being unavailable rather than optional. + */ +.phasePlatform { + display: inline-block; + margin-left: 0.5rem; + padding: 0.05rem 0.34rem; + border: 1px solid var(--tp-rule); + border-radius: 2px; + color: var(--tp-faint); + font-size: 0.9em; + letter-spacing: 0.08em; + /* The eyebrow is uppercase; this stays lower case so it reads as an aside to it. */ + text-transform: lowercase; + white-space: nowrap; +} + .phaseSelectTitle { font-family: var(--tp-display); font-size: 0.8rem; @@ -2338,19 +2336,6 @@ gap: 0.85rem; } -.stepNum { - display: grid; - place-items: center; - width: 1.55rem; - height: 1.55rem; - flex: none; - border: 1px solid var(--tp-accent); - color: var(--tp-accent); - font-family: var(--tp-display); - font-size: 0.7rem; - font-weight: 700; - align-self: center; -} .stepTitle { margin: 0; @@ -3113,9 +3098,7 @@ padding-left: 0; } - .howFlow, - .quickStart, - .quickNotes { + .howFlow { grid-template-columns: minmax(0, 1fr); } @@ -3123,9 +3106,7 @@ content: none !important; } - .quickSteps li, .quickCode, - .quickNotes li, .exploreCard { padding-left: 1rem; padding-right: 1rem; @@ -3182,21 +3163,6 @@ padding-right: 1rem; } - .assure li { - padding-right: 0; - padding-left: 0; - border-right: 0; - border-bottom: 1px solid var(--tp-rule); - } - - .assure li:not(:first-child) { - padding-left: 0; - } - - .assure li:last-child { - border-bottom: 0; - } - .uiTabs li { padding-right: 0; padding-left: 0; @@ -3401,3 +3367,6 @@ padding-right: 0; } } + + + diff --git a/documentation/src/pages/learn.js b/documentation/src/pages/learn.js index 5cb01dbf..6b7a1ccc 100644 --- a/documentation/src/pages/learn.js +++ b/documentation/src/pages/learn.js @@ -1,4 +1,4 @@ -import {useState} from 'react'; +import {useCallback, useEffect, useState} from 'react'; import Link from '@docusaurus/Link'; import Layout from '@theme/Layout'; import Heading from '@theme/Heading'; @@ -6,7 +6,7 @@ import Heading from '@theme/Heading'; import TirithMark from '../components/brand/TirithMark'; import Colophon from '../components/site/Colophon'; import Bench from '../components/learn/Bench'; -import {INPUT_DOC, LESSONS, PLAYGROUND_START} from '../data/lessons'; +import {TRACKS} from '../data/lessons'; import {CONDITION_NAMES} from '../data/tirithLite'; import styles from './learn.module.css'; import '../css/chrome.module.css'; @@ -99,15 +99,22 @@ function Lesson({lesson, input}) { ); } -function Playground() { - const [policy, setPolicy] = useState(PLAYGROUND_START); - const [input, setInput] = useState(INPUT_DOC); +/* + * The blank bench, seeded from whichever track is open. + * + * `start` and `doc` are only the *initial* state, so the parent gives this a key of the + * track id: changing tracks remounts it rather than trying to reconcile a policy the + * reader may have edited with a document it no longer matches. + */ +function Playground({start, doc, num}) { + const [policy, setPolicy] = useState(start); + const [input, setInput] = useState(doc); return (
      - 07 + {num} Playground @@ -140,8 +147,8 @@ function Playground() { type="button" className={styles.reset} onClick={() => { - setPolicy(PLAYGROUND_START); - setInput(INPUT_DOC); + setPolicy(start); + setInput(doc); }}> Reset @@ -153,7 +160,60 @@ function Playground() { ); } +/* + * The chooser. + * + * Three providers stacked on one page meant a reader wanting Kubernetes scrolled past nine + * lessons about something else to reach two about their own problem. So the page asks first + * and shows one track. + * + * WHY THE HASH IS THE STATE. `/learn/#kubernetes` has to open on Kubernetes, because that is + * the link someone sends a colleague, and `#playground` is already linked from the Skills + * page and must keep working. Reading it on mount rather than tracking a separate piece of + * state means a deep link and a click end up in exactly the same place. + * + * The initial render is always the first track, deliberately: this page is prerendered at + * build time, where there is no location, and a component that renders one thing on the + * server and another on the client is a hydration mismatch. The effect below corrects it + * after mount, which is one frame on a page whose content is several screens tall. + */ +function useTrack() { + const [id, setId] = useState(TRACKS[0].id); + + useEffect(() => { + const fromHash = () => { + const hash = window.location.hash.replace('#', ''); + if (!hash) return; + // A track id, or any lesson inside one: both mean "open that track". + const track = + TRACKS.find((t) => t.id === hash) || + TRACKS.find((t) => t.lessons.some((l) => l.id === hash)); + if (track) setId(track.id); + }; + fromHash(); + window.addEventListener('hashchange', fromHash); + return () => window.removeEventListener('hashchange', fromHash); + }, []); + + return [id, setId]; +} + export default function Learn() { + const [trackId, setTrackId] = useTrack(); + const track = TRACKS.find((t) => t.id === trackId) || TRACKS[0]; + + /* + * Choosing a track rewrites the hash without a navigation, so the back button walks the + * reader's choices instead of leaving the page, and a copied URL carries the track. No + * scroll, because the selector is what they just clicked and it should stay put. + */ + const choose = useCallback((id) => { + setTrackId(id); + if (typeof window !== 'undefined') { + window.history.replaceState(null, '', `#${id}`); + } + }, [setTrackId]); + return ( Tirith
      @@ -180,9 +240,10 @@ export default function Learn() {

      - Six steps, one document, one policy that grows a rule at a time. Each - step is editable — change a value, run it, and watch the verdict, the - messages and the exit code move with it. + Pick the thing you actually need to gate and learn on that. The syntax is + the same for all three, so the Terraform track teaches it in full and the + other two teach only what changes. Every step is editable: change a value, + run it, and watch the verdict, the messages and the exit code move with it.

      @@ -198,10 +259,11 @@ export default function Learn() { Browser playground

      This runs a Tirith-compatible teaching subset in your browser, not the Python - package. Edit either pane and press Run check. The examples - cover stackguardian/json; install Tirith to evaluate OpenTofu, - Terraform, - Kubernetes, Infracost, and StackGuardian Workflow inputs. + package. Edit either pane and press Run check. It implements + stackguardian/json, stackguardian/terraform_plan and + stackguardian/kubernetes, and its verdicts are checked against the + real engine. Install Tirith for Infracost and StackGuardian Workflow inputs, + and for anything you intend to trust.

      @@ -215,24 +277,79 @@ export default function Learn() {
      -
      - {LESSONS.map((lesson) => ( - - ))} + {track.lessons.map((lesson) => ( + + ))} - + +
      diff --git a/documentation/src/pages/learn.module.css b/documentation/src/pages/learn.module.css index 6ecdfca3..42cbbae8 100644 --- a/documentation/src/pages/learn.module.css +++ b/documentation/src/pages/learn.module.css @@ -863,3 +863,193 @@ color: var(--tp-accent); text-decoration: underline; } + +/* ------------------------------------------------------------------- tracks ---- */ + +/* + * The header that opens each provider's run of lessons. + * + * A rule above rather than a box: this is the same section grammar the rest of the + * site uses, and a card here would be the only one on the page. The provider name is + * set in mono because it is an identifier the reader will type, not a label. + */ +.track { + max-width: 68ch; + margin: 5.5rem 0 0; + padding-top: 2rem; + border-top: 2px solid var(--tp-ink); +} + +.trackProvider { + display: block; + margin-bottom: 0.7rem; + color: var(--tp-accent); + font-family: var(--tp-mono); + font-size: 0.78rem; + letter-spacing: 0.01em; +} + +.trackTitle { + margin: 0 0 0.7rem; + font-family: var(--tp-display); + font-size: clamp(1.3rem, 2.4vw, 1.7rem); + font-weight: 700; + letter-spacing: -0.02em; + line-height: 1.15; +} + +.trackLede { + margin: 0; + color: var(--tp-soft); + font-size: 1rem; + line-height: 1.6; +} + +/* The first track opens directly under the contents, which already has a rule. */ +.track:first-of-type { + margin-top: 3rem; +} + +/* ------------------------------------------------------------------ chooser ---- */ + +/* + * Three providers, one of which is open. + * + * A row of shared-edge cells rather than three separate controls: the tracks are three + * states of one setting, and hairlines between them say that where a gap would not. The + * open one is filled ink so the choice survives a glance from across the page, which a + * coloured underline on a page this long does not. + */ +.chooser { + margin: 3rem 0 0; +} + +.chooserLabel { + display: block; + margin-bottom: 0.85rem; + color: var(--tp-faint); + font-family: var(--tp-display); + font-size: 0.58rem; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.chooserTabs { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + border: 1px solid var(--tp-rule); +} + +/* + * SHARED RULE CARRIES STRUCTURE ONLY. No colour here, and that is load-bearing. + * + * The obvious way to write this is `.tab, .tabOn {background: paper}` followed by + * `.tabOn {background: ink}`, relying on source order between two rules of equal + * specificity. It works in development and breaks in the build: cssnano groups rules that + * share a declaration block, so `.tabOn`'s ink background was merged into an unrelated + * rule that also sets ink on paper (the copy button's hover) and hoisted above the paper + * rule. The selected tab then rendered white, with its provider id paper-on-paper and + * therefore invisible. + * + * Giving each state its own background means no element is ever matched by two rules that + * declare the same property at the same specificity, so cascade order stops mattering and + * the minifier can group whatever it likes. + */ +.tab, +.tabOn { + display: grid; + gap: 0.3rem; + padding: 1.1rem 1.25rem; + border: 0; + border-radius: 0; + cursor: pointer; + text-align: left; + transition: background 120ms ease-out, color 120ms ease-out; +} + +.tab + .tab, +.tab + .tabOn, +.tabOn + .tab { + border-left: 1px solid var(--tp-rule); +} + +.tab { + background: var(--tp-paper); + color: var(--tp-ink); +} + +.tab:hover { + background: var(--tp-surface); + color: var(--tp-ink); +} + +.tabOn { + background: var(--tp-ink); + color: var(--tp-paper); +} + +.tab:focus-visible, +.tabOn:focus-visible { + outline: 2px solid var(--tp-accent); + outline-offset: -3px; +} + +.tabName { + font-family: var(--tp-display); + font-size: 0.9rem; + font-weight: 700; + letter-spacing: -0.01em; +} + +.tabProvider { + color: var(--tp-soft); + font-family: var(--tp-mono); + font-size: 0.68rem; + /* The provider id is long, and it must not force the three cells to different widths. */ + overflow-wrap: anywhere; +} + +.tabOn .tabProvider { + /* --tp-soft on ink is under 3:1. The paper token at reduced opacity holds the + hierarchy without dropping below contrast. */ + color: var(--tp-paper); + opacity: 0.72; +} + +.tabCount { + color: var(--tp-faint); + font-family: var(--tp-display); + font-size: 0.56rem; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.tabOn .tabCount { + color: var(--tp-paper); + opacity: 0.6; +} + +.trackForYou { + margin: 0.9rem 0 0; + padding-left: 0.9rem; + border-left: 2px solid var(--tp-accent); + color: var(--tp-ink); + font-size: 0.92rem; + line-height: 1.55; +} + +/* Stacked below the point where three cells of monospaced provider ids stop fitting. */ +@media (max-width: 720px) { + .chooserTabs { + grid-template-columns: 1fr; + } + + .tab + .tab, + .tab + .tabOn, + .tabOn + .tab { + border-top: 1px solid var(--tp-rule); + border-left: 0; + } +} diff --git a/documentation/src/pages/skills.js b/documentation/src/pages/skills.js index 4e8a61a8..d58e5508 100644 --- a/documentation/src/pages/skills.js +++ b/documentation/src/pages/skills.js @@ -18,13 +18,12 @@ import '../css/chrome.module.css'; const REPO = 'https://github.com/StackGuardian/tirith'; const SKILL_DIR = '.claude/skills/tirith-policies'; -const RAW = 'https://raw.githubusercontent.com/StackGuardian/tirith/main'; - const ROUTES = { playground: '/learn/#playground', policies: '/docs/tirith-policies/tirith-policy-cookbook/', atScale: '/at-scale/', ci: '/docs/tirith-usage/ci-integration/', + install: '/docs/tirith-installation/quick-installation/', }; const hero = { @@ -38,54 +37,34 @@ const hero = { }; /* - * One install command per client. + * One line, because the previous version was six: a mkdir, a BASE assignment, a curl and a + * for loop over ten filenames. That is a correct install and nobody reads it, let alone + * types it. * - * The two that fetch the pack fetch every file in it. An earlier draft created the - * reference/ directory and then downloaded only SKILL.md, which left the ten references - * this page advertises as dangling paths inside the skill. + * The script is served from this site's own static/ directory, so it sits on the same + * origin as the page telling you to run it, and its source is a committed file rather than + * a gist. `curl | sh` earns an objection from exactly this audience, so the page shows the + * URL as text next to the command: it is readable before it is runnable, and the no-pipe + * form is offered beside it. */ -const REFERENCES = [ - 'schema', - 'validate', - 'verdicts', - 'terraform-plan', - 'other-providers', - 'variables', - 'install', - 'pipelines', - 'platform', - 'debug-ci', -]; - -const FETCH_PACK = - `mkdir -p ${SKILL_DIR}/reference\n` + - `BASE=${RAW}/${SKILL_DIR}\n` + - `curl -sL $BASE/SKILL.md -o ${SKILL_DIR}/SKILL.md\n` + - `for f in ${REFERENCES.join(' ')}; do\n` + - ` curl -sL $BASE/reference/$f.md -o ${SKILL_DIR}/reference/$f.md\n` + - `done`; +const INSTALLER = 'https://stackguardian.github.io/tirith/skill.sh'; const CLIENTS = [ { id: 'claude', name: 'Claude Code · Claude Desktop', detail: - 'Drop the folder into your repository. It is picked up automatically — no config file, ' + + 'Drop the folder into your repository. It is picked up automatically: no config file, ' + 'no restart. Works in any project, not just this one.', - command: FETCH_PACK, - verify: 'Ask: "write a Tirith policy requiring an owner tag" — it should name real conditions.', + command: `curl -fsSL ${INSTALLER} | sh`, }, { id: 'cursor', name: 'Cursor', detail: 'A single rule file scoped with globs, so it attaches by itself the moment a policy file ' + - 'is open and stays out of the way otherwise. Self-contained — it needs nothing else.', - command: - 'mkdir -p .cursor/rules\n' + - `curl -sL ${RAW}/.cursor/rules/tirith-policies.mdc \\\n` + - ' -o .cursor/rules/tirith-policies.mdc', - verify: 'Open a file under .tirith/policies — the rule shows as attached in the chat panel.', + 'is open and stays out of the way otherwise. Self-contained: it needs nothing else.', + command: `curl -fsSL ${INSTALLER} | sh -s -- --cursor`, }, { id: 'agents', @@ -94,22 +73,85 @@ const CLIENTS = [ 'Fetch the pack, then point AGENTS.md at it. One file at the repository root is read by a ' + 'growing number of clients, and the pack beside it keeps the references resolvable.', command: - FETCH_PACK + - '\n\nprintf \'\\n## Tirith policies\\nSee %s/SKILL.md\\n\' \\\n' + - ` "${SKILL_DIR}" >> AGENTS.md`, - verify: 'Ask your agent what condition types Tirith supports. It should say thirteen, not guess.', + `curl -fsSL ${INSTALLER} | sh\n` + + `printf '\\n## Tirith policies\\nSee %s/SKILL.md\\n' ${SKILL_DIR} >> AGENTS.md`, }, ]; -/* Every entry is a real file under .claude/skills/tirith-policies/. */ +/* + * The starred skill. Getting a gate into a pipeline is the first thing anyone does with + * Tirith and the thing an agent is best placed to do unattended: it is a known install, a + * known plan export and a known exit-code contract, all of which the pack already carries. + * It leads the page for that reason, and it is the one skill shown with its commands rather + * than only named. + */ +const FEATURED = { + eyebrow: 'Start here', + title: 'Add Tirith to any pipeline', + lede: + 'Ask your agent for a policy gate and, with the pack loaded, it knows the install is a ' + + 'git URL rather than PyPI, which plan document each provider reads, and that exit 3 and ' + + 'exit 1 have to reach the job differently.', + ask: 'Add a Tirith policy gate to our pipeline', + files: [ + ['reference/pipelines.md', 'GitHub Actions, GitLab, Bitbucket, Jenkins, Azure DevOps, CircleCI, any container runner, and the pre-commit hooks.'], + ['reference/install.md', 'Install from git, because the name on PyPI belongs to something else. Pinning a tag, the optional interface, the Python floors.'], + ['reference/debug-ci.md', 'Start from a red build and end at the rule and the resource, ordered by what is most often the answer.'], + ], + /* + * Two snippets, because the pair is the whole job: teach the agent, then give it the + * command it will write into the job. The second is the universal form from + * reference/pipelines.md -- every platform in that file reduces to it. + */ + snippets: [ + { + id: 'pack', + label: 'Give your agent the pack', + command: `curl -fsSL ${INSTALLER} | sh`, + }, + { + id: 'gate', + label: 'What it writes into the job', + command: + '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', + }, + ], +}; + +/* + * Every entry is a real file under .claude/skills/tirith-policies/. + * + * "Get it running" leads: the featured plate above expands the same three files, and a + * reader who scrolled past it should meet them again first rather than last. It also + * removed a duplicate -- reference/pipelines.md was listed twice, once under a "While you + * write" group that held nothing else. + */ const SKILLS = [ + { + group: 'Get it running', + items: [ + ['Add it to a pipeline', 'reference/pipelines.md', 'GitHub Actions, GitLab, Bitbucket, Jenkins, Azure DevOps and CircleCI: the plan step, the install, the pre-commit hooks, and making each exit code do the right thing to the job.'], + ['Install Tirith', 'reference/install.md', 'Install from git, because it is not on PyPI, and the name there belongs to something else. Pinning a tag, the optional interface, and the Python floors.'], + ['Debug a red build', 'reference/debug-ci.md', 'Start from a failed job and end at the rule and the resource, ordered by what is most often the answer.'], + ], + }, + { + group: 'From a repository you already have', + dir: 'tirith-standards', + items: [ + ['Generate a standards set', 'SKILL.md', 'Read the Terraform or OpenTofu you already run, propose the standards it would support, and write one policy per rule. Includes the guard every type-scoped rule needs to stay green on unrelated changes.'], + ['The standards catalogue', 'reference/standards.md', 'Required tags, tag value shape, naming per resource type, allowed regions, forbidden types, size ceilings, encryption, version pinning, and the trap attached to each.'], + ], + }, { group: 'Write and check', items: [ - ['Author a policy', 'SKILL.md', 'Turn an intent — “every resource needs an owner tag” — into valid policy JSON: the provider, the operation, the condition and the expression that ties them together.'], - ['The schema', 'reference/schema.md', 'The closed vocabulary. Thirteen condition types, each provider’s operations, and the argument key that differs per provider — the one an agent otherwise invents.'], - ['Validate it', 'reference/validate.md', 'Run tirith lint, read the report and fix the six trap classes before anything is evaluated.'], + ['Author a policy', 'SKILL.md', 'Turn an intent, “every resource needs an owner tag”, into valid policy JSON: the provider, the operation, the condition and the expression that ties them together.'], + ['The schema', 'reference/schema.md', 'The closed vocabulary. Thirteen condition types, each provider’s operations, and the argument key that differs per provider, which is the one an agent otherwise invents.'], + ['Validate it', 'reference/validate.md', 'The trap classes that produce a policy which looks right and gates nothing, and why a clean shape is not a working rule. tirith lint runs that same check from the command line, and tirith ui runs it as you type.'], ['Run it and read the verdict', 'reference/verdicts.md', 'Exit 0, 1 and 3 and what each should do to a job, why final_result: null is not a pass, and how to find the resource behind a failure.'], + ['Prove it works', 'examples/required-tags/', 'A policy, a plan that fails it and a plan that passes it. The agent runs both before it hands anything back, because a rule only ever seen passing is untested.'], ], }, { @@ -121,25 +163,16 @@ const SKILLS = [ ], }, { - group: 'While you write', - items: [ - ['Run it from your editor', 'reference/pipelines.md', 'VS Code tasks that lint and evaluate in one keystroke, and a pre-commit hook that catches a broken policy before it is committed — the loop that proves what an agent just drafted.'], - ], - }, - { - group: 'Set up and ship', + group: 'Across repositories', items: [ - ['Install Tirith', 'reference/install.md', 'Install from git — it is not on PyPI, and the name there belongs to something else. Pinning a tag, the optional interface, and the Python floors.'], - ['Add it to a pipeline', 'reference/pipelines.md', 'GitHub Actions, GitLab, Bitbucket, Jenkins, any container CI, and a pre-commit hook — plus making each exit code do the right thing to the job.'], - ['Debug a red build', 'reference/debug-ci.md', 'Start from a failed job and end at the rule and the resource, ordered by what is most often the answer.'], - ['Organization policies', 'reference/platform.md', 'tirith platform check — central policy across many repositories, what is masked locally, and the extra timeout exit code.'], + ['Organization policies', 'reference/platform.md', 'tirith platform check: central policy across many repositories, what is masked on your runner before anything is uploaded, and which flags are required.'], ], }, ]; const WORKFLOW = [ ['Ask', 'Describe the guardrail in a sentence. The skill supplies the schema, so the agent picks a real provider, operation and condition instead of guessing.'], - ['Check the shape', 'tirith lint reads the engine’s own registries and rejects an invented condition type before it can look like a violation.'], + ['Check the shape', 'Check the condition type and every argument key against the closed vocabulary. An invented one is ignored rather than rejected, so the check reads nothing and passes.'], ['Check the meaning', 'Only evaluation proves a policy matches anything. Run it against a document that should fail it.'], ['Ship it', 'Commit the policy, add the gate to the pipeline, and let the exit code decide.'], ]; @@ -197,11 +230,11 @@ export default function Skills() {
      @@ -213,7 +246,7 @@ export default function Skills() {
        {CLIENTS.map((c) => ( @@ -221,35 +254,95 @@ export default function Skills() { {c.name}

        {c.detail}

        capture(EVENTS.skillCopy, {client: c.id})} command={c.command} label={`skill-${c.id}`} prompt={false} /> -

        - Check it worked - {c.verify} -

        ))}
      +

      + Check it worked + Ask for a policy in plain words: every bucket needs an Owner tag. With the + pack loaded your agent names a real condition type and the argument key that provider + actually takes. Without it, it invents one that reads perfectly and gates nothing. +

      Working in VS Code? The{' '} editor setup wires lint and evaluate to one keystroke, so the policy your agent just wrote is proved before you read it. The skills teach your agent the vocabulary. To let it run a policy as well, - install Tirith so the command is on PATH —{' '} + install Tirith so the command is on PATH.{' '} one pip command, and the skill's own install reference covers pinning a version.

      - {/* ================= 02 WHAT IT COVERS ================= */} -
      + {/* ================= 02 THE STARRED SKILL ================= */} +
      +
      +
      + {FEATURED.eyebrow} +

      “{FEATURED.ask}”

      +

      + One sentence, and no need to name your CI: the pack covers GitHub Actions, + GitLab, Bitbucket, Jenkins, Azure DevOps, CircleCI and any container runner, + so the agent writes for whichever one it finds in the repository. What comes + back is that pipeline file, a policy under .tirith/policies, and + a job that fails on exit 3 and reports a tooling problem on exit{' '} + 1 instead of confusing the two. +

      +
      +
      + {FEATURED.snippets.map((snippet) => ( +
      + {snippet.label} + capture(EVENTS.skillCopy, {client: `featured-${snippet.id}`})} + command={snippet.command} + label={`featured-${snippet.id}`} + prompt={false} + tone={snippet.id === 'pack' ? 'primary' : 'quiet'} + /> +
      + ))} +
      +
      +
        + {FEATURED.files.map(([file, what]) => ( +
      • + + {file} + + {what} +
      • + ))} +
      +
      + + Every platform, written out + + + Install Tirith itself + +
      +
      + + {/* ================= 03 WHAT IT COVERS ================= */} +
      + {SKILLS.map((group) => (
      @@ -260,7 +353,7 @@ export default function Skills() { {name} + href={`${REPO}/blob/main/.claude/skills/${group.dir || 'tirith-policies'}/${file}`}> {file} {what} @@ -271,10 +364,10 @@ export default function Skills() { ))}
      - {/* ================= 03 THE LOOP ================= */} + {/* ================= 04 THE LOOP ================= */}
      @@ -297,9 +390,9 @@ export default function Skills() {
      - {/* ================= 04 BOUNDARIES ================= */} + {/* ================= 05 BOUNDARIES ================= */}
      - +
      {BOUNDARIES.map(([k, v]) => (
      diff --git a/documentation/src/pages/skills.module.css b/documentation/src/pages/skills.module.css index 897d6513..c872866b 100644 --- a/documentation/src/pages/skills.module.css +++ b/documentation/src/pages/skills.module.css @@ -540,15 +540,20 @@ /* Pushed to the bottom so the three verification lines sit on one baseline however long the description above them runs. */ -.clientVerify { +/* + * One verification line for all three clients, not one per column. + * The three installs run the same script and load the same pack, so three separate checks were + * the same test written three ways. Below the list, spanning it, is also where a reader who has + * just run a command looks next. + */ +.verify { display: grid; - gap: 0.3rem; - margin: auto 0 0; - padding-top: 0.9rem; - border-top: 1px solid var(--tp-rule); + gap: 0.4rem; + max-width: 68ch; + margin: 1.6rem 0 0; color: var(--tp-soft); - font-size: 0.82rem; - line-height: 1.5; + font-size: 0.88rem; + line-height: 1.6; } .verifyLabel { @@ -717,3 +722,108 @@ margin-top: 0.3rem; } } + +/* --------------------------------------------------------------------------- + * THE STARRED SKILL + * + * Two columns: the sentence someone actually types on the left, the two commands on the + * right. It reads as one plate rather than a card grid because it is one action, and it + * is the only skill on the page shown with its commands: everything under "What it + * covers" is a name and a file, which is right for a catalogue and wrong for the entry + * point. + * ------------------------------------------------------------------------ */ +.featured { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 0 var(--tp-gut); + align-items: start; + padding: 1.75rem 0 1.9rem; + border-top: 1px solid var(--tp-rule); + border-bottom: 1px solid var(--tp-rule); +} + +.featuredAsk { + display: grid; + gap: 0.7rem; + align-content: start; + min-width: 0; +} + +.featuredEyebrow { + color: var(--tp-faint); + font-family: var(--tp-mono, monospace); + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +/* The ask is set as the page's own voice rather than in a code field: it is a sentence + typed at an agent, not a command with syntax to get right. */ +.featuredQuote { + margin: 0; + font-family: var(--tp-display); + font-size: 1.15rem; + font-weight: 600; + line-height: 1.4; + letter-spacing: -0.02em; + text-wrap: balance; +} + +.featuredNote { + margin: 0; + max-width: 46ch; + color: var(--tp-soft); + font-size: 0.88rem; + line-height: 1.6; +} + +.featuredNote code { + font-size: 0.85em; +} + +.featuredCommands { + display: grid; + gap: 1.1rem; + min-width: 0; +} + +.featuredCommand { + display: grid; + gap: 0.45rem; + min-width: 0; +} + +.featuredLabel { + color: var(--tp-faint); + font-size: 0.78rem; + letter-spacing: 0.02em; +} + +.featuredFiles { + display: grid; + gap: 0.75rem; + margin: 1.5rem 0 0; + padding: 0; + list-style: none; +} + +.featuredFiles > li { + display: grid; + grid-template-columns: minmax(0, 15rem) minmax(0, 1fr); + gap: 0 var(--tp-gut); + align-items: baseline; + min-width: 0; +} + +@media (max-width: 860px) { + .featured { + grid-template-columns: minmax(0, 1fr); + gap: 1.6rem; + } + + .featuredFiles > li { + grid-template-columns: minmax(0, 1fr); + gap: 0.25rem; + } +} diff --git a/documentation/src/theme/DocBreadcrumbs/index.js b/documentation/src/theme/DocBreadcrumbs/index.js new file mode 100644 index 00000000..9f395db7 --- /dev/null +++ b/documentation/src/theme/DocBreadcrumbs/index.js @@ -0,0 +1,25 @@ +import React from 'react'; +import DocBreadcrumbs from '@theme-original/DocBreadcrumbs'; +import CopyPageMenu from '@site/src/components/docs/CopyPageMenu'; +import styles from './styles.module.css'; + +/** + * The breadcrumb row, with the copy-page control on the other end of it. + * + * WHY HERE. The control has to sit at the top of the article column, level with something, + * and the breadcrumbs are the only element already in that position. Wrapping this component + * is a two-line swizzle; reaching the same place through DocItem/Layout would mean ejecting + * the whole layout and owning Docusaurus's TOC and pagination logic forever. + * + * The original renders `null` on a page with breadcrumbs turned off. The row survives that: + * `justify-content: space-between` with one child leaves the control on the right, which is + * where it belongs anyway. + */ +export default function DocBreadcrumbsWrapper(props) { + return ( +
      + + +
      + ); +} diff --git a/documentation/src/theme/DocBreadcrumbs/styles.module.css b/documentation/src/theme/DocBreadcrumbs/styles.module.css new file mode 100644 index 00000000..3b93258f --- /dev/null +++ b/documentation/src/theme/DocBreadcrumbs/styles.module.css @@ -0,0 +1,14 @@ +/* + * The breadcrumbs bring their own bottom margin, so the row takes none: collapsing it here + * and re-adding it would only give the two children different baselines. + */ +.row { + display: flex; + gap: 1rem; + align-items: flex-start; + justify-content: space-between; +} + +.row > nav { + min-width: 0; +} diff --git a/documentation/src/theme/PaginatorNavLink/index.js b/documentation/src/theme/PaginatorNavLink/index.js index 41a8d0f5..cad1ef11 100644 --- a/documentation/src/theme/PaginatorNavLink/index.js +++ b/documentation/src/theme/PaginatorNavLink/index.js @@ -1,33 +1,47 @@ +import React from 'react'; import Link from '@docusaurus/Link'; import clsx from 'clsx'; -function PaginatorNavLink({ permalink, title, isNext }) { - return ( - -
      - {!isNext && ( -
      +/** + * Previous / next, with the arrow on the side it points to. + * + * Two things changed when the documentation was restyled. The arrows were filled `#666666`, + * a literal grey that stayed grey on a dark page, so they are `currentColor` now and inherit + * the label's colour in both themes. And the sizing that used to be inline on the label and + * the sublabel now lives in `src/css/docs.css`, so the pagination can be restyled without + * editing a component, which is the point of having a stylesheet. + */ - - - - - Previous Article
      - )} - {isNext && ( -
      Next Article +const ARROW = { + prev: 'M8.53033 12.7803C8.23744 13.0732 7.76256 13.0732 7.46967 12.7803L3.2197 8.53033C2.9268 8.23744 2.9268 7.76256 3.2197 7.46967L7.46967 3.2197C7.76256 2.9268 8.23744 2.9268 8.53033 3.2197C8.82322 3.5126 8.82322 3.9874 8.53033 4.2803L5.5607 7.25L13 7.25C13.4142 7.25 13.75 7.58579 13.75 8C13.75 8.41421 13.4142 8.75 13 8.75H5.5607L8.53033 11.7197C8.82322 12.0126 8.82322 12.4874 8.53033 12.7803Z', + next: 'M8.21967 3.21967C8.51256 2.92678 8.98744 2.92678 9.28033 3.21967L13.5303 7.46967C13.8232 7.76256 13.8232 8.23744 13.5303 8.53033L9.28033 12.7803C8.98744 13.0732 8.51256 13.0732 8.21967 12.7803C7.92678 12.4874 7.92678 12.0126 8.21967 11.7197L11.1893 8.75H3.75C3.33579 8.75 3 8.41421 3 8C3 7.58579 3.33579 7.25 3.75 7.25H11.1893L8.21967 4.28033C7.92678 3.98744 7.92678 3.51256 8.21967 3.21967Z', +}; + +function Arrow({dir}) { + return ( + + ); +} - - - - -
      - )} -
      {title}
      - +function PaginatorNavLink({permalink, title, isNext}) { + return ( + +
      +
      + {!isNext && } + {isNext ? 'Next' : 'Previous'} + {isNext && } +
      +
      {title}
      ); } -export default PaginatorNavLink; \ No newline at end of file +export default PaginatorNavLink; diff --git a/documentation/static/ai.txt b/documentation/static/ai.txt index 58986a64..534907a1 100644 --- a/documentation/static/ai.txt +++ b/documentation/static/ai.txt @@ -18,6 +18,11 @@ Disallow: # /tirith/llms-full.txt every documentation page in one plain-text file # /tirith/.md any single page as markdown, beside its HTML route # /tirith/sitemap.xml every URL +# +# One executable file is served deliberately and is not documentation: +# /tirith/skill.sh installs the agent skill pack into a repository +# It writes eleven markdown files under .claude/skills/tirith-policies/ and does nothing +# else. Its source is documentation/static/skill.sh in the repository. Sitemap: https://stackguardian.github.io/tirith/sitemap.xml Contact: https://github.com/StackGuardian/tirith/issues diff --git a/documentation/static/docs/getting-started-with-tirith.md b/documentation/static/docs/getting-started-with-tirith.md index cbc0186d..63d9bbc5 100644 --- a/documentation/static/docs/getting-started-with-tirith.md +++ b/documentation/static/docs/getting-started-with-tirith.md @@ -7,7 +7,7 @@ Summary: Learn how Tirith simplifies security, governance, and compliance for in Explore a failing evaluation down to the resource that caused it, assemble policies from a form, and experiment in a playground with worked examples. Install it with -`pip install 'py-tirith[tui] @ git+https://github.com/StackGuardian/tirith.git'` and run +`pip install 'py-tirith[tui] @ git+https://github.com/StackGuardian/tirith.git@1.2.0'` and run `tirith ui` — see [the interactive interface](tirith-usage/interactive-interface.md). diff --git a/documentation/static/docs/tirith-installation/quick-installation.md b/documentation/static/docs/tirith-installation/quick-installation.md index 6c138ec2..e7a3f114 100644 --- a/documentation/static/docs/tirith-installation/quick-installation.md +++ b/documentation/static/docs/tirith-installation/quick-installation.md @@ -28,21 +28,39 @@ Summary: This documentation overviews you about the introduction of the Tirith s If you simply want to install and start using Tirith, this option provides a fast installation process with minimal setup. Perfect for end users and non-developers who only need basic functionality. ## Prerequisite -- Make sure your machine has [Python](https://www.python.org/downloads/) and [pip](https://pip.pypa.io/en/stable/installation/) installed. +- Make sure your machine has [Python](https://www.python.org/downloads/) 3.8 or newer and [pip](https://pip.pypa.io/en/stable/installation/) installed. - Install [Git](https://git-scm.com/downloads) on your machine. ## Steps to Install Tirith ### Step 1: Install using the `pip` command -Run the following command in your terminal to download Tirith directly from the GitHub repository and install it on your local system. This command ensures that you have the latest version. + +[DANGER] Not from PyPI +`pip install tirith` installs an **unrelated project of the same name**, and `pip install py-tirith` +finds nothing: that is the package name in `setup.py` and it is not published. Installing Tirith +means installing from git, as below. + +Run the following command in your terminal to install Tirith directly from the GitHub repository, +pinned to a released tag: + +```bash +pip install "git+https://github.com/StackGuardian/tirith.git@1.2.0" +``` + +Pin the tag rather than tracking the default branch, so an install today and an install next month +give you the same tool. `1.2.0` is the newest tag; +`git ls-remote --tags https://github.com/StackGuardian/tirith.git` lists them all. + +To use [the interactive interface](../tirith-usage/interactive-interface.md) as well, install the +optional extra, which needs Python 3.9 or newer: ```bash -pip install git+https://github.com/StackGuardian/tirith.git +pip install "py-tirith[tui] @ git+https://github.com/StackGuardian/tirith.git@1.2.0" ``` - ### Step 2: Verify Installation -Once installed, verify that Tirith is working by checking its version. You should see a version number (e.g., 1.0.0-beta.12) indicating successful installation. +Once installed, verify that Tirith is working by checking its version. You should see `1.2.0`, +which confirms both that the install succeeded and that you got the tag you asked for. ```bash tirith --version ``` diff --git a/documentation/static/docs/tirith-providers/providers-overview.md b/documentation/static/docs/tirith-providers/providers-overview.md index a3693389..e5ba5e82 100644 --- a/documentation/static/docs/tirith-providers/providers-overview.md +++ b/documentation/static/docs/tirith-providers/providers-overview.md @@ -82,3 +82,50 @@ When a provider cannot find what an operation asked for, it reports an error ins 2. **Errors without a severity value.** Some errors (an unsupported `operation_type` in the `json` and `kubernetes` providers, and all errors from the `infracost` and `sg_workflow` providers) carry no severity. These always **fail** the check, regardless of `error_tolerance`. Each provider page below lists exactly which situation produces which severity. See also [error tolerance](../tirith-policies/tirith-policy-error-tolerance.md). + +## Write one for what you actually run + +Five providers ship. That is not a claim about what is worth gating, it is a list of what has been written so far, and the interesting policies are usually about the system nobody wrote a provider for yet. + +A provider is small. It is one function: + +```python +def provide(provider_args: dict, input_data) -> list[dict]: + """Turn a document into values a condition can be run against.""" +``` + +It receives the `provider_args` from an evaluator and the parsed input document, and it returns a list of outputs: `{"value": ...}` for something a condition can judge, or `{"value": ProviderError(severity_value=1), "err": "..."}` for something it could not find. That is the entire contract. The thirteen conditions, `eval_expression`, `error_tolerance`, the result document, the exit codes and every CI integration already work on top of it. `kubernetes/handler.py` is about fifty lines, and it is a complete provider. + +[NOTE] How a provider is registered +There is no plugin discovery and no entry point to hook: `PROVIDERS_DICT` in `src/tirith/providers/__init__.py` is a literal dictionary, so a new provider is a module plus one line in that dict. In practice that means a pull request, or a fork you install from your own git URL. Making providers loadable from outside the package is a real request and worth opening an issue for if you need it. + +### What people ask for + +The pattern that makes a good provider is narrow: **a document that describes a proposed change, available before the change is applied.** If you can get that as JSON, you can gate it. + +| | | +|---|---| +| **Other IaC formats** | CloudFormation change sets, Pulumi previews, ARM and Bicep what-if output, Helm rendered templates and values | +| **Cloud and SaaS APIs** | AWS Config or Cloud Control, GCP asset inventory, Datadog monitors, PagerDuty schedules, an identity provider's roles | +| **Your own APIs** | A service catalogue, a CMDB, a deployment API, an internal platform's change request. This is the one nobody else can write for you, and it is usually where the rules that matter to your organisation live | +| **Supply chain** | An SBOM, a lockfile, a dependency manifest, image provenance and signatures | +| **Cost and capacity** | Beyond Infracost: quota headroom, commitment coverage, a chargeback model | +| **Compliance evidence** | Turning a control framework into checks that run on every change instead of once a quarter | + +### The one that does not exist yet + +Everything above is the same shape as what ships today: a plan, a manifest, an estimate. The shape holds somewhere less obvious. + +An AI agent with tools is a system that proposes changes and then applies them. Before it calls a tool, there is a document describing what it is about to do: which tool, which arguments, what it costs, what it can reach. That is a plan, in every sense that matters to a policy engine, and today almost nothing sits between an agent's intention and its action. + +**A provider for agent runtime decisions** would let the rules be written the same way the rest of your governance is: this agent may not call a tool that writes to production, may not spend beyond a threshold in one run, may not touch a resource outside its blast radius, may not act at all without a plan a human approved. The same thirteen conditions, the same expression grammar, the same verdict and exit code, evaluated before the call rather than in a review afterwards. + +This is **aspirational**. There is no such provider, it is not on the [roadmap](https://stackguardian.github.io/tirith/roadmap/) with a date, and it is written down here because it is the clearest example of the point: the engine does not care what the document is about. If you are building agent infrastructure and want a policy layer with a real evaluator behind it rather than a prompt asking a model to behave, this is worth a conversation. + +### Start one + +Open an issue describing the document you want to gate and what a rule over it would say. That is enough to work out whether it is a new provider, a new operation on an existing one, or something the `json` provider already does. + +- **[Propose a provider](https://github.com/StackGuardian/tirith/issues/new?template=feature_request.md&title=Provider%3A+)**: the system, the document, and one rule you would write +- **[Read an existing one](https://github.com/StackGuardian/tirith/tree/main/src/tirith/providers/kubernetes)**: the shortest complete example in the repository +- **[Good first issues](https://github.com/StackGuardian/tirith/labels/good%20first%20issue)**: if you would rather start somewhere smaller diff --git a/documentation/static/docs/tirith-usage/agent-skills.md b/documentation/static/docs/tirith-usage/agent-skills.md new file mode 100644 index 00000000..4913e464 --- /dev/null +++ b/documentation/static/docs/tirith-usage/agent-skills.md @@ -0,0 +1,153 @@ +# Agent Skills + +Source: https://stackguardian.github.io/tirith/docs/tirith-usage/agent-skills/ +Summary: Install the Tirith skill pack so a coding agent writes policies from the real vocabulary instead of inventing condition types that look plausible. + +An agent asked for a Tirith policy will produce one. The JSON will be well formed, the keys will +look right, and it will very often be wrong in a way that reads as correct: a condition type named +`Matches` or `Exists`, neither of which exists, or the argument key from a different provider. + +That failure is quiet. The policy parses, the evaluator does not match, and the check reports a +pass. **A rule that gates nothing looks exactly like a rule that found nothing wrong.** + +The skill pack fixes the cause: it gives the agent the closed vocabulary instead of leaving it to +guess from a plausible-looking shape. + +## Install it + +```bash +curl -fsSL https://stackguardian.github.io/tirith/skill.sh | sh +``` + +Three skills under `.claude/skills/`: `tirith-policies`, for writing policies, +`tirith-standards`, for generating a policy set from a repository you already have, and +`tirith-migrate`, for translating existing Sentinel or Azure policies. No config file, and they are picked +up in any repository you copy them into. A session that is already running may not see a newly installed +skill until it is restarted; a new session sees it immediately. + +| Flag | | +|---|---| +| `--cursor` | Also install `.cursor/rules/tirith-policies.mdc`, scoped with globs | +| `--global` | Install into `~/.claude/skills/` instead of this repository | +| `--ref REF` | Install from a branch or tag instead of `main` | +| `--help` | The same summary, from the script itself | + +The script downloads one archive of the repository, copies the skills out of it, and does nothing +else: no package is installed, no `PATH` is changed, nothing is executed after the download, and +it never touches a file it did not create. It extracts to a temporary directory and copies each +skill into place only once the whole archive has arrived, because a half-written skill is worse +than none: an agent reads whatever files exist and works from a partial vocabulary without saying +so. It needs `curl` and `tar`. + +It is [a committed file in this repository](https://github.com/StackGuardian/tirith/blob/main/documentation/static/skill.sh) +served from the same origin as this page, so the thing you pipe into a shell is the thing you can +read first. + +### Cursor + +```bash +curl -fsSL https://stackguardian.github.io/tirith/skill.sh | sh -s -- --cursor +``` + +Cursor reads a single rule file scoped with globs, so it attaches by itself the moment a policy +file is open and stays out of the way otherwise. + +### Codex, Zed, and anything reading `AGENTS.md` + +```bash +curl -fsSL https://stackguardian.github.io/tirith/skill.sh | sh +printf '\n## Tirith policies\nSee .claude/skills/tirith-policies/SKILL.md\n' >> AGENTS.md +``` + +One file at the repository root is read by a growing number of clients, and the pack beside it +keeps the references resolvable. + +## Check it worked + +Ask for a policy in plain words: *every bucket needs an Owner tag*. With the pack loaded your agent +names a real condition type and the argument key that provider actually takes. Without it, it +invents one that reads perfectly and gates nothing. + +## What is in the pack + +`SKILL.md` is the entry point and is loaded first; the references are read on demand, so a client +with a small context window pays for only what the task needs. + +| File | | +|---|---| +| `SKILL.md` | Turning an intent into valid policy JSON: provider, operation, condition, expression | +| `reference/schema.md` | The closed vocabulary. Thirteen condition types, each provider's operations, and the argument key that differs per provider | +| `reference/validate.md` | The mistakes that produce a policy which looks right and gates nothing | +| `reference/verdicts.md` | Reading a result document and an exit code | +| `reference/terraform-plan.md` | The Terraform and OpenTofu plan provider | +| `reference/other-providers.md` | Kubernetes, Infracost, JSON and StackGuardian Workflow | +| `reference/variables.md` | One policy across environments | +| `reference/install.md` | Installing Tirith, and why the install is a git URL | +| `reference/pipelines.md` | Adding the gate to six CI platforms | +| `reference/platform.md` | Evaluating an organization's policies | +| `reference/debug-ci.md` | Diagnosing a red check | +| `examples/required-tags/` | A policy, a plan that fails it and a plan that passes it, so the agent can prove its own work before it hands it back | + +## Standards from a repository you already have + +`tirith-standards` starts from the Terraform or OpenTofu you already run rather than from a +catalogue. It reads the code to work out which resource types are in use and which conventions +are already followed, proposes a set of standards with the count of resources that would pass and +fail today, and writes one policy per rule: required tags, naming conventions, allowed regions, +permitted resource types, size ceilings, encryption defaults. + +Two things in it are worth knowing even if you never install it, because they decide whether a +generated policy is usable: + +- **The policies never see your HCL.** The skill reads `.tf` files only to decide what to write + about; the rules themselves evaluate `terraform show -json`, where variables are resolved, + modules are expanded into addressed resources, and a name that is not known until apply is + `null`. A rule about a variable or a module block reads nothing. +- **A rule scoped to one resource type needs a guard.** With the default `error_tolerance` an + absent resource type is severity `1` and the check fails, so the rule is red on every plan that + does not touch that type. At `error_tolerance: 1` it is skipped instead, every check is skipped, + and `final_result` is `null`, which exits `1`. Neither is green. The skill's fix is a `count` + evaluator, which returns `0` for an absent type rather than erroring, combined with `||`. + +## Migrating from Sentinel or Azure Policy + +The third skill, `tirith-migrate`, is for teams with existing HashiCorp Sentinel or Azure Policy +definitions. It is a +projection from a larger language onto a smaller one, and the skill's job is to say what survives. +Measured against the 110 policies in HashiCorp's public libraries, 41 translate exactly, 40 +approximately, and 29 not at all. Each translation is tagged with that fidelity, every approximate +one ships a plan on which Sentinel and Tirith disagree, and every impossible one is refused in +words with the Tirith issue that would change it. For Azure Policy the reference carries an +alias-to-azurerm crosswalk, the corpus's classification of 713 built-ins, and eight verified +translations of the policies a subscription is usually assigned first. Checkov and OPA/Rego are +planned next. + +## Two things decide whether the policy actually works + +The pack teaches vocabulary. It does not run anything, and it is not a substitute for evaluating +the policy: + +1. **Give the agent `tirith` on `PATH`.** It is an ordinary command, so an agent with a shell can + evaluate its own work without a protocol server or a plugin. See + [Quick Installation](../tirith-installation/quick-intallation.md). +2. **Give it a document that should fail.** Ask for the policy *and* a plan that violates it, then + check the exit code is `3`. If it is `0`, the policy matched nothing, which is the failure this + whole page exists to prevent. The pack ships a starting pair in `examples/required-tags/`. + See [Exit codes](exit-codes.md). + +## Keeping it current + +The pack is a copy, so it does not update itself. Re-run the installer to take the current +version: + +```bash +curl -fsSL https://stackguardian.github.io/tirith/skill.sh | sh +``` + +Re-running is safe: it replaces each skill directory it owns wholesale, so a file removed upstream +does not linger, and it leaves everything else alone. + +`--ref` takes a branch or a commit, which is worth knowing for a fork or a pull request. It cannot +yet take a release tag: the pack was added after `1.2.0`, so `main` is the only ref that has it, +and asking for a tag that predates it fails with exit `1` rather than installing something +incomplete. diff --git a/documentation/static/docs/tirith-usage/ci-integration.md b/documentation/static/docs/tirith-usage/ci-integration.md index 89d8b37b..f96f4e2f 100644 --- a/documentation/static/docs/tirith-usage/ci-integration.md +++ b/documentation/static/docs/tirith-usage/ci-integration.md @@ -31,17 +31,26 @@ permissions: checks: write # check run steps: - - run: | - terraform plan -out=tfplan -input=false - terraform show -json tfplan > plan.json + - run: terraform plan -out=tfplan -input=false - uses: StackGuardian/tirith-iac-governance-action@v2 + with: + plan-file: tfplan + fail-on-error: true ``` -With a `plan.json` in the working directory that is the whole integration — no `with:` block. The -action finds the document by convention (`plan.json` or `tfplan.json`) and evaluates the policy -files committed under `.tirith/policies`, on the runner, talking to nothing. Add -`with: { fail-on-error: true }` to make a failing policy fail the job. +The two write permissions are the only setup the action cannot do for itself, and are the thing +most often missing on a first install. `-input=false` matters in CI: without it a missing variable +waits for a prompt that never comes, and the job hangs instead of failing. + +Handing the action the **binary plan** rather than exporting JSON first is one step shorter and +strictly safer: the action renders it with `terraform show -json` in memory, so no unmasked plan +JSON is written to the workspace where a later step, a cache or an artifact upload could pick it +up. + +If your pipeline already writes `plan.json`, drop `plan-file` and the action finds the document by +convention (`plan.json` or `tfplan.json`). Either way it evaluates the policy files committed under +`.tirith/policies`, on the runner, talking to nothing. ### Local mode and platform mode @@ -170,13 +179,18 @@ pipelines: - step: name: Policy gate script: - - pip install "git+https://github.com/StackGuardian/tirith.git@1.2.0" - - tirith lint .tirith/policies # needs a build from main until the next release + # @main rather than @1.2.0, because the lint step below is not in 1.2.0 yet. + - pip install "git+https://github.com/StackGuardian/tirith.git@main" + - tirith lint .tirith/policies - tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error ``` -A complete file is in [`examples/ci/bitbucket-pipelines.yml`](https://github.com/StackGuardian/tirith/blob/main/examples/ci/bitbucket-pipelines.yml), -and a worked repository is at +Linting first is cheap and it fails for a different reason than the gate does: a policy that is +malformed never gets as far as disagreeing with your infrastructure. It needs no plan document, +so it can also run in a job that has no cloud credentials at all. See +[lint and format](lint-and-fmt.md). + +A worked repository is at [tirith-bitbucket-demo](https://bitbucket.org/__refeed__/tirith-bitbucket-demo). ## Jenkins @@ -203,18 +217,22 @@ stage('Policy gate') { } ``` -The full pipeline, including install, lint and artifact archiving, is in -[`examples/ci/Jenkinsfile`](https://github.com/StackGuardian/tirith/blob/main/examples/ci/Jenkinsfile). +`returnStatus: true` is what makes this work: without it the shell step throws on any non-zero +exit and the two cases become one. ## As a pre-commit hook +[NOTE] Not in 1.2.0 +`tirith lint` and `tirith fmt` are on `main` and arrive in the next release, so pin `rev` to a +branch until then. Everything else on this page works on 1.2.0. + Catch a broken policy before it is committed, let alone before CI runs it. Tirith publishes a -`tirith-lint` hook: +`tirith-lint` and a `tirith-fmt` hook: ```yaml repos: - repo: https://github.com/StackGuardian/tirith - rev: main # 1.2.0 predates the hook; pin the first tag that includes it + rev: main hooks: - id: tirith-lint - id: tirith-fmt @@ -222,12 +240,12 @@ repos: ```bash pre-commit install -pre-commit run tirith-lint --all-files +pre-commit run --all-files ``` -The hooks run only when a file under `.tirith/` or a `*.tirith.json` changes, and lint exactly the -files that changed. `tirith-fmt` rewrites them into the canonical layout; commit again after it -does. +Both hooks run only when a file under `.tirith/` or a `*.tirith.json` changes, and both are +handed the individual changed files rather than the whole directory: `tirith lint` and +`tirith fmt` each take any number of paths, so a commit touching one policy checks one policy. [NOTE] Why linting and not evaluation Evaluating a policy needs a plan document, and producing one means running `terraform plan` — diff --git a/documentation/static/docs/tirith-usage/cli-reference.md b/documentation/static/docs/tirith-usage/cli-reference.md index 23511a93..5bd0d041 100644 --- a/documentation/static/docs/tirith-usage/cli-reference.md +++ b/documentation/static/docs/tirith-usage/cli-reference.md @@ -12,9 +12,23 @@ tirith -policy-path policy.json -input-path plan.json Run with no arguments, `tirith` prints its help text and exits `0`. -There is one subcommand, `tirith platform check`, which evaluates against the policies a -StackGuardian organization enforces instead of local files. It has its own flags and its own page: -[Platform Check](platform-check.md). +Four subcommands sit beside it, each with its own flags and its own page: + +| Subcommand | What it does | +|---|---| +| [`tirith lint`](lint-and-fmt.md) | Check policy files for mistakes that would gate nothing or fail as a false violation. No plan document needed | +| [`tirith fmt`](lint-and-fmt.md) | Rewrite policy files into the canonical layout | +| [`tirith ui`](interactive-interface.md) | Explore results, build policies and experiment in an interactive interface. Needs the optional `tui` extra | +| [`tirith platform check`](platform-check.md) | Evaluate against the policies a StackGuardian organization enforces instead of local files | + +A first argument that is not one of those four and does not begin with a dash is rejected by name, +because the flat command below takes no positional arguments: + +```console +$ tirith lnit .tirith/policies +tirith: 'lnit' is not a tirith command. Commands: fmt, lint, platform, ui. +Run 'tirith --help' for the local-evaluation options. +``` ## Flags diff --git a/documentation/static/docs/tirith-usage/editor-and-local.md b/documentation/static/docs/tirith-usage/editor-and-local.md index f11b37f7..f3dfdef1 100644 --- a/documentation/static/docs/tirith-usage/editor-and-local.md +++ b/documentation/static/docs/tirith-usage/editor-and-local.md @@ -4,14 +4,13 @@ Source: https://stackguardian.github.io/tirith/docs/tirith-usage/editor-and-loca Summary: VS Code tasks, a pre-commit hook, and the local loop to use when an AI agent is drafting the policy. [NOTE] Not in 1.2.0 +`tirith lint` and `tirith fmt` are on `main` and will arrive in the next release. Until then, +install from the branch rather than the tag: +`pip install "git+https://github.com/StackGuardian/tirith.git@main"`. Evaluation, the second half +of the loop, works on any installed version. -`tirith lint`, `tirith fmt` and the pre-commit hooks are on `main` and will be in the next -release. `pip install "git+https://github.com/StackGuardian/tirith.git@1.2.0"` does not have them; -install from `main` until then. - - -CI is the last place a policy should fail. This page is about the loop before that — running -Tirith on your own machine, while the code is still being written. +CI is the last place a policy should fail. This page is about the loop before that, running +Tirith on your own machine while the code is still being written. It matters most when an agent is drafting the policy for you. Generated policy JSON is plausible by construction: it parses, it looks right, and a policy that matches nothing is indistinguishable @@ -20,13 +19,15 @@ from one that works until you evaluate it. ## The loop ```bash +tirith fmt .tirith/policies # layout tirith lint .tirith/policies # shape tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error # meaning ``` -Linting checks the **shape** — that every condition type exists, that no `provider_args` key -belongs to a different provider, that `eval_expression` names every evaluator. Only evaluation -checks the **meaning**. +Linting checks the **shape**: that every condition type exists, that no `provider_args` key +belongs to a different provider, that `eval_expression` names every evaluator, that +`error_tolerance` is inside `condition` where the engine reads it. Only evaluation checks the +**meaning**. [Lint and format](lint-and-fmt.md) covers both commands in full. Run both against a document that *should* fail. A guardrail only ever seen passing is a guardrail nobody has tested. @@ -39,6 +40,13 @@ Drop this in `.vscode/tasks.json` and the loop becomes one keystroke: { "version": "2.0.0", "tasks": [ + { + "label": "Tirith: format policies", + "type": "shell", + "command": "tirith fmt .tirith/policies", + "problemMatcher": [], + "group": "test" + }, { "label": "Tirith: lint policies", "type": "shell", @@ -59,16 +67,17 @@ Drop this in `.vscode/tasks.json` and the loop becomes one keystroke: ``` `Tirith: check the plan` is the default test task, so **⇧⌘B** / **Ctrl+Shift+B** runs the whole -loop. The complete file — including a task to refresh `plan.json` and one to open the result in -the interactive explorer — is -[`.vscode/tasks.json`](https://github.com/StackGuardian/tirith/blob/main/.vscode/tasks.json). +loop: lint first, then evaluate, because `dependsOn` will not run the second if the first exits +non-zero. ## Catch it at commit time +Tirith publishes both commands as pre-commit hooks: + ```yaml repos: - repo: https://github.com/StackGuardian/tirith - rev: main # 1.2.0 predates the hooks; pin the first tag that includes them + rev: main hooks: - id: tirith-lint - id: tirith-fmt @@ -78,8 +87,9 @@ repos: pre-commit install ``` -The hook runs only when a policy file changes, needs no network, and exits `3` when a policy has -an error. See [CI integration](ci-integration.md) for why it lints rather than evaluates. +Both run only on the policy files a commit actually touches, need no network, and exit `3` on a +finding. See [CI integration](ci-integration.md) for why they lint rather than evaluate, and +[lint and format](lint-and-fmt.md) for the flags and the files they read. ## When an agent is writing the policy @@ -87,13 +97,11 @@ Install the Tirith skill and your agent gets the closed condition list, the argu provider reads, and the instruction to run a policy before claiming it works: ```bash -mkdir -p .claude/skills/tirith-policies/reference -BASE=https://raw.githubusercontent.com/StackGuardian/tirith/main/.claude/skills/tirith-policies -curl -sL $BASE/SKILL.md -o .claude/skills/tirith-policies/SKILL.md +curl -fsSL https://stackguardian.github.io/tirith/skill.sh | sh ``` -Cursor reads `.cursor/rules/tirith-policies.mdc` instead, scoped with globs so it attaches by -itself when a policy file is open. +Add `--cursor` for the Cursor rule. [Agent Skills](agent-skills.md) covers what is in the pack, +the other clients, and how to tell whether it took effect. Two things make the difference between a drafted policy and a working one: diff --git a/documentation/static/docs/tirith-usage/exit-codes.md b/documentation/static/docs/tirith-usage/exit-codes.md index 3e17a977..1dd150d6 100644 --- a/documentation/static/docs/tirith-usage/exit-codes.md +++ b/documentation/static/docs/tirith-usage/exit-codes.md @@ -3,18 +3,21 @@ Source: https://stackguardian.github.io/tirith/docs/tirith-usage/exit-codes/ Summary: The complete Tirith exit-code contract, and how to gate a CI job on it. -Tirith's exit codes are a contract shared by both surfaces — local evaluation (`tirith`) and -platform evaluation (`tirith platform check`) — so a caller scripting both only has to learn one -vocabulary. +Tirith's exit codes are one contract shared by every surface: local evaluation (`tirith`), +platform evaluation (`tirith platform check`), and the two file commands, `tirith lint` and +`tirith fmt`. A caller scripting more than one only has to learn a single vocabulary. | Code | Meaning | |---|---| -| `0` | Policies passed, or nothing was in scope to gate on | -| `1` | Tirith could not complete the evaluation — bad input, a policy it could not evaluate, unreachable API | -| `2` | Timed out waiting for a StackGuardian run (`tirith platform check` only; local evaluation never produces it) | -| `3` | A policy failed. Only with `--fail-on-error`, on either surface | +| `0` | Policies passed, nothing was in scope to gate on, or the files were clean | +| `1` | Tirith could not complete the run: bad input, a policy it could not evaluate, an unreachable API, a path that does not exist | +| `3` | A policy said no. On the evaluation surfaces only with `--fail-on-error`; `lint` and `fmt` need no flag | | `130` | Interrupted (Ctrl-C) | +`2` is deliberately absent. It used to be a timeout code that nothing ever returned, and +`argparse` already exits `2` of its own accord on a usage error, so a caller seeing `2` has +passed a bad argument. Do not write a pipeline that branches on it. + ## `3` is deliberately not `1` `3` means a check ran and said no: your infrastructure violates a policy. `1` means Tirith could @@ -28,6 +31,21 @@ Both surfaces **fail closed**: anything that leaves the verdict unknown exits no of `--fail-on-error`. That flag governs policy verdicts, not tool health — a run that produced no verdict must never look like a pass. +## `tirith lint` and `tirith fmt` + +Both use the same three codes, and neither takes `--fail-on-error`: a linter that cannot fail is +not a linter, and there is no existing green pipeline to protect because nothing has run them +before. + +| Command | `0` | `3` | `1` | +|---|---|---|---| +| `tirith lint` | every policy is clean | a policy has an error-level finding, or a warning under `--strict` | a path is missing, or nothing was found to lint | +| `tirith fmt` | nothing to change, or the changes were written | `--check` found files that would change | a path is missing, a file is not valid JSON, or nothing was found | + +"Nothing was found" being `1` rather than `0` is the same fail-closed rule the evaluation +surfaces follow. A hook pointed at the wrong directory reports a problem instead of quietly +passing every commit. See [lint and format](lint-and-fmt.md). + ## Without `--fail-on-error` The local command exits `0` whether the policy passed or failed, with the verdict in the output. diff --git a/documentation/static/docs/tirith-usage/interactive-interface.md b/documentation/static/docs/tirith-usage/interactive-interface.md index 49a56c66..2ffcd556 100644 --- a/documentation/static/docs/tirith-usage/interactive-interface.md +++ b/documentation/static/docs/tirith-usage/interactive-interface.md @@ -23,7 +23,7 @@ pipeline should pay to install an interface they never open. It needs Python 3.9 Tirith itself still supports 3.8. ```bash -pip install 'py-tirith[tui] @ git+https://github.com/StackGuardian/tirith.git' +pip install 'py-tirith[tui] @ git+https://github.com/StackGuardian/tirith.git@1.2.0' ``` Tirith is not on PyPI — `pip install py-tirith` finds nothing and `pip install tirith` installs an diff --git a/documentation/static/docs/tirith-usage/lint-and-fmt.md b/documentation/static/docs/tirith-usage/lint-and-fmt.md new file mode 100644 index 00000000..7857ca80 --- /dev/null +++ b/documentation/static/docs/tirith-usage/lint-and-fmt.md @@ -0,0 +1,205 @@ +# Lint and format + +Source: https://stackguardian.github.io/tirith/docs/tirith-usage/lint-and-fmt/ +Summary: tirith lint checks policy files for mistakes that would gate nothing or fail as a false violation. tirith fmt rewrites them into one canonical layout. Neither needs a plan document. + +Two commands that read policy files and nothing else. Neither needs a plan document, an account, +or a network call, which is what lets them run in a pre-commit hook and in a slim CI image without +the optional `tui` extra. + +```bash +tirith lint .tirith/policies # is the policy well formed? +tirith fmt .tirith/policies # rewrite it into the canonical layout +``` + +[NOTE] Not in 1.2.0 +Both commands are on `main` and will arrive in the next release. To use them today, install from +the branch rather than the `1.2.0` tag: + +```bash +pip install "git+https://github.com/StackGuardian/tirith.git@main" +``` + +## `tirith lint` + +Lint checks the **shape**, not the meaning. It runs the same validator the +[interactive interface](interactive-interface.md) runs on every keystroke, so the two never +disagree, and it catches the class of mistake that otherwise reaches CI looking like a real +infrastructure violation: + +- a condition `type` that does not exist, against the closed list of + [thirteen conditions](../tirith-reference/evaluators.md) +- a `provider_args` key that belongs to a different provider than the one in + `meta.required_provider` +- an evaluator that `eval_expression` never names, so it is computed and then discarded +- `error_tolerance` placed on the evaluator instead of inside `condition`, where it is silently + ignored and the check still fails + +What lint cannot tell you is whether a well-formed policy matches anything. Only evaluating it +against a document that *should* fail answers that: + +```bash +tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error +``` + +### Usage + +``` +tirith lint [PATH ...] [--json] [--strict] [--quiet] +``` + +| Flag | What it does | +|---|---| +| `--json` | Print the report as a JSON document instead of text | +| `--strict` | Treat warnings as errors when deciding the exit code | +| `--quiet` | Print findings only, with no summary line and no skipped files | + +Findings are printed one per line, in a form an editor or a log scraper can parse: + +``` +.tirith/policies/s3.json:evaluators[0].condition: error: unknown condition type 'EqualTo' +.tirith/policies/s3.json:eval_expression: warning: evaluator 'bucket_acl' is never referenced +2 policies, 1 errors, 1 warnings +``` + +`--json` returns the same report structured, so an editor integration or another program does not +have to parse the text: + +```json +{ + "files": [ + { + "path": ".tirith/policies/s3.json", + "findings": [ + {"severity": "error", "where": "evaluators[0].condition", "message": "unknown condition type 'EqualTo'"} + ] + } + ], + "skipped": [], + "missing": [], + "summary": {"policies": 2, "errors": 1, "warnings": 1, "ignored": 0}, + "exit_status": 3 +} +``` + +### Exit codes + +| Code | Meaning | +|---|---| +| `0` | Every policy is clean | +| `3` | A policy has an error-level finding, or a warning under `--strict` | +| `1` | A path does not exist, or nothing was found to lint | + +`3` rather than `1` for a bad policy is deliberate, and matches the rest of Tirith: the linter +saying no about a policy is a verdict, not a tool failure. See [exit codes](exit-codes.md). + +## `tirith fmt` + +Format rewrites a policy into one canonical layout, so that two people writing the same policy +produce the same bytes and a diff shows a change of meaning rather than a change of key order. + +It reorders keys and normalises whitespace. It never changes a value, adds a key, or reorders a +list, and the result parses back to a document equal to the one it read. + +### Usage + +``` +tirith fmt [PATH ...] [--check] [--diff] +``` + +| Flag | What it does | +|---|---| +| `--check` | Do not write. Exit `3` if any file would change | +| `--diff` | Print a unified diff of what would change. Implies `--check` | + +With no flags it rewrites the files in place and prints the path of each one it changed. + +### The layout + +Whitespace is exactly `json.dumps(indent=2, ensure_ascii=False)` plus one trailing newline, which +means a policy written by any tool using the Python standard library is already canonical without +knowing `fmt` exists. Non-ASCII characters stay as written rather than being escaped. + +Keys are ordered: + +| Object | Order | +|---|---| +| top level | `$schema`, `meta`, `evaluators`, `eval_expression` | +| `meta` | `version`, `required_provider`, `id`, `name`, `description`, `severity`, `enforcement`, `tags`, `remediation` | +| each evaluator | `id`, `description`, `provider_args`, `condition` | +| `provider_args` | `operation_type` first, then the provider's own keys in their original order | +| `condition` | `type`, `value`, `error_tolerance` | + +A key the document has that is not in these lists keeps its original relative order after the +listed ones, so an unrecognised key is preserved rather than dropped or sorted somewhere +surprising. + +### Exit codes + +| Code | Meaning | +|---|---| +| `0` | Nothing to change, or the changes were written | +| `3` | `--check` found files that would change | +| `1` | A path is missing, a file is not valid JSON, or nothing was found | + +## Which files they read + +Both commands take any number of paths, and both default to the same place when given none: +`.tirith/policies` if that directory exists, otherwise the current directory. + +A directory is searched recursively for `*.json`. A **policy** is a JSON object with at least one +of the three top-level keys the engine reads, `meta`, `evaluators` or `eval_expression`; anything +else that parses as JSON is left alone. That is what lets you point either command at a directory +that also holds a `package.json` or a plan document without producing findings about files that +were never policies. + +- A JSON file named **explicitly on the command line** that is not a policy is reported as skipped + by name, so a typo'd path to a plan document does not read as a clean run. +- A JSON file met **while walking a directory** that is not a policy is counted rather than + listed. Input documents living beside their policies are expected there. +- A `.json` file inside a policy directory that does not parse at all is reported as an error, not + ignored. An unparseable policy is a defect. + +These directories are never descended into: `.git`, `.terraform`, `node_modules`, `__pycache__`, +`.venv`, `venv`. Note that `.tirith` is deliberately not among them, since that is where policies +conventionally live and skipping dot-directories wholesale would skip the one that matters. + +## In a pre-commit hook + +This repository publishes both commands as hooks, so a commit that touches one policy lints and +formats that one policy: + +```yaml +repos: + - repo: https://github.com/StackGuardian/tirith + rev: main + hooks: + - id: tirith-lint + - id: tirith-fmt +``` + +```bash +pre-commit install +pre-commit run --all-files +``` + +Both hooks match `.tirith/*.json` and `*.tirith.json` by default. Override `files` if your +policies live somewhere else. + +Linting at commit time rather than evaluating is a deliberate choice. Evaluating needs a plan +document, and producing one means running `terraform plan`, which is too slow for a commit hook +and needs cloud credentials a hook has no business holding. See +[CI integration](ci-integration.md) for the full argument, and for where evaluation belongs. + +## In CI + +`fmt --check` is the CI shape, next to whatever else already checks formatting: + +```bash +tirith fmt --check .tirith/policies +tirith lint .tirith/policies +``` + +Both exit `3` on a finding, so a pipeline that already distinguishes `3` from `1` treats a badly +formatted or invalid policy exactly as it treats a policy violation, and a missing path as the +tooling problem it is. diff --git a/documentation/static/llms-full.txt b/documentation/static/llms-full.txt index 1125c52f..a9b0886e 100644 --- a/documentation/static/llms-full.txt +++ b/documentation/static/llms-full.txt @@ -18,7 +18,7 @@ Summary: Learn how Tirith simplifies security, governance, and compliance for in Explore a failing evaluation down to the resource that caused it, assemble policies from a form, and experiment in a playground with worked examples. Install it with -`pip install 'py-tirith[tui] @ git+https://github.com/StackGuardian/tirith.git'` and run +`pip install 'py-tirith[tui] @ git+https://github.com/StackGuardian/tirith.git@1.2.0'` and run `tirith ui` — see [the interactive interface](tirith-usage/interactive-interface.md). @@ -73,21 +73,39 @@ Summary: This documentation overviews you about the introduction of the Tirith s If you simply want to install and start using Tirith, this option provides a fast installation process with minimal setup. Perfect for end users and non-developers who only need basic functionality. ## Prerequisite -- Make sure your machine has [Python](https://www.python.org/downloads/) and [pip](https://pip.pypa.io/en/stable/installation/) installed. +- Make sure your machine has [Python](https://www.python.org/downloads/) 3.8 or newer and [pip](https://pip.pypa.io/en/stable/installation/) installed. - Install [Git](https://git-scm.com/downloads) on your machine. ## Steps to Install Tirith ### Step 1: Install using the `pip` command -Run the following command in your terminal to download Tirith directly from the GitHub repository and install it on your local system. This command ensures that you have the latest version. + +[DANGER] Not from PyPI +`pip install tirith` installs an **unrelated project of the same name**, and `pip install py-tirith` +finds nothing: that is the package name in `setup.py` and it is not published. Installing Tirith +means installing from git, as below. + +Run the following command in your terminal to install Tirith directly from the GitHub repository, +pinned to a released tag: + +```bash +pip install "git+https://github.com/StackGuardian/tirith.git@1.2.0" +``` + +Pin the tag rather than tracking the default branch, so an install today and an install next month +give you the same tool. `1.2.0` is the newest tag; +`git ls-remote --tags https://github.com/StackGuardian/tirith.git` lists them all. + +To use [the interactive interface](../tirith-usage/interactive-interface.md) as well, install the +optional extra, which needs Python 3.9 or newer: ```bash -pip install git+https://github.com/StackGuardian/tirith.git +pip install "py-tirith[tui] @ git+https://github.com/StackGuardian/tirith.git@1.2.0" ``` - ### Step 2: Verify Installation -Once installed, verify that Tirith is working by checking its version. You should see a version number (e.g., 1.0.0-beta.12) indicating successful installation. +Once installed, verify that Tirith is working by checking its version. You should see `1.2.0`, +which confirms both that the install succeeded and that you got the tag you asked for. ```bash tirith --version ``` @@ -1367,6 +1385,53 @@ When a provider cannot find what an operation asked for, it reports an error ins Each provider page below lists exactly which situation produces which severity. See also [error tolerance](../tirith-policies/tirith-policy-error-tolerance.md). +## Write one for what you actually run + +Five providers ship. That is not a claim about what is worth gating, it is a list of what has been written so far, and the interesting policies are usually about the system nobody wrote a provider for yet. + +A provider is small. It is one function: + +```python +def provide(provider_args: dict, input_data) -> list[dict]: + """Turn a document into values a condition can be run against.""" +``` + +It receives the `provider_args` from an evaluator and the parsed input document, and it returns a list of outputs: `{"value": ...}` for something a condition can judge, or `{"value": ProviderError(severity_value=1), "err": "..."}` for something it could not find. That is the entire contract. The thirteen conditions, `eval_expression`, `error_tolerance`, the result document, the exit codes and every CI integration already work on top of it. `kubernetes/handler.py` is about fifty lines, and it is a complete provider. + +[NOTE] How a provider is registered +There is no plugin discovery and no entry point to hook: `PROVIDERS_DICT` in `src/tirith/providers/__init__.py` is a literal dictionary, so a new provider is a module plus one line in that dict. In practice that means a pull request, or a fork you install from your own git URL. Making providers loadable from outside the package is a real request and worth opening an issue for if you need it. + +### What people ask for + +The pattern that makes a good provider is narrow: **a document that describes a proposed change, available before the change is applied.** If you can get that as JSON, you can gate it. + +| | | +|---|---| +| **Other IaC formats** | CloudFormation change sets, Pulumi previews, ARM and Bicep what-if output, Helm rendered templates and values | +| **Cloud and SaaS APIs** | AWS Config or Cloud Control, GCP asset inventory, Datadog monitors, PagerDuty schedules, an identity provider's roles | +| **Your own APIs** | A service catalogue, a CMDB, a deployment API, an internal platform's change request. This is the one nobody else can write for you, and it is usually where the rules that matter to your organisation live | +| **Supply chain** | An SBOM, a lockfile, a dependency manifest, image provenance and signatures | +| **Cost and capacity** | Beyond Infracost: quota headroom, commitment coverage, a chargeback model | +| **Compliance evidence** | Turning a control framework into checks that run on every change instead of once a quarter | + +### The one that does not exist yet + +Everything above is the same shape as what ships today: a plan, a manifest, an estimate. The shape holds somewhere less obvious. + +An AI agent with tools is a system that proposes changes and then applies them. Before it calls a tool, there is a document describing what it is about to do: which tool, which arguments, what it costs, what it can reach. That is a plan, in every sense that matters to a policy engine, and today almost nothing sits between an agent's intention and its action. + +**A provider for agent runtime decisions** would let the rules be written the same way the rest of your governance is: this agent may not call a tool that writes to production, may not spend beyond a threshold in one run, may not touch a resource outside its blast radius, may not act at all without a plan a human approved. The same thirteen conditions, the same expression grammar, the same verdict and exit code, evaluated before the call rather than in a review afterwards. + +This is **aspirational**. There is no such provider, it is not on the [roadmap](https://stackguardian.github.io/tirith/roadmap/) with a date, and it is written down here because it is the clearest example of the point: the engine does not care what the document is about. If you are building agent infrastructure and want a policy layer with a real evaluator behind it rather than a prompt asking a model to behave, this is worth a conversation. + +### Start one + +Open an issue describing the document you want to gate and what a rule over it would say. That is enough to work out whether it is a new provider, a new operation on an existing one, or something the `json` provider already does. + +- **[Propose a provider](https://github.com/StackGuardian/tirith/issues/new?template=feature_request.md&title=Provider%3A+)**: the system, the document, and one rule you would write +- **[Read an existing one](https://github.com/StackGuardian/tirith/tree/main/src/tirith/providers/kubernetes)**: the shortest complete example in the repository +- **[Good first issues](https://github.com/StackGuardian/tirith/labels/good%20first%20issue)**: if you would rather start somewhere smaller + ============================================================================== # Terraform Plan Provider @@ -2653,9 +2718,23 @@ tirith -policy-path policy.json -input-path plan.json Run with no arguments, `tirith` prints its help text and exits `0`. -There is one subcommand, `tirith platform check`, which evaluates against the policies a -StackGuardian organization enforces instead of local files. It has its own flags and its own page: -[Platform Check](platform-check.md). +Four subcommands sit beside it, each with its own flags and its own page: + +| Subcommand | What it does | +|---|---| +| [`tirith lint`](lint-and-fmt.md) | Check policy files for mistakes that would gate nothing or fail as a false violation. No plan document needed | +| [`tirith fmt`](lint-and-fmt.md) | Rewrite policy files into the canonical layout | +| [`tirith ui`](interactive-interface.md) | Explore results, build policies and experiment in an interactive interface. Needs the optional `tui` extra | +| [`tirith platform check`](platform-check.md) | Evaluate against the policies a StackGuardian organization enforces instead of local files | + +A first argument that is not one of those four and does not begin with a dash is rejected by name, +because the flat command below takes no positional arguments: + +```console +$ tirith lnit .tirith/policies +tirith: 'lnit' is not a tirith command. Commands: fmt, lint, platform, ui. +Run 'tirith --help' for the local-evaluation options. +``` ## Flags @@ -2784,24 +2863,235 @@ Prints the version number (for example `1.2.0`) and exits `0`. This split is deliberate so that redirecting stdout captures only the verdict. +============================================================================== +# Lint and format +Source: https://stackguardian.github.io/tirith/docs/tirith-usage/lint-and-fmt/ +Summary: tirith lint checks policy files for mistakes that would gate nothing or fail as a false violation. tirith fmt rewrites them into one canonical layout. Neither needs a plan document. +============================================================================== + +Two commands that read policy files and nothing else. Neither needs a plan document, an account, +or a network call, which is what lets them run in a pre-commit hook and in a slim CI image without +the optional `tui` extra. + +```bash +tirith lint .tirith/policies # is the policy well formed? +tirith fmt .tirith/policies # rewrite it into the canonical layout +``` + +[NOTE] Not in 1.2.0 +Both commands are on `main` and will arrive in the next release. To use them today, install from +the branch rather than the `1.2.0` tag: + +```bash +pip install "git+https://github.com/StackGuardian/tirith.git@main" +``` + +## `tirith lint` + +Lint checks the **shape**, not the meaning. It runs the same validator the +[interactive interface](interactive-interface.md) runs on every keystroke, so the two never +disagree, and it catches the class of mistake that otherwise reaches CI looking like a real +infrastructure violation: + +- a condition `type` that does not exist, against the closed list of + [thirteen conditions](../tirith-reference/evaluators.md) +- a `provider_args` key that belongs to a different provider than the one in + `meta.required_provider` +- an evaluator that `eval_expression` never names, so it is computed and then discarded +- `error_tolerance` placed on the evaluator instead of inside `condition`, where it is silently + ignored and the check still fails + +What lint cannot tell you is whether a well-formed policy matches anything. Only evaluating it +against a document that *should* fail answers that: + +```bash +tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error +``` + +### Usage + +``` +tirith lint [PATH ...] [--json] [--strict] [--quiet] +``` + +| Flag | What it does | +|---|---| +| `--json` | Print the report as a JSON document instead of text | +| `--strict` | Treat warnings as errors when deciding the exit code | +| `--quiet` | Print findings only, with no summary line and no skipped files | + +Findings are printed one per line, in a form an editor or a log scraper can parse: + +``` +.tirith/policies/s3.json:evaluators[0].condition: error: unknown condition type 'EqualTo' +.tirith/policies/s3.json:eval_expression: warning: evaluator 'bucket_acl' is never referenced +2 policies, 1 errors, 1 warnings +``` + +`--json` returns the same report structured, so an editor integration or another program does not +have to parse the text: + +```json +{ + "files": [ + { + "path": ".tirith/policies/s3.json", + "findings": [ + {"severity": "error", "where": "evaluators[0].condition", "message": "unknown condition type 'EqualTo'"} + ] + } + ], + "skipped": [], + "missing": [], + "summary": {"policies": 2, "errors": 1, "warnings": 1, "ignored": 0}, + "exit_status": 3 +} +``` + +### Exit codes + +| Code | Meaning | +|---|---| +| `0` | Every policy is clean | +| `3` | A policy has an error-level finding, or a warning under `--strict` | +| `1` | A path does not exist, or nothing was found to lint | + +`3` rather than `1` for a bad policy is deliberate, and matches the rest of Tirith: the linter +saying no about a policy is a verdict, not a tool failure. See [exit codes](exit-codes.md). + +## `tirith fmt` + +Format rewrites a policy into one canonical layout, so that two people writing the same policy +produce the same bytes and a diff shows a change of meaning rather than a change of key order. + +It reorders keys and normalises whitespace. It never changes a value, adds a key, or reorders a +list, and the result parses back to a document equal to the one it read. + +### Usage + +``` +tirith fmt [PATH ...] [--check] [--diff] +``` + +| Flag | What it does | +|---|---| +| `--check` | Do not write. Exit `3` if any file would change | +| `--diff` | Print a unified diff of what would change. Implies `--check` | + +With no flags it rewrites the files in place and prints the path of each one it changed. + +### The layout + +Whitespace is exactly `json.dumps(indent=2, ensure_ascii=False)` plus one trailing newline, which +means a policy written by any tool using the Python standard library is already canonical without +knowing `fmt` exists. Non-ASCII characters stay as written rather than being escaped. + +Keys are ordered: + +| Object | Order | +|---|---| +| top level | `$schema`, `meta`, `evaluators`, `eval_expression` | +| `meta` | `version`, `required_provider`, `id`, `name`, `description`, `severity`, `enforcement`, `tags`, `remediation` | +| each evaluator | `id`, `description`, `provider_args`, `condition` | +| `provider_args` | `operation_type` first, then the provider's own keys in their original order | +| `condition` | `type`, `value`, `error_tolerance` | + +A key the document has that is not in these lists keeps its original relative order after the +listed ones, so an unrecognised key is preserved rather than dropped or sorted somewhere +surprising. + +### Exit codes + +| Code | Meaning | +|---|---| +| `0` | Nothing to change, or the changes were written | +| `3` | `--check` found files that would change | +| `1` | A path is missing, a file is not valid JSON, or nothing was found | + +## Which files they read + +Both commands take any number of paths, and both default to the same place when given none: +`.tirith/policies` if that directory exists, otherwise the current directory. + +A directory is searched recursively for `*.json`. A **policy** is a JSON object with at least one +of the three top-level keys the engine reads, `meta`, `evaluators` or `eval_expression`; anything +else that parses as JSON is left alone. That is what lets you point either command at a directory +that also holds a `package.json` or a plan document without producing findings about files that +were never policies. + +- A JSON file named **explicitly on the command line** that is not a policy is reported as skipped + by name, so a typo'd path to a plan document does not read as a clean run. +- A JSON file met **while walking a directory** that is not a policy is counted rather than + listed. Input documents living beside their policies are expected there. +- A `.json` file inside a policy directory that does not parse at all is reported as an error, not + ignored. An unparseable policy is a defect. + +These directories are never descended into: `.git`, `.terraform`, `node_modules`, `__pycache__`, +`.venv`, `venv`. Note that `.tirith` is deliberately not among them, since that is where policies +conventionally live and skipping dot-directories wholesale would skip the one that matters. + +## In a pre-commit hook + +This repository publishes both commands as hooks, so a commit that touches one policy lints and +formats that one policy: + +```yaml +repos: + - repo: https://github.com/StackGuardian/tirith + rev: main + hooks: + - id: tirith-lint + - id: tirith-fmt +``` + +```bash +pre-commit install +pre-commit run --all-files +``` + +Both hooks match `.tirith/*.json` and `*.tirith.json` by default. Override `files` if your +policies live somewhere else. + +Linting at commit time rather than evaluating is a deliberate choice. Evaluating needs a plan +document, and producing one means running `terraform plan`, which is too slow for a commit hook +and needs cloud credentials a hook has no business holding. See +[CI integration](ci-integration.md) for the full argument, and for where evaluation belongs. + +## In CI + +`fmt --check` is the CI shape, next to whatever else already checks formatting: + +```bash +tirith fmt --check .tirith/policies +tirith lint .tirith/policies +``` + +Both exit `3` on a finding, so a pipeline that already distinguishes `3` from `1` treats a badly +formatted or invalid policy exactly as it treats a policy violation, and a missing path as the +tooling problem it is. + + ============================================================================== # Exit Codes Source: https://stackguardian.github.io/tirith/docs/tirith-usage/exit-codes/ Summary: The complete Tirith exit-code contract, and how to gate a CI job on it. ============================================================================== -Tirith's exit codes are a contract shared by both surfaces — local evaluation (`tirith`) and -platform evaluation (`tirith platform check`) — so a caller scripting both only has to learn one -vocabulary. +Tirith's exit codes are one contract shared by every surface: local evaluation (`tirith`), +platform evaluation (`tirith platform check`), and the two file commands, `tirith lint` and +`tirith fmt`. A caller scripting more than one only has to learn a single vocabulary. | Code | Meaning | |---|---| -| `0` | Policies passed, or nothing was in scope to gate on | -| `1` | Tirith could not complete the evaluation — bad input, a policy it could not evaluate, unreachable API | -| `2` | Timed out waiting for a StackGuardian run (`tirith platform check` only; local evaluation never produces it) | -| `3` | A policy failed. Only with `--fail-on-error`, on either surface | +| `0` | Policies passed, nothing was in scope to gate on, or the files were clean | +| `1` | Tirith could not complete the run: bad input, a policy it could not evaluate, an unreachable API, a path that does not exist | +| `3` | A policy said no. On the evaluation surfaces only with `--fail-on-error`; `lint` and `fmt` need no flag | | `130` | Interrupted (Ctrl-C) | +`2` is deliberately absent. It used to be a timeout code that nothing ever returned, and +`argparse` already exits `2` of its own accord on a usage error, so a caller seeing `2` has +passed a bad argument. Do not write a pipeline that branches on it. + ## `3` is deliberately not `1` `3` means a check ran and said no: your infrastructure violates a policy. `1` means Tirith could @@ -2815,6 +3105,21 @@ Both surfaces **fail closed**: anything that leaves the verdict unknown exits no of `--fail-on-error`. That flag governs policy verdicts, not tool health — a run that produced no verdict must never look like a pass. +## `tirith lint` and `tirith fmt` + +Both use the same three codes, and neither takes `--fail-on-error`: a linter that cannot fail is +not a linter, and there is no existing green pipeline to protect because nothing has run them +before. + +| Command | `0` | `3` | `1` | +|---|---|---|---| +| `tirith lint` | every policy is clean | a policy has an error-level finding, or a warning under `--strict` | a path is missing, or nothing was found to lint | +| `tirith fmt` | nothing to change, or the changes were written | `--check` found files that would change | a path is missing, a file is not valid JSON, or nothing was found | + +"Nothing was found" being `1` rather than `0` is the same fail-closed rule the evaluation +surfaces follow. A hook pointed at the wrong directory reports a problem instead of quietly +passing every commit. See [lint and format](lint-and-fmt.md). + ## Without `--fail-on-error` The local command exits `0` whether the policy passed or failed, with the verdict in the output. @@ -2906,17 +3211,26 @@ permissions: checks: write # check run steps: - - run: | - terraform plan -out=tfplan -input=false - terraform show -json tfplan > plan.json + - run: terraform plan -out=tfplan -input=false - uses: StackGuardian/tirith-iac-governance-action@v2 + with: + plan-file: tfplan + fail-on-error: true ``` -With a `plan.json` in the working directory that is the whole integration — no `with:` block. The -action finds the document by convention (`plan.json` or `tfplan.json`) and evaluates the policy -files committed under `.tirith/policies`, on the runner, talking to nothing. Add -`with: { fail-on-error: true }` to make a failing policy fail the job. +The two write permissions are the only setup the action cannot do for itself, and are the thing +most often missing on a first install. `-input=false` matters in CI: without it a missing variable +waits for a prompt that never comes, and the job hangs instead of failing. + +Handing the action the **binary plan** rather than exporting JSON first is one step shorter and +strictly safer: the action renders it with `terraform show -json` in memory, so no unmasked plan +JSON is written to the workspace where a later step, a cache or an artifact upload could pick it +up. + +If your pipeline already writes `plan.json`, drop `plan-file` and the action finds the document by +convention (`plan.json` or `tfplan.json`). Either way it evaluates the policy files committed under +`.tirith/policies`, on the runner, talking to nothing. ### Local mode and platform mode @@ -3045,13 +3359,18 @@ pipelines: - step: name: Policy gate script: - - pip install "git+https://github.com/StackGuardian/tirith.git@1.2.0" - - tirith lint .tirith/policies # needs a build from main until the next release + # @main rather than @1.2.0, because the lint step below is not in 1.2.0 yet. + - pip install "git+https://github.com/StackGuardian/tirith.git@main" + - tirith lint .tirith/policies - tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error ``` -A complete file is in [`examples/ci/bitbucket-pipelines.yml`](https://github.com/StackGuardian/tirith/blob/main/examples/ci/bitbucket-pipelines.yml), -and a worked repository is at +Linting first is cheap and it fails for a different reason than the gate does: a policy that is +malformed never gets as far as disagreeing with your infrastructure. It needs no plan document, +so it can also run in a job that has no cloud credentials at all. See +[lint and format](lint-and-fmt.md). + +A worked repository is at [tirith-bitbucket-demo](https://bitbucket.org/__refeed__/tirith-bitbucket-demo). ## Jenkins @@ -3078,18 +3397,22 @@ stage('Policy gate') { } ``` -The full pipeline, including install, lint and artifact archiving, is in -[`examples/ci/Jenkinsfile`](https://github.com/StackGuardian/tirith/blob/main/examples/ci/Jenkinsfile). +`returnStatus: true` is what makes this work: without it the shell step throws on any non-zero +exit and the two cases become one. ## As a pre-commit hook +[NOTE] Not in 1.2.0 +`tirith lint` and `tirith fmt` are on `main` and arrive in the next release, so pin `rev` to a +branch until then. Everything else on this page works on 1.2.0. + Catch a broken policy before it is committed, let alone before CI runs it. Tirith publishes a -`tirith-lint` hook: +`tirith-lint` and a `tirith-fmt` hook: ```yaml repos: - repo: https://github.com/StackGuardian/tirith - rev: main # 1.2.0 predates the hook; pin the first tag that includes it + rev: main hooks: - id: tirith-lint - id: tirith-fmt @@ -3097,12 +3420,12 @@ repos: ```bash pre-commit install -pre-commit run tirith-lint --all-files +pre-commit run --all-files ``` -The hooks run only when a file under `.tirith/` or a `*.tirith.json` changes, and lint exactly the -files that changed. `tirith-fmt` rewrites them into the canonical layout; commit again after it -does. +Both hooks run only when a file under `.tirith/` or a `*.tirith.json` changes, and both are +handed the individual changed files rather than the whole directory: `tirith lint` and +`tirith fmt` each take any number of paths, so a commit touching one policy checks one policy. [NOTE] Why linting and not evaluation Evaluating a policy needs a plan document, and producing one means running `terraform plan` — @@ -3151,7 +3474,7 @@ pipeline should pay to install an interface they never open. It needs Python 3.9 Tirith itself still supports 3.8. ```bash -pip install 'py-tirith[tui] @ git+https://github.com/StackGuardian/tirith.git' +pip install 'py-tirith[tui] @ git+https://github.com/StackGuardian/tirith.git@1.2.0' ``` Tirith is not on PyPI — `pip install py-tirith` finds nothing and `pip install tirith` installs an @@ -3253,14 +3576,13 @@ Summary: VS Code tasks, a pre-commit hook, and the local loop to use when an AI ============================================================================== [NOTE] Not in 1.2.0 +`tirith lint` and `tirith fmt` are on `main` and will arrive in the next release. Until then, +install from the branch rather than the tag: +`pip install "git+https://github.com/StackGuardian/tirith.git@main"`. Evaluation, the second half +of the loop, works on any installed version. -`tirith lint`, `tirith fmt` and the pre-commit hooks are on `main` and will be in the next -release. `pip install "git+https://github.com/StackGuardian/tirith.git@1.2.0"` does not have them; -install from `main` until then. - - -CI is the last place a policy should fail. This page is about the loop before that — running -Tirith on your own machine, while the code is still being written. +CI is the last place a policy should fail. This page is about the loop before that, running +Tirith on your own machine while the code is still being written. It matters most when an agent is drafting the policy for you. Generated policy JSON is plausible by construction: it parses, it looks right, and a policy that matches nothing is indistinguishable @@ -3269,13 +3591,15 @@ from one that works until you evaluate it. ## The loop ```bash +tirith fmt .tirith/policies # layout tirith lint .tirith/policies # shape tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error # meaning ``` -Linting checks the **shape** — that every condition type exists, that no `provider_args` key -belongs to a different provider, that `eval_expression` names every evaluator. Only evaluation -checks the **meaning**. +Linting checks the **shape**: that every condition type exists, that no `provider_args` key +belongs to a different provider, that `eval_expression` names every evaluator, that +`error_tolerance` is inside `condition` where the engine reads it. Only evaluation checks the +**meaning**. [Lint and format](lint-and-fmt.md) covers both commands in full. Run both against a document that *should* fail. A guardrail only ever seen passing is a guardrail nobody has tested. @@ -3288,6 +3612,13 @@ Drop this in `.vscode/tasks.json` and the loop becomes one keystroke: { "version": "2.0.0", "tasks": [ + { + "label": "Tirith: format policies", + "type": "shell", + "command": "tirith fmt .tirith/policies", + "problemMatcher": [], + "group": "test" + }, { "label": "Tirith: lint policies", "type": "shell", @@ -3308,16 +3639,17 @@ Drop this in `.vscode/tasks.json` and the loop becomes one keystroke: ``` `Tirith: check the plan` is the default test task, so **⇧⌘B** / **Ctrl+Shift+B** runs the whole -loop. The complete file — including a task to refresh `plan.json` and one to open the result in -the interactive explorer — is -[`.vscode/tasks.json`](https://github.com/StackGuardian/tirith/blob/main/.vscode/tasks.json). +loop: lint first, then evaluate, because `dependsOn` will not run the second if the first exits +non-zero. ## Catch it at commit time +Tirith publishes both commands as pre-commit hooks: + ```yaml repos: - repo: https://github.com/StackGuardian/tirith - rev: main # 1.2.0 predates the hooks; pin the first tag that includes them + rev: main hooks: - id: tirith-lint - id: tirith-fmt @@ -3327,8 +3659,9 @@ repos: pre-commit install ``` -The hook runs only when a policy file changes, needs no network, and exits `3` when a policy has -an error. See [CI integration](ci-integration.md) for why it lints rather than evaluates. +Both run only on the policy files a commit actually touches, need no network, and exit `3` on a +finding. See [CI integration](ci-integration.md) for why they lint rather than evaluate, and +[lint and format](lint-and-fmt.md) for the flags and the files they read. ## When an agent is writing the policy @@ -3336,13 +3669,11 @@ Install the Tirith skill and your agent gets the closed condition list, the argu provider reads, and the instruction to run a policy before claiming it works: ```bash -mkdir -p .claude/skills/tirith-policies/reference -BASE=https://raw.githubusercontent.com/StackGuardian/tirith/main/.claude/skills/tirith-policies -curl -sL $BASE/SKILL.md -o .claude/skills/tirith-policies/SKILL.md +curl -fsSL https://stackguardian.github.io/tirith/skill.sh | sh ``` -Cursor reads `.cursor/rules/tirith-policies.mdc` instead, scoped with globs so it attaches by -itself when a policy file is open. +Add `--cursor` for the Cursor rule. [Agent Skills](agent-skills.md) covers what is in the pack, +the other clients, and how to tell whether it took effect. Two things make the difference between a drafted policy and a working one: @@ -3363,6 +3694,162 @@ attributes that changed — which the pretty printer does not show. See [the interactive interface](interactive-interface.md). +============================================================================== +# Agent Skills +Source: https://stackguardian.github.io/tirith/docs/tirith-usage/agent-skills/ +Summary: Install the Tirith skill pack so a coding agent writes policies from the real vocabulary instead of inventing condition types that look plausible. +============================================================================== + +An agent asked for a Tirith policy will produce one. The JSON will be well formed, the keys will +look right, and it will very often be wrong in a way that reads as correct: a condition type named +`Matches` or `Exists`, neither of which exists, or the argument key from a different provider. + +That failure is quiet. The policy parses, the evaluator does not match, and the check reports a +pass. **A rule that gates nothing looks exactly like a rule that found nothing wrong.** + +The skill pack fixes the cause: it gives the agent the closed vocabulary instead of leaving it to +guess from a plausible-looking shape. + +## Install it + +```bash +curl -fsSL https://stackguardian.github.io/tirith/skill.sh | sh +``` + +Three skills under `.claude/skills/`: `tirith-policies`, for writing policies, +`tirith-standards`, for generating a policy set from a repository you already have, and +`tirith-migrate`, for translating existing Sentinel or Azure policies. No config file, and they are picked +up in any repository you copy them into. A session that is already running may not see a newly installed +skill until it is restarted; a new session sees it immediately. + +| Flag | | +|---|---| +| `--cursor` | Also install `.cursor/rules/tirith-policies.mdc`, scoped with globs | +| `--global` | Install into `~/.claude/skills/` instead of this repository | +| `--ref REF` | Install from a branch or tag instead of `main` | +| `--help` | The same summary, from the script itself | + +The script downloads one archive of the repository, copies the skills out of it, and does nothing +else: no package is installed, no `PATH` is changed, nothing is executed after the download, and +it never touches a file it did not create. It extracts to a temporary directory and copies each +skill into place only once the whole archive has arrived, because a half-written skill is worse +than none: an agent reads whatever files exist and works from a partial vocabulary without saying +so. It needs `curl` and `tar`. + +It is [a committed file in this repository](https://github.com/StackGuardian/tirith/blob/main/documentation/static/skill.sh) +served from the same origin as this page, so the thing you pipe into a shell is the thing you can +read first. + +### Cursor + +```bash +curl -fsSL https://stackguardian.github.io/tirith/skill.sh | sh -s -- --cursor +``` + +Cursor reads a single rule file scoped with globs, so it attaches by itself the moment a policy +file is open and stays out of the way otherwise. + +### Codex, Zed, and anything reading `AGENTS.md` + +```bash +curl -fsSL https://stackguardian.github.io/tirith/skill.sh | sh +printf '\n## Tirith policies\nSee .claude/skills/tirith-policies/SKILL.md\n' >> AGENTS.md +``` + +One file at the repository root is read by a growing number of clients, and the pack beside it +keeps the references resolvable. + +## Check it worked + +Ask for a policy in plain words: *every bucket needs an Owner tag*. With the pack loaded your agent +names a real condition type and the argument key that provider actually takes. Without it, it +invents one that reads perfectly and gates nothing. + +## What is in the pack + +`SKILL.md` is the entry point and is loaded first; the references are read on demand, so a client +with a small context window pays for only what the task needs. + +| File | | +|---|---| +| `SKILL.md` | Turning an intent into valid policy JSON: provider, operation, condition, expression | +| `reference/schema.md` | The closed vocabulary. Thirteen condition types, each provider's operations, and the argument key that differs per provider | +| `reference/validate.md` | The mistakes that produce a policy which looks right and gates nothing | +| `reference/verdicts.md` | Reading a result document and an exit code | +| `reference/terraform-plan.md` | The Terraform and OpenTofu plan provider | +| `reference/other-providers.md` | Kubernetes, Infracost, JSON and StackGuardian Workflow | +| `reference/variables.md` | One policy across environments | +| `reference/install.md` | Installing Tirith, and why the install is a git URL | +| `reference/pipelines.md` | Adding the gate to six CI platforms | +| `reference/platform.md` | Evaluating an organization's policies | +| `reference/debug-ci.md` | Diagnosing a red check | +| `examples/required-tags/` | A policy, a plan that fails it and a plan that passes it, so the agent can prove its own work before it hands it back | + +## Standards from a repository you already have + +`tirith-standards` starts from the Terraform or OpenTofu you already run rather than from a +catalogue. It reads the code to work out which resource types are in use and which conventions +are already followed, proposes a set of standards with the count of resources that would pass and +fail today, and writes one policy per rule: required tags, naming conventions, allowed regions, +permitted resource types, size ceilings, encryption defaults. + +Two things in it are worth knowing even if you never install it, because they decide whether a +generated policy is usable: + +- **The policies never see your HCL.** The skill reads `.tf` files only to decide what to write + about; the rules themselves evaluate `terraform show -json`, where variables are resolved, + modules are expanded into addressed resources, and a name that is not known until apply is + `null`. A rule about a variable or a module block reads nothing. +- **A rule scoped to one resource type needs a guard.** With the default `error_tolerance` an + absent resource type is severity `1` and the check fails, so the rule is red on every plan that + does not touch that type. At `error_tolerance: 1` it is skipped instead, every check is skipped, + and `final_result` is `null`, which exits `1`. Neither is green. The skill's fix is a `count` + evaluator, which returns `0` for an absent type rather than erroring, combined with `||`. + +## Migrating from Sentinel or Azure Policy + +The third skill, `tirith-migrate`, is for teams with existing HashiCorp Sentinel or Azure Policy +definitions. It is a +projection from a larger language onto a smaller one, and the skill's job is to say what survives. +Measured against the 110 policies in HashiCorp's public libraries, 41 translate exactly, 40 +approximately, and 29 not at all. Each translation is tagged with that fidelity, every approximate +one ships a plan on which Sentinel and Tirith disagree, and every impossible one is refused in +words with the Tirith issue that would change it. For Azure Policy the reference carries an +alias-to-azurerm crosswalk, the corpus's classification of 713 built-ins, and eight verified +translations of the policies a subscription is usually assigned first. Checkov and OPA/Rego are +planned next. + +## Two things decide whether the policy actually works + +The pack teaches vocabulary. It does not run anything, and it is not a substitute for evaluating +the policy: + +1. **Give the agent `tirith` on `PATH`.** It is an ordinary command, so an agent with a shell can + evaluate its own work without a protocol server or a plugin. See + [Quick Installation](../tirith-installation/quick-intallation.md). +2. **Give it a document that should fail.** Ask for the policy *and* a plan that violates it, then + check the exit code is `3`. If it is `0`, the policy matched nothing, which is the failure this + whole page exists to prevent. The pack ships a starting pair in `examples/required-tags/`. + See [Exit codes](exit-codes.md). + +## Keeping it current + +The pack is a copy, so it does not update itself. Re-run the installer to take the current +version: + +```bash +curl -fsSL https://stackguardian.github.io/tirith/skill.sh | sh +``` + +Re-running is safe: it replaces each skill directory it owns wholesale, so a file removed upstream +does not linger, and it leaves everything else alone. + +`--ref` takes a branch or a commit, which is worth knowing for a fork or a pull request. It cannot +yet take a release tag: the pack was added after `1.2.0`, so `main` is the only ref that has it, +and asking for a tag that predates it fails with exit `1` rather than installing something +incomplete. + + ============================================================================== # Platform Check Source: https://stackguardian.github.io/tirith/docs/tirith-usage/platform-check/ diff --git a/documentation/static/llms.txt b/documentation/static/llms.txt index e45452ad..3b167a7e 100644 --- a/documentation/static/llms.txt +++ b/documentation/static/llms.txt @@ -8,6 +8,16 @@ Policies are data, not code: a policy is a JSON file, so it is diffable and reviewable by anyone on the team. There is no rule DSL and no embedded language. +## Minimal working example + +```bash +terraform plan -out=tfplan -input=false +terraform show -json tfplan > plan.json +tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error +``` + +Without `--fail-on-error`, Tirith reports findings but does not fail the job. + ## Facts an answer about Tirith usually gets wrong - **Not on PyPI.** `pip install tirith` installs an unrelated project of the same name, and @@ -17,12 +27,14 @@ anyone on the team. There is no rule DSL and no embedded language. - **The interactive interface needs an extra**, and the same git URL: `pip install 'py-tirith[tui] @ git+https://github.com/StackGuardian/tirith.git'`, then `tirith ui`. Requires Python 3.9; Tirith itself supports 3.8 and newer. -- **`tirith lint` is not in the released package.** It is in development. The released CLI - dispatches `tirith`, `tirith ui` and `tirith platform check` and nothing else. +- **`tirith lint` and `tirith fmt` exist, but not in 1.2.0.** They are on `main` and arrive in + the next release; install `@main` to use them today. The CLI dispatches four subcommands: + `lint`, `fmt`, `ui` and `platform`. Lint checks a policy's shape without a plan document; fmt + rewrites it into the canonical layout. Both exit `3` on a finding, with no `--fail-on-error`. - **Exit codes are a contract, not a convention.** `0` passed, `3` a policy failed, `1` Tirith could not reach a verdict. `3` is deliberately not `1`: a caller has to be able to page - the platform team on one and the change author on the other. (`ERROR_TIMEOUT = 2` exists in - `status.py` but is never returned; do not write a pipeline that branches on it.) + the platform team on one and the change author on the other. (`2` is not a Tirith code at all: + argparse exits `2` on a usage error, so a caller seeing it passed a bad argument.) - **`final_result: null` is not a pass.** It means every check was skipped, so the policy evaluated nothing, and it exits `1`. A check that could not run is reported as unevaluated rather than as success. @@ -35,18 +47,29 @@ anyone on the team. There is no rule DSL and no embedded language. - **Thirteen condition types ship**: Equals, NotEquals, GreaterThan, GreaterThanEqualTo, LessThan, LessThanEqualTo, Contains, NotContains, ContainedIn, NotContainedIn, IsEmpty, IsNotEmpty, RegexMatch. +- **The argument key differs per provider, and a wrong key is ignored rather than rejected.** + `terraform_plan` reads `terraform_resource_attribute` alongside `terraform_resource_type`; + `kubernetes` reads `attribute_path` alongside `kubernetes_kind`; `json` reads `key_path`; + `sg_workflow` reads `workflow_attribute`. Give an evaluator the wrong key and it reads + nothing and the check passes. This is the highest-cost mistake in the schema. - **`error_tolerance` belongs inside the `condition` object**, not on the evaluator. Placed on the evaluator it is silently ignored and the check still fails. - -## Minimal working example - -```bash -terraform plan -out=tfplan -input=false -terraform show -json tfplan > plan.json -tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error -``` - -Without `--fail-on-error`, Tirith reports findings but does not fail the job. +- **`error_tolerance` is a threshold, not a switch.** A problem of severity N fails the check + when `N > error_tolerance` and is skipped otherwise; the default is `0`. The plan provider + raises severity `0` for a resource being deleted, `1` for a resource type absent from the + plan, and `2` for an attribute absent from the resource. So the default skips deletes and + fails everything else. +- **A policy scoped to one resource type has no green outcome on a plan that does not touch + that type.** At the default tolerance the absent type is severity `1`, the check fails, exit + `3`. At `error_tolerance: 1` it is skipped, so every check is skipped, `final_result` is + `null`, exit `1`. Guard it instead: `count` returns `0` for an absent type rather than + raising severity `1`, so an evaluator of + `{"operation_type": "count", "terraform_resource_type": T}` with `Equals: 0`, combined as + `eval_expression: "not_applicable || the_rule"`, passes cleanly on unrelated plans and still + fails on real violations. +- **The pre-commit hooks are published.** `.pre-commit-hooks.yaml` carries `tirith-lint` and + `tirith-fmt`, both run on the policy files a commit touches. Pin `rev` to a branch until lint + and fmt are in a release. ## Fetching this documentation @@ -57,6 +80,23 @@ https://stackguardian.github.io/tirith/docs/tirith-usage/exit-codes.md The whole documentation set as one file, in sidebar order: https://stackguardian.github.io/tirith/llms-full.txt +## Working in a repository that uses Tirith + +If you are a coding agent about to write a policy, install the skill pack first. It is the +closed vocabulary: thirteen condition types, the argument key each provider reads, and the +instruction to run a policy against a document that should fail before calling it done. + +``` +curl -fsSL https://stackguardian.github.io/tirith/skill.sh | sh +``` + +Three skills: tirith-policies (eleven markdown files and a worked example), tirith-standards +(generating a policy set from an existing Terraform or OpenTofu repository, including the guard +above) and tirith-migrate (Sentinel translation). Add --cursor for the Cursor rule, --global to +install into the home directory instead of the repository. The script installs no +package, changes no PATH and executes nothing after the download; its source is +documentation/static/skill.sh in the repository. + ## Start here - [Getting started](https://stackguardian.github.io/tirith/docs/getting-started-with-tirith/): what Tirith is and the shortest path to a first verdict. @@ -88,6 +128,7 @@ https://stackguardian.github.io/tirith/llms-full.txt ## Running it - [CLI reference](https://stackguardian.github.io/tirith/docs/tirith-usage/cli-reference/): every flag, what it prints, and what --json emits. +- [Lint and format](https://stackguardian.github.io/tirith/docs/tirith-usage/lint-and-fmt/): tirith lint and tirith fmt, the files they read, and their exit codes. - [Exit codes](https://stackguardian.github.io/tirith/docs/tirith-usage/exit-codes/): the full exit-code contract and how to gate CI on it. - [CI integration](https://stackguardian.github.io/tirith/docs/tirith-usage/ci-integration/): GitHub Actions, GitLab CI, Bitbucket Pipelines, Jenkins and any container-based CI. - [The interactive interface](https://stackguardian.github.io/tirith/docs/tirith-usage/interactive-interface/): tirith ui, in beta. @@ -97,10 +138,10 @@ https://stackguardian.github.io/tirith/llms-full.txt - [Source](https://github.com/StackGuardian/tirith): the repository, Apache-2.0. - [Roadmap](https://stackguardian.github.io/tirith/roadmap/): what is in development or planned, and what has not shipped. -- [Agent skill pack](https://github.com/StackGuardian/tirith/tree/main/.claude/skills/tirith-policies): a self-contained skill for writing Tirith policies, copyable into any repository. +- [Agent skill pack](https://github.com/StackGuardian/tirith/tree/main/.claude/skills/tirith-policies): a self-contained skill for writing Tirith policies. Install it with the one-line script above, or copy the directory into any repository. - [Origins](https://stackguardian.github.io/tirith/origins/): where the name and the mark come from. ## Optional - [Tirith at scale](https://stackguardian.github.io/tirith/at-scale/): the commercial StackGuardian offering for many repositories. Not required to use Tirith, which works with no account. -- [In your editor](https://stackguardian.github.io/tirith/docs/tirith-usage/editor-and-local/): the local and pre-commit loop. In development, and not in the released package. +- [In your editor](https://stackguardian.github.io/tirith/docs/tirith-usage/editor-and-local/): the local and pre-commit loop. diff --git a/documentation/static/skill.sh b/documentation/static/skill.sh new file mode 100644 index 00000000..1d8b6614 --- /dev/null +++ b/documentation/static/skill.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env sh +# +# Install the Tirith skills for a coding agent. +# +# curl -fsSL https://stackguardian.github.io/tirith/skill.sh | sh +# +# Read this file before you run it. It is served over HTTPS from the documentation site and +# its source is documentation/static/skill.sh in StackGuardian/tirith, so the version you +# are about to pipe into a shell is the version you can read in the repository. +# +# What it does: downloads one archive of the repository at REF, copies every skill under +# .claude/skills/ out of it into ./.claude/skills/ (tirith-policies, tirith-standards, +# tirith-migrate today) and, with --cursor, one rule file into .cursor/rules/. It creates +# directories, writes those files, and nothing else. No package is installed, no PATH is +# changed, nothing is executed after the download, and it never touches a file it did not +# create. One request rather than one per file: the first version fetched each file from +# raw.githubusercontent.com and took ten minutes on a slow link. +# +# Flags: +# --global install into ~/.claude/skills/ instead of ./.claude/skills/ +# --cursor also install the Cursor rule into .cursor/rules/ +# --ref REF install from a branch or tag instead of main +# +# POSIX sh on purpose: it runs under dash, ash and busybox, which is what a slim CI image +# gives you. Needs curl and tar. + +set -eu + +REPO="StackGuardian/tirith" +REF="main" +DEST="." +CURSOR=0 + +while [ $# -gt 0 ]; do + case "$1" in + --global) DEST="$HOME" ;; + --cursor) CURSOR=1 ;; + --ref) REF="${2:?--ref needs a branch or tag}"; shift ;; + -h|--help) + sed -n '3,26p' "$0" 2>/dev/null | sed 's/^# \{0,1\}//' + exit 0 + ;; + *) printf 'skill.sh: unknown option %s\n' "$1" >&2; exit 2 ;; + esac + shift +done + +command -v curl >/dev/null 2>&1 || { echo "skill.sh: curl is required" >&2; exit 1; } +command -v tar >/dev/null 2>&1 || { echo "skill.sh: tar is required" >&2; exit 1; } + +# The archive URL can be overridden for testing against a local `git archive` tarball. +ARCHIVE="${TIRITH_SKILL_ARCHIVE:-https://codeload.github.com/$REPO/tar.gz/refs/heads/$REF}" + +# Everything lands in a temporary directory first and is copied into place only once the +# archive has been read in full. A half-written skill is worse than no skill: an agent will +# read whatever files exist and quietly work from a partial vocabulary. +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT INT TERM + +if ! curl -fsSL "$ARCHIVE" -o "$TMP/repo.tar.gz"; then + # A tag lives under refs/tags, a branch under refs/heads; try the other before giving up. + ALT="https://codeload.github.com/$REPO/tar.gz/refs/tags/$REF" + [ -n "${TIRITH_SKILL_ARCHIVE:-}" ] || curl -fsSL "$ALT" -o "$TMP/repo.tar.gz" || { + printf 'skill.sh: could not download %s (ref %s)\n' "$REPO" "$REF" >&2; exit 1; } +fi + +mkdir -p "$TMP/x" +tar -xzf "$TMP/repo.tar.gz" -C "$TMP/x" +# GitHub archives have one top-level directory named after the ref; a `git archive` tarball +# has none. Find the skills directory wherever it landed, at most one level down. +SRC="" +for candidate in "$TMP/x/.claude/skills" "$TMP"/x/*/.claude/skills; do + [ -d "$candidate" ] && { SRC="$candidate"; break; } +done +[ -n "$SRC" ] || { printf 'skill.sh: the archive for ref %s has no .claude/skills directory\n' "$REF" >&2; exit 1; } +ROOT="$(dirname "$(dirname "$SRC")")" + +TARGET="$DEST/.claude/skills" +mkdir -p "$TARGET" +installed=0 +for pack in "$SRC"/*/; do + name="$(basename "$pack")" + [ -f "$pack/SKILL.md" ] || continue + # Replace the pack wholesale so a file dropped upstream does not linger here. + rm -rf "$TARGET/$name" + cp -R "$pack" "$TARGET/$name" + printf 'Installed %s: %s\n' "$name" "$TARGET/$name" + installed=$((installed + 1)) +done +[ "$installed" -gt 0 ] || { echo "skill.sh: no skills found in the archive" >&2; exit 1; } + +if [ "$CURSOR" -eq 1 ]; then + RULE="$ROOT/.cursor/rules/tirith-policies.mdc" + [ -f "$RULE" ] || { echo "skill.sh: the archive has no .cursor/rules/tirith-policies.mdc" >&2; exit 1; } + mkdir -p "$DEST/.cursor/rules" + cp "$RULE" "$DEST/.cursor/rules/tirith-policies.mdc" + printf 'Installed the Cursor rule: %s\n' "$DEST/.cursor/rules/tirith-policies.mdc" +fi + +printf 'Ask your agent to write a Tirith policy. It should name real condition types.\n'