Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
93 changes: 93 additions & 0 deletions .claude/skills/tirith-migrate/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
---
name: tirith-migrate
description: Translate existing policy-as-code into Tirith policies. HashiCorp Sentinel today; Checkov, OPA/Rego and conftest are planned. Use when asked to migrate, convert, port or translate policies to Tirith, when a repository contains .sentinel files or a sentinel.hcl, or when asked what a Sentinel policy would look like in Tirith. Requires the tirith-policies skill for the target vocabulary.
---

# Migrate policies to Tirith

A migration is a projection from a larger language onto a smaller one. Sentinel and Rego are
programs; a Tirith policy is JSON that names a provider, a value, and a condition. Most real
policies fit. Some do not, and the failure mode is quiet: a translation that parses, looks right,
and gates nothing. **This skill exists to say which is which before any JSON is written.**

## Vocabulary comes from `tirith-policies`

Do not translate from memory. Read `../tirith-policies/reference/schema.md` for the closed list of
providers, operations, argument keys and the thirteen condition types. If that skill is not
installed, fetch `https://stackguardian.github.io/tirith/llms.txt` and follow it to the schema
page. Everything below assumes that vocabulary.

## Per-source references

| Source | Reference | Status |
| --- | --- | --- |
| HashiCorp Sentinel | `reference/sentinel.md`, corpus in `reference/sentinel-corpus.md` | Measured against 110 public policies |
| Checkov | | Planned |
| OPA / Rego, conftest | | Planned |

## The protocol

1. **Inventory.** List every source policy. Read the policy-set manifest (`sentinel.hcl`) for
enforcement levels and parameters. Note which policies are registered twice with different
parameters; they translate once.
2. **Classify before translating.** For each policy, name its pattern from the source reference
and assign a fidelity:
- `exact`: a Tirith policy returns the same verdict on every plan.
- `approximate`: expressible, but stricter or looser in a case you can name.
- `not expressible`: needs something Tirith lacks. Name it, and link the tracking issue.
3. **Translate `exact` and `approximate`.** Carry `meta.name` from the source policy name, put the
Sentinel enforcement level in `meta.enforcement`, and map every `param` to `{{ var.NAME }}`.
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/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 |
13 changes: 13 additions & 0 deletions .claude/skills/tirith-migrate/examples/sentinel/README.md
Original file line number Diff line number Diff line change
@@ -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`.
Original file line number Diff line number Diff line change
@@ -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 |
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
@@ -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": {}
}
}
]
}
Original file line number Diff line number Diff line change
@@ -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": {}
}
}
]
}
Original file line number Diff line number Diff line change
@@ -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 }
Original file line number Diff line number Diff line change
@@ -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 |
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
@@ -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": {}
}
}
]
}
Original file line number Diff line number Diff line change
@@ -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": {}
}
}
]
}
Original file line number Diff line number Diff line change
@@ -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": {}
}
}
]
}
Loading
Loading