Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
25eb652
feat: restore and expose the Skills page in the documentation
Akshat0694 Sep 3, 2026
e8a62c1
Refactor documentation styles and enhance agent skills installation
Akshat0694 Sep 3, 2026
6f8a934
Merge branch 'main' into f/agent-skills
AkashS0510 Sep 4, 2026
c0adae5
Ship a worked example with the skill pack, and fix stale exit-code text
AkashS0510 Sep 4, 2026
6370783
Add tirith-migrate: a skill for translating Sentinel policies into Ti…
AkashS0510 Sep 4, 2026
5ba5b33
Fix the destroy-guard guidance and four other defects found by a live…
AkashS0510 Sep 4, 2026
bde4f4f
Warn about skip-masking (#293) and null list attributes in both skills
AkashS0510 Sep 4, 2026
f0374f9
Correct three claims found by a second smoke test, and footnote the h…
AkashS0510 Sep 4, 2026
ae33c59
feat: Add Terraform and Kubernetes providers with lessons and documen…
Akshat0694 Sep 4, 2026
f7589ac
Merge remote-tracking branch 'origin/main' into f/agent-skills
AkashS0510 Sep 4, 2026
b353860
Drop the #293 warnings now that the engine fix has merged
AkashS0510 Sep 4, 2026
a72c0ec
Merge remote-tracking branch 'origin/f/agent-skills' into f/agent-skills
AkashS0510 Sep 4, 2026
9e33fb2
feat(learn): update lesson numbering and enhance track chooser functi…
Akshat0694 Sep 4, 2026
62f3363
Add lint and format documentation, update exit codes, and enhance skills
Akshat0694 Sep 7, 2026
8586bb0
tirith-migrate: Azure Policy as a second source
AkashS0510 Sep 8, 2026
284fab2
Merge remote-tracking branch 'origin/f/agent-skills' into f/agent-skills
AkashS0510 Sep 8, 2026
ccdfcb4
Merge remote-tracking branch 'origin/main' into f/agent-skills
AkashS0510 Sep 8, 2026
8768f52
skill.sh: one archive download instead of a hundred raw fetches
AkashS0510 Sep 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions .claude/skills/tirith-migrate/SKILL.md
Original file line number Diff line number Diff line change
@@ -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/<policy>/`. 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 |
24 changes: 24 additions & 0 deletions .claude/skills/tirith-migrate/examples/azure-policy/README.md
Original file line number Diff line number Diff line change
@@ -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 |
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
@@ -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
}
}
}
]
}
Original file line number Diff line number Diff line change
@@ -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
}
}
}
]
}
Loading
Loading