diff --git a/.claude/skills/tirith-migrate/SKILL.md b/.claude/skills/tirith-migrate/SKILL.md new file mode 100644 index 00000000..2d893fd3 --- /dev/null +++ b/.claude/skills/tirith-migrate/SKILL.md @@ -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//`. 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 | 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/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..899750e6 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` does not ship.** Do not put it in a pipeline. `reference/validate.md` has + what to do instead. + +## 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, and why `tirith lint` is not the way | | `reference/verdicts.md` | Running a policy, exit codes, and finding the resource behind a failure | | `reference/terraform-plan.md` | The plan provider's operations, for OpenTofu and Terraform | | `reference/other-providers.md` | Kubernetes, Infracost and arbitrary JSON or YAML | @@ -131,5 +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..9139298b 100644 --- a/.claude/skills/tirith-policies/reference/pipelines.md +++ b/.claude/skills/tirith-policies/reference/pipelines.md @@ -207,6 +207,5 @@ it, which is what makes a failure explainable after the fact. ## Not yet available -A `tirith-lint` pre-commit hook and a VS Code task loop are in development, and both depend on -`tirith lint`, which is not in the released package. Do not write either into a pipeline today. -`https://stackguardian.github.io/tirith/roadmap/` tracks them. +A pre-commit hook and an editor loop depend on `tirith lint`, which does not ship. See +`reference/validate.md`. diff --git a/.claude/skills/tirith-policies/reference/platform.md b/.claude/skills/tirith-policies/reference/platform.md index cd06856c..b3d197ed 100644 --- a/.claude/skills/tirith-policies/reference/platform.md +++ b/.claude/skills/tirith-policies/reference/platform.md @@ -45,8 +45,7 @@ The same contract as local evaluation, plus one: | Exit | Meaning | | --- | --- | | `0` | Passed | -| `1` | Could not reach a verdict — bad input, unreachable API | -| `2` | Timed out waiting for the run | +| `1` | Could not reach a verdict — bad input, unreachable API, or the run timed out | | `3` | A policy failed (with `--fail-on-error`) | ## Credentials diff --git a/.claude/skills/tirith-policies/reference/schema.md b/.claude/skills/tirith-policies/reference/schema.md index 44e9caf9..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..bfa4327e 100644 --- a/.claude/skills/tirith-policies/reference/validate.md +++ b/.claude/skills/tirith-policies/reference/validate.md @@ -2,11 +2,13 @@ ## `tirith lint` is not in the released package -It is in development. The released CLI dispatches `tirith`, `tirith ui` and `tirith platform -check` and nothing else, so `tirith lint` in a pipeline you are writing for someone else is a step -that fails with an unrecognised argument. +This is the one place in the pack that explains it; the other files point here. The released CLI +dispatches `tirith`, `tirith ui` and `tirith platform check` and nothing else. `tirith lint` prints +the usage text and "Failed because of System Exit" and exits `1`, which a pipeline reads as a tool +failure on every run. A linter is on the roadmap at +`https://stackguardian.github.io/tirith/roadmap/`; do not assume it has shipped. -Until it ships there are two ways to validate, and both are available today. +Two ways to validate exist today. ## The interactive validator does ship @@ -35,7 +37,7 @@ for a reason unrelated to your infrastructure. | Trap | Why it matters | | --- | --- | -| An invented condition type | There is no `Exists`, `Matches` or `In`. The engine returns an unknown type as an ordinary failed check, so it reads as a real violation rather than a typo. | +| An invented condition type | There is no `Exists`, `Matches` or `In`. The engine returns an unknown type as a failed check, exit `3`, `errors` empty. The result message does name it; the exit code does not. | | A key from the wrong provider | `terraform_plan` reads `terraform_resource_attribute`; `kubernetes` reads `attribute_path`. An unrecognised key is **ignored, not rejected**, so the evaluator reads nothing and the check passes. | | An operation that does not ship | `jmespath` and `jq_query` appear in some test fixtures. Neither exists. | | `error_tolerance` outside `condition` | It belongs **inside** `condition`. On the evaluator it is silently ignored: no warning, and the check still fails as though the tolerance were never written. | @@ -57,6 +59,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 +80,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..baaa1037 100644 --- a/.claude/skills/tirith-policies/reference/verdicts.md +++ b/.claude/skills/tirith-policies/reference/verdicts.md @@ -12,12 +12,15 @@ 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. The CLI catches argparse's usage error and exits `1`, so a bad argument or an +unknown subcommand (`tirith lint` prints "Failed because of System Exit") is `1`, the same as a +tool or input problem. Tirith has no timeout code: a `platform check` that times out is `1` too. + **`3` is deliberately not `1`.** `3` means a check ran and refused the change. `1` means Tirith could not reach a verdict. A job that treats every non-zero code alike reports an outage as a policy violation and cannot tell a working gate from a broken one. @@ -26,6 +29,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 +68,7 @@ looking in the plan for the resource lacking that attribute. ## A misconfigured policy fails closed -An unsupported `condition.type` or an unknown `required_provider` comes back as an ordinary failed -check with no error attached — indistinguishable from a real violation, and it exits `3`. It fails -in the safe direction, but it points at your infrastructure when the fault is in the policy. -Check the condition type against the closed list in `reference/schema.md`: a typo there is the -usual cause, and it is not reported as one. +An unsupported `condition.type` comes back as a failed check, exit `3`, with the `errors` array +empty. It fails in the safe direction, but a job that branches on the exit code sees a policy +violation. The result message names the fault (`` `Exists` is not a supported evaluator ``), so +when a check fails on every resource at once, read the message before reading the plan. diff --git a/.cursor/rules/tirith-policies.mdc b/.cursor/rules/tirith-policies.mdc index 120d4676..bd799a8c 100644 --- a/.cursor/rules/tirith-policies.mdc +++ b/.cursor/rules/tirith-policies.mdc @@ -34,8 +34,8 @@ rejects those with `Unsupported operator in eval_expression`. `!` is the only ne the positive detector and invert it. An evaluator the expression never references cannot affect the verdict. -**Verdicts:** exit `0` passed · `3` a policy failed · `1` no verdict was reached. (`2` is 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:** @@ -106,7 +106,7 @@ between them as an artifact: was reached. A job that cannot tell them apart reports an outage as a policy violation, and cannot tell a working gate from a broken one. -Worked examples: `src/tirith/tui/examples/`. Deeper reference, if the repository has it: +Worked example, if the repository has it: `.claude/skills/tirith-policies/examples/required-tags/`. Deeper reference: `.claude/skills/tirith-policies/reference/` covers the schema, validation, verdicts, each provider, policy variables, installing Tirith, six CI platforms, and debugging a red check. Online: https://stackguardian.github.io/tirith/llms.txt diff --git a/documentation/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..6e3dc781 --- /dev/null +++ b/documentation/docs/tirith-usage/agent-skills.md @@ -0,0 +1,137 @@ +--- +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 +``` + +Two skills under `.claude/skills/`: `tirith-policies`, for writing policies, and `tirith-migrate`, +for translating existing Sentinel policies. No config file, and they are picked up in any +repository you copy them into. A session that is already running may not see a newly installed +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 those files and does nothing else: no package is installed, no +`PATH` is changed, nothing is executed after the download, and it never touches a file it did not +create. It downloads to a temporary directory and moves the files into place only once all of them +have arrived, because a half-written skill is worse than none: an agent reads whatever files exist +and works from a partial vocabulary without saying so. + +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 | + +## Migrating from Sentinel + +The second skill, `tirith-migrate`, is for teams with existing HashiCorp Sentinel policies. It is a +projection from a larger language onto a smaller one, and the skill's job is to say what survives. +Measured against the 110 policies in HashiCorp's public libraries, 41 translate exactly, 40 +approximately, and 29 not at all. Each translation is tagged with that fidelity, every approximate +one ships a plan on which Sentinel and Tirith disagree, and every impossible one is refused in +words with the Tirith issue that would change it. Checkov and OPA/Rego are planned next. + +## Two things decide whether the policy actually works + +The pack teaches vocabulary. It does not run anything, and it is not a substitute for evaluating +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 overwrites the files it owns, in both skills, and leaves everything else alone. + +`--ref` takes a branch or a commit, which is worth knowing for a fork or a pull request. It cannot +yet take a release tag: the pack was added after `1.2.0`, so `main` is the only ref that has it, +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 48fccc2a..d2c63780 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 @@ -187,8 +196,7 @@ pipelines: - tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error ``` -A complete file is in [`examples/ci/bitbucket-pipelines.yml`](https://github.com/StackGuardian/tirith/blob/main/examples/ci/bitbucket-pipelines.yml), -and a worked repository is at +A worked repository is at [tirith-bitbucket-demo](https://bitbucket.org/__refeed__/tirith-bitbucket-demo). ## Jenkins @@ -215,12 +223,19 @@ stage('Policy gate') { } ``` -The full pipeline, including install, lint and artifact archiving, is in -[`examples/ci/Jenkinsfile`](https://github.com/StackGuardian/tirith/blob/main/examples/ci/Jenkinsfile). +`returnStatus: true` is what makes this work: without it the shell step throws on any non-zero +exit and the two cases become one. ## As a pre-commit hook -Catch a broken policy before it is committed, let alone before CI runs it. Tirith publishes a +:::warning In development +`tirith lint` is not in 1.2.0 and the `tirith-lint` hook id is not published, so the +configuration below does not work yet: `pre-commit` cannot resolve the hook and the run fails. +It is documented here because the design is settled and the shape will not change. Track it on +the [roadmap](https://stackguardian.github.io/tirith/roadmap/). +::: + +Catch a broken policy before it is committed, let alone before CI runs it. Tirith will publish a `tirith-lint` hook: ```yaml title=".pre-commit-config.yaml" diff --git a/documentation/docs/tirith-usage/editor-and-local.md b/documentation/docs/tirith-usage/editor-and-local.md index bb627cac..052f7569 100644 --- a/documentation/docs/tirith-usage/editor-and-local.md +++ b/documentation/docs/tirith-usage/editor-and-local.md @@ -111,13 +111,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/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/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..4e61345a 100644 --- a/documentation/scripts/generate-llms-full.py +++ b/documentation/scripts/generate-llms-full.py @@ -61,6 +61,7 @@ "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..96ebba86 100644 --- a/documentation/sidebars.js +++ b/documentation/sidebars.js @@ -23,6 +23,7 @@ module.exports = { "tirith-usage/cli-reference", "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/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..651dd3e0 100644
--- a/documentation/src/data/agentBrief.js
+++ b/documentation/src/data/agentBrief.js
@@ -5,4 +5,4 @@
  * static/llms.txt and re-run documentation/scripts/generate-llms-full.py.
  */
 
-export const AGENT_BRIEF = "# Tirith\n\n> Tirith is an Apache-2.0 policy gate for infrastructure-as-code. It reads the plan a\n> pipeline already produces, evaluates it against declarative JSON policies committed in\n> the repository, and returns one verdict with an exit code the pipeline can gate on. It\n> runs locally or in any CI, on your own runner, with no account and no network call.\n\nPolicies are data, not code: a policy is a JSON file, so it is diffable and reviewable by\nanyone on the team. There is no rule DSL and no embedded language.\n\n## Facts an answer about Tirith usually gets wrong\n\n- **Not on PyPI.** `pip install tirith` installs an unrelated project of the same name, and\n  `pip install py-tirith` finds nothing. The package name in `setup.py` is `py-tirith` and it\n  is not published. Install from git:\n  `pip install \"git+https://github.com/StackGuardian/tirith.git@1.2.0\"`\n- **The interactive interface needs an extra**, and the same git URL:\n  `pip install 'py-tirith[tui] @ git+https://github.com/StackGuardian/tirith.git'`, then\n  `tirith ui`. Requires Python 3.9; Tirith itself supports 3.8 and newer.\n- **`tirith lint` is not in the released package.** It is in development. The released CLI\n  dispatches `tirith`, `tirith ui` and `tirith platform check` and nothing else.\n- **Exit codes are a contract, not a convention.** `0` passed, `3` a policy failed, `1`\n  Tirith could not reach a verdict. `3` is deliberately not `1`: a caller has to be able to page\n  the platform team on one and the change author on the other. (`ERROR_TIMEOUT = 2` exists in\n  `status.py` but is never returned; do not write a pipeline that branches on it.)\n- **`final_result: null` is not a pass.** It means every check was skipped, so the policy\n  evaluated nothing, and it exits `1`. A check that could not run is reported as unevaluated\n  rather than as success.\n- **Local mode makes no network call.** That is a published governance commitment. Only\n  `tirith platform check` talks to a network, and it is optional.\n- **Five providers ship**, named in `meta.required_provider`: `stackguardian/terraform_plan`,\n  `stackguardian/kubernetes`, `stackguardian/infracost`, `stackguardian/json`,\n  `stackguardian/sg_workflow`. There is no CloudFormation provider; a CloudFormation template\n  is read as a JSON document by the `json` provider.\n- **Thirteen condition types ship**: Equals, NotEquals, GreaterThan, GreaterThanEqualTo,\n  LessThan, LessThanEqualTo, Contains, NotContains, ContainedIn, NotContainedIn, IsEmpty,\n  IsNotEmpty, RegexMatch.\n- **`error_tolerance` belongs inside the `condition` object**, not on the evaluator. Placed on\n  the evaluator it is silently ignored and the check still fails.\n\n## Minimal working example\n\n```bash\nterraform plan -out=tfplan -input=false\nterraform show -json tfplan > plan.json\ntirith -policy-path .tirith/policies -input-path plan.json --fail-on-error\n```\n\nWithout `--fail-on-error`, Tirith reports findings but does not fail the job.\n\n## Fetching this documentation\n\nEvery page is also served as plain markdown at its route plus `.md`, so there is no need to\nparse the rendered HTML. For example\nhttps://stackguardian.github.io/tirith/docs/tirith-usage/exit-codes.md\n\nThe whole documentation set as one file, in sidebar order:\nhttps://stackguardian.github.io/tirith/llms-full.txt\n\n## Start here\n\n- [Getting started](https://stackguardian.github.io/tirith/docs/getting-started-with-tirith/): what Tirith is and the shortest path to a first verdict.\n- [Quick installation](https://stackguardian.github.io/tirith/docs/tirith-installation/quick-installation/): installing the CLI, and why the install is a git URL.\n- [Learn](https://stackguardian.github.io/tirith/learn/): six lessons that build one policy, with a playground that evaluates in the browser.\n- [Creating your first policy](https://stackguardian.github.io/tirith/docs/tirith-policies/tirith-create-first-policy/): a policy written and evaluated step by step.\n\n## Writing policies\n\n- [Policy reference](https://stackguardian.github.io/tirith/docs/tirith-policies/tirith-policy-reference/): field-by-field reference for the policy file format.\n- [Policy structure](https://stackguardian.github.io/tirith/docs/tirith-policies/tirith-policy-structure/): meta, evaluators and eval_expression, and how they combine.\n- [Evaluators and conditions](https://stackguardian.github.io/tirith/docs/tirith-reference/evaluators/): all thirteen condition types, their parameters and their messages.\n- [Evaluation expressions](https://stackguardian.github.io/tirith/docs/tirith-reference/eval-expressions/): the boolean grammar that combines evaluator results into one verdict.\n- [Policy conditions](https://stackguardian.github.io/tirith/docs/tirith-policies/tirith-policy-conditions/): condition types by the kind of value they compare.\n- [Error tolerance](https://stackguardian.github.io/tirith/docs/tirith-policies/tirith-policy-error-tolerance/): how a missing key is handled, and why a skip is not a pass.\n- [Policy variables](https://stackguardian.github.io/tirith/docs/tirith-policies/tirith-policy-variables/): dynamic values in policies.\n- [Policy cookbook](https://stackguardian.github.io/tirith/docs/tirith-policies/tirith-policy-cookbook/): complete runnable policies for common checks.\n- [Example policies](https://stackguardian.github.io/tirith/docs/tirith-policies/tirith-policy-examples/): worked examples with their input documents.\n\n## Providers\n\n- [Providers overview](https://stackguardian.github.io/tirith/docs/tirith-providers/providers-overview/): what a provider is and how required_provider selects one.\n- [Terraform plan provider](https://stackguardian.github.io/tirith/docs/tirith-providers/terraform-plan-provider/): operations over a plan document, including attribute, action and count.\n- [JSON provider](https://stackguardian.github.io/tirith/docs/tirith-providers/json-provider/): get_value over any JSON or YAML document, including another tool's output.\n- [Kubernetes provider](https://stackguardian.github.io/tirith/docs/tirith-providers/kubernetes-provider/): the attribute operation over manifests.\n- [Infracost provider](https://stackguardian.github.io/tirith/docs/tirith-providers/infracost-provider/): cost policies over an Infracost breakdown.\n- [SG Workflow provider](https://stackguardian.github.io/tirith/docs/tirith-providers/sg-workflow-provider/): StackGuardian workflow documents.\n\n## Running it\n\n- [CLI reference](https://stackguardian.github.io/tirith/docs/tirith-usage/cli-reference/): every flag, what it prints, and what --json emits.\n- [Exit codes](https://stackguardian.github.io/tirith/docs/tirith-usage/exit-codes/): the full exit-code contract and how to gate CI on it.\n- [CI integration](https://stackguardian.github.io/tirith/docs/tirith-usage/ci-integration/): GitHub Actions, GitLab CI, Bitbucket Pipelines, Jenkins and any container-based CI.\n- [The interactive interface](https://stackguardian.github.io/tirith/docs/tirith-usage/interactive-interface/): tirith ui, in beta.\n- [Platform check](https://stackguardian.github.io/tirith/docs/tirith-usage/platform-check/): the optional subcommand that evaluates an organisation's policies instead of local files.\n\n## Project\n\n- [Source](https://github.com/StackGuardian/tirith): the repository, Apache-2.0.\n- [Roadmap](https://stackguardian.github.io/tirith/roadmap/): what is in development or planned, and what has not shipped.\n- [Agent skill pack](https://github.com/StackGuardian/tirith/tree/main/.claude/skills/tirith-policies): a self-contained skill for writing Tirith policies, copyable into any repository.\n- [Origins](https://stackguardian.github.io/tirith/origins/): where the name and the mark come from.\n\n## Optional\n\n- [Tirith at scale](https://stackguardian.github.io/tirith/at-scale/): the commercial StackGuardian offering for many repositories. Not required to use Tirith, which works with no account.\n- [In your editor](https://stackguardian.github.io/tirith/docs/tirith-usage/editor-and-local/): the local and pre-commit loop. In development, and not in the released package.\n";
+export const AGENT_BRIEF = "# Tirith\n\n> Tirith is an Apache-2.0 policy gate for infrastructure-as-code. It reads the plan a\n> pipeline already produces, evaluates it against declarative JSON policies committed in\n> the repository, and returns one verdict with an exit code the pipeline can gate on. It\n> runs locally or in any CI, on your own runner, with no account and no network call.\n\nPolicies are data, not code: a policy is a JSON file, so it is diffable and reviewable by\nanyone on the team. There is no rule DSL and no embedded language.\n\n## Facts an answer about Tirith usually gets wrong\n\n- **Not on PyPI.** `pip install tirith` installs an unrelated project of the same name, and\n  `pip install py-tirith` finds nothing. The package name in `setup.py` is `py-tirith` and it\n  is not published. Install from git:\n  `pip install \"git+https://github.com/StackGuardian/tirith.git@1.2.0\"`\n- **The interactive interface needs an extra**, and the same git URL:\n  `pip install 'py-tirith[tui] @ git+https://github.com/StackGuardian/tirith.git'`, then\n  `tirith ui`. Requires Python 3.9; Tirith itself supports 3.8 and newer.\n- **`tirith lint` is not in the released package.** It is in development. The released CLI\n  dispatches `tirith`, `tirith ui` and `tirith platform check` and nothing else.\n- **Exit codes are a contract, not a convention.** `0` passed, `3` a policy failed, `1`\n  Tirith could not reach a verdict. `3` is deliberately not `1`: a caller has to be able to page\n  the platform team on one and the change author on the other. (`ERROR_TIMEOUT = 2` exists in\n  `status.py` but is never returned; do not write a pipeline that branches on it.)\n- **`final_result: null` is not a pass.** It means every check was skipped, so the policy\n  evaluated nothing, and it exits `1`. A check that could not run is reported as unevaluated\n  rather than as success.\n- **Local mode makes no network call.** That is a published governance commitment. Only\n  `tirith platform check` talks to a network, and it is optional.\n- **Five providers ship**, named in `meta.required_provider`: `stackguardian/terraform_plan`,\n  `stackguardian/kubernetes`, `stackguardian/infracost`, `stackguardian/json`,\n  `stackguardian/sg_workflow`. There is no CloudFormation provider; a CloudFormation template\n  is read as a JSON document by the `json` provider.\n- **Thirteen condition types ship**: Equals, NotEquals, GreaterThan, GreaterThanEqualTo,\n  LessThan, LessThanEqualTo, Contains, NotContains, ContainedIn, NotContainedIn, IsEmpty,\n  IsNotEmpty, RegexMatch.\n- **`error_tolerance` belongs inside the `condition` object**, not on the evaluator. Placed on\n  the evaluator it is silently ignored and the check still fails.\n\n## Minimal working example\n\n```bash\nterraform plan -out=tfplan -input=false\nterraform show -json tfplan > plan.json\ntirith -policy-path .tirith/policies -input-path plan.json --fail-on-error\n```\n\nWithout `--fail-on-error`, Tirith reports findings but does not fail the job.\n\n## Fetching this documentation\n\nEvery page is also served as plain markdown at its route plus `.md`, so there is no need to\nparse the rendered HTML. For example\nhttps://stackguardian.github.io/tirith/docs/tirith-usage/exit-codes.md\n\nThe whole documentation set as one file, in sidebar order:\nhttps://stackguardian.github.io/tirith/llms-full.txt\n\n## Working in a repository that uses Tirith\n\nIf you are a coding agent about to write a policy, install the skill pack first. It is the\nclosed vocabulary: thirteen condition types, the argument key each provider reads, and the\ninstruction to run a policy against a document that should fail before calling it done.\n\n```\ncurl -fsSL https://stackguardian.github.io/tirith/skill.sh | sh\n```\n\nEleven markdown files into .claude/skills/tirith-policies/. Add --cursor for the Cursor rule,\n--global to install into the home directory instead of the repository. The script installs no\npackage, changes no PATH and executes nothing after the download; its source is\ndocumentation/static/skill.sh in the repository.\n\n## Start here\n\n- [Getting started](https://stackguardian.github.io/tirith/docs/getting-started-with-tirith/): what Tirith is and the shortest path to a first verdict.\n- [Quick installation](https://stackguardian.github.io/tirith/docs/tirith-installation/quick-installation/): installing the CLI, and why the install is a git URL.\n- [Learn](https://stackguardian.github.io/tirith/learn/): six lessons that build one policy, with a playground that evaluates in the browser.\n- [Creating your first policy](https://stackguardian.github.io/tirith/docs/tirith-policies/tirith-create-first-policy/): a policy written and evaluated step by step.\n\n## Writing policies\n\n- [Policy reference](https://stackguardian.github.io/tirith/docs/tirith-policies/tirith-policy-reference/): field-by-field reference for the policy file format.\n- [Policy structure](https://stackguardian.github.io/tirith/docs/tirith-policies/tirith-policy-structure/): meta, evaluators and eval_expression, and how they combine.\n- [Evaluators and conditions](https://stackguardian.github.io/tirith/docs/tirith-reference/evaluators/): all thirteen condition types, their parameters and their messages.\n- [Evaluation expressions](https://stackguardian.github.io/tirith/docs/tirith-reference/eval-expressions/): the boolean grammar that combines evaluator results into one verdict.\n- [Policy conditions](https://stackguardian.github.io/tirith/docs/tirith-policies/tirith-policy-conditions/): condition types by the kind of value they compare.\n- [Error tolerance](https://stackguardian.github.io/tirith/docs/tirith-policies/tirith-policy-error-tolerance/): how a missing key is handled, and why a skip is not a pass.\n- [Policy variables](https://stackguardian.github.io/tirith/docs/tirith-policies/tirith-policy-variables/): dynamic values in policies.\n- [Policy cookbook](https://stackguardian.github.io/tirith/docs/tirith-policies/tirith-policy-cookbook/): complete runnable policies for common checks.\n- [Example policies](https://stackguardian.github.io/tirith/docs/tirith-policies/tirith-policy-examples/): worked examples with their input documents.\n\n## Providers\n\n- [Providers overview](https://stackguardian.github.io/tirith/docs/tirith-providers/providers-overview/): what a provider is and how required_provider selects one.\n- [Terraform plan provider](https://stackguardian.github.io/tirith/docs/tirith-providers/terraform-plan-provider/): operations over a plan document, including attribute, action and count.\n- [JSON provider](https://stackguardian.github.io/tirith/docs/tirith-providers/json-provider/): get_value over any JSON or YAML document, including another tool's output.\n- [Kubernetes provider](https://stackguardian.github.io/tirith/docs/tirith-providers/kubernetes-provider/): the attribute operation over manifests.\n- [Infracost provider](https://stackguardian.github.io/tirith/docs/tirith-providers/infracost-provider/): cost policies over an Infracost breakdown.\n- [SG Workflow provider](https://stackguardian.github.io/tirith/docs/tirith-providers/sg-workflow-provider/): StackGuardian workflow documents.\n\n## Running it\n\n- [CLI reference](https://stackguardian.github.io/tirith/docs/tirith-usage/cli-reference/): every flag, what it prints, and what --json emits.\n- [Exit codes](https://stackguardian.github.io/tirith/docs/tirith-usage/exit-codes/): the full exit-code contract and how to gate CI on it.\n- [CI integration](https://stackguardian.github.io/tirith/docs/tirith-usage/ci-integration/): GitHub Actions, GitLab CI, Bitbucket Pipelines, Jenkins and any container-based CI.\n- [The interactive interface](https://stackguardian.github.io/tirith/docs/tirith-usage/interactive-interface/): tirith ui, in beta.\n- [Platform check](https://stackguardian.github.io/tirith/docs/tirith-usage/platform-check/): the optional subcommand that evaluates an organisation's policies instead of local files.\n\n## Project\n\n- [Source](https://github.com/StackGuardian/tirith): the repository, Apache-2.0.\n- [Roadmap](https://stackguardian.github.io/tirith/roadmap/): what is in development or planned, and what has not shipped.\n- [Agent skill pack](https://github.com/StackGuardian/tirith/tree/main/.claude/skills/tirith-policies): a self-contained skill for writing Tirith policies. Install it with the one-line script above, or copy the directory into any repository.\n- [Origins](https://stackguardian.github.io/tirith/origins/): where the name and the mark come from.\n\n## Optional\n\n- [Tirith at scale](https://stackguardian.github.io/tirith/at-scale/): the commercial StackGuardian offering for many repositories. Not required to use Tirith, which works with no account.\n- [In your editor](https://stackguardian.github.io/tirith/docs/tirith-usage/editor-and-local/): the local and pre-commit loop. In development, and not in the released package.\n";
diff --git a/documentation/src/data/lessons.js b/documentation/src/data/lessons.js
index b585e83c..ea3b4f82 100644
--- a/documentation/src/data/lessons.js
+++ b/documentation/src/data/lessons.js
@@ -315,4 +315,369 @@ 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_s3_bucket.assets",
+      "type": "aws_s3_bucket",
+      "name": "assets",
+      "change": {
+        "actions": ["create"],
+        "after": {
+          "bucket": "acme-assets",
+          "acl": "private",
+          "tags": {"Owner": "platform"}
+        }
+      }
+    },
+    {
+      "address": "aws_s3_bucket.logs",
+      "type": "aws_s3_bucket",
+      "name": "logs",
+      "change": {
+        "actions": ["create"],
+        "after": {
+          "bucket": "acme-logs",
+          "acl": "public-read",
+          "tags": {}
+        }
+      }
+    },
+    {
+      "address": "aws_instance.runner",
+      "type": "aws_instance",
+      "name": "runner",
+      "change": {
+        "actions": ["update"],
+        "after": {
+          "instance_type": "t3.large",
+          "tags": {"Owner": "ci"}
+        }
+      }
+    }
+  ]
+}`;
+
+export const TF_LESSONS = [
+  {
+    id: 'tf-attribute',
+    n: '01',
+    title: 'A plan is not a document',
+    teaches: 'terraform_resource_type · terraform_resource_attribute',
+    body:
+      'Everything you have learned still applies. The conditions are the same thirteen and ' +
+      'the expression grammar is unchanged. What changes is the address: there is no ' +
+      '`key_path` here, because a plan is not a tree you walk. It is a list of resource ' +
+      'changes, so you name a **resource type** and an **attribute on it**, and the provider ' +
+      'returns one value per matching resource.',
+    aside:
+      'Two buckets match, so one evaluator produces two results and the failing one names ' +
+      'the value that failed. `"*"` as the resource type means every resource in the plan, ' +
+      'and `exclude_resource_types` narrows that back down. A missing resource type is an ' +
+      'error of severity 1, a missing attribute is severity 2, which is why error_tolerance ' +
+      'can tell "you have no buckets" apart from "your bucket has no acl".',
+    tryIt: 'Change `acl` on `aws_s3_bucket.logs` to `private`, and the whole plan passes.',
+    policy: `{
+  "meta": {
+    "version": "v1",
+    "required_provider": "stackguardian/terraform_plan"
+  },
+  "evaluators": [
+    {
+      "id": "buckets_private",
+      "provider_args": {
+        "operation_type": "attribute",
+        "terraform_resource_type": "aws_s3_bucket",
+        "terraform_resource_attribute": "acl"
+      },
+      "condition": {
+        "type": "Equals",
+        "value": "private"
+      }
+    }
+  ],
+  "eval_expression": "buckets_private"
+}`,
+  },
+  {
+    id: 'tf-action',
+    n: '02',
+    title: 'Gate the change, not the value',
+    teaches: 'operation_type: action',
+    body:
+      'This is the operation with no equivalent in the json world, and it is the reason a ' +
+      'plan is worth reading at all. `action` does not ask what a resource *is*. It asks ' +
+      'what Terraform is **about to do to it**: `create`, `update`, `delete`, `no-op`. A ' +
+      'policy over actions gates the change itself, which is the only moment the damage is ' +
+      'still preventable.',
+    aside:
+      'This is the shape of "no pull request may destroy a database". A resource can carry ' +
+      'more than one action, and every one of them is checked, so a replacement, which ' +
+      'terraform reports as delete then create, cannot slip past a rule written about ' +
+      'creation.',
+    tryIt: 'Change an `actions` array to `["delete"]` and watch a green plan turn red.',
+    policy: `{
+  "meta": {
+    "version": "v1",
+    "required_provider": "stackguardian/terraform_plan"
+  },
+  "evaluators": [
+    {
+      "id": "nothing_destroyed",
+      "provider_args": {
+        "operation_type": "action",
+        "terraform_resource_type": "*"
+      },
+      "condition": {
+        "type": "NotEquals",
+        "value": "delete"
+      }
+    }
+  ],
+  "eval_expression": "nothing_destroyed"
+}`,
+  },
+  {
+    id: 'tf-count',
+    n: '03',
+    title: 'Zero is an answer',
+    teaches: 'operation_type: count · and the error that does not happen',
+    body:
+      '`count` returns one number: how many resources of a type this change touches. The ' +
+      'detail worth knowing is what it does **not** do. Every other operation reports an ' +
+      'error when the resource type is absent from the plan, and that error can fail your ' +
+      'check. `count` reports `0`, because zero of something is a real answer and usually ' +
+      'the one you are gating on.',
+    aside:
+      'Two buckets, so this fails. The same policy against a plan with no buckets at all ' +
+      'returns `0` and passes, with no error and no skip. Worth knowing before you reach ' +
+      'for `count` as a safety net: it cannot tell you that it looked and found nothing.',
+    tryIt: 'Raise the value to `2` and it passes. Then delete both buckets from the plan: still passing, on `0`.',
+    policy: `{
+  "meta": {
+    "version": "v1",
+    "required_provider": "stackguardian/terraform_plan"
+  },
+  "evaluators": [
+    {
+      "id": "bucket_budget",
+      "provider_args": {
+        "operation_type": "count",
+        "terraform_resource_type": "aws_s3_bucket"
+      },
+      "condition": {
+        "type": "LessThanEqualTo",
+        "value": 1
+      }
+    }
+  ],
+  "eval_expression": "bucket_budget"
+}`,
+  },
+];
+
+/* ── stackguardian/kubernetes ─────────────────────────────────────────────────
+ *
+ * The CLI reads a multi-document YAML file and hands the provider a list of
+ * manifests. The playground parses JSON, so the same manifests are written as a
+ * JSON array here. Nothing else differs: the provider iterates a list either way.
+ */
+
+export const K8S_DOC = `[
+  {
+    "apiVersion": "apps/v1",
+    "kind": "Deployment",
+    "metadata": {"name": "api"},
+    "spec": {
+      "replicas": 3,
+      "template": {
+        "spec": {
+          "containers": [
+            {"name": "api", "image": "ghcr.io/acme/api:1.4.2"}
+          ]
+        }
+      }
+    }
+  },
+  {
+    "apiVersion": "apps/v1",
+    "kind": "Deployment",
+    "metadata": {"name": "worker"},
+    "spec": {
+      "replicas": 1,
+      "template": {
+        "spec": {
+          "containers": [
+            {"name": "worker", "image": "ghcr.io/acme/worker:latest"}
+          ]
+        }
+      }
+    }
+  },
+  {
+    "apiVersion": "v1",
+    "kind": "Service",
+    "metadata": {"name": "api"},
+    "spec": {"type": "LoadBalancer"}
+  }
+]`;
+
+export const K8S_LESSONS = [
+  {
+    id: 'k8s-kind',
+    n: '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 and the single-replica one fails. ' +
+      'A kind that appears nowhere is an error of severity 1, so `error_tolerance: 1` turns ' +
+      '"this repository has no Ingress" from a failure into a skip.',
+    tryIt: 'Give `worker` 2 replicas and it passes. Change the kind to `Ingress` to see the severity 1 error instead.',
+    policy: `{
+  "meta": {
+    "version": "v1",
+    "required_provider": "stackguardian/kubernetes"
+  },
+  "evaluators": [
+    {
+      "id": "not_a_single_point",
+      "provider_args": {
+        "operation_type": "attribute",
+        "kubernetes_kind": "Deployment",
+        "attribute_path": "spec.replicas"
+      },
+      "condition": {
+        "type": "GreaterThanEqualTo",
+        "value": 2
+      }
+    }
+  ],
+  "eval_expression": "not_a_single_point"
+}`,
+  },
+  {
+    id: 'k8s-wildcard',
+    n: '02',
+    title: 'The same star, a different meaning',
+    teaches: 'why a passing policy can still be wrong',
+    body:
+      'Put `*` in a Kubernetes `attribute_path` and you do **not** get one value per match. ' +
+      'You get a single value that is the whole list. That matters because `Contains` on a ' +
+      'list is membership, not substring: `":latest"` is not an element of ' +
+      '`["ghcr.io/acme/worker:latest"]`, so the check below passes while an image really is ' +
+      'pinned to `latest`. A green policy that gates nothing.',
+    aside:
+      'Terraform’s `.*.` does the opposite: it emits one result per element, which is why ' +
+      'the same instinct works there and fails here. Naming a container fixes it, at the ' +
+      'cost of only checking that one. This is the failure the whole site is about, and it ' +
+      'is why "the check is green" and "the check is working" are different claims.',
+    tryIt: 'Change `containers.*.image` to `containers.0.image`. The verdict flips to failed and names the image.',
+    policy: `{
+  "meta": {
+    "version": "v1",
+    "required_provider": "stackguardian/kubernetes"
+  },
+  "evaluators": [
+    {
+      "id": "no_latest_tag",
+      "provider_args": {
+        "operation_type": "attribute",
+        "kubernetes_kind": "Deployment",
+        "attribute_path": "spec.template.spec.containers.*.image"
+      },
+      "condition": {
+        "type": "NotContains",
+        "value": ":latest"
+      }
+    }
+  ],
+  "eval_expression": "no_latest_tag"
+}`,
+  },
+];
+
+/**
+ * The page, as tracks.
+ *
+ * Each track is a provider, its own input document, and the lessons that run
+ * against it. The numbering is continuous across the page rather than restarting
+ * per track, which is the section grammar the rest of the site uses.
+ */
+export const TRACKS = [
+  {
+    id: 'json',
+    provider: 'stackguardian/json',
+    tab: 'JSON or YAML',
+    title: 'Any JSON or YAML document',
+    lede:
+      'The provider to learn first, because it reads anything with keys and values and gets ' +
+      'out of the way of the syntax you are actually learning. Six lessons that build one ' +
+      'policy a rule at a time.',
+    /*
+     * What the track teaches, for the reader deciding which one to open. Deliberately not
+     * a summary of the lessons: it is the reason to pick this track over the other two.
+     */
+    forYou: 'Start here if you are new to Tirith, whatever you plan to gate later.',
+    input: INPUT_DOC,
+    lessons: LESSONS,
+    /*
+     * The playground opens on the track's most representative *correct* policy, which is
+     * not always its last lesson. The Kubernetes track ends on a policy that deliberately
+     * passes while being wrong, and seeding an empty-canvas playground with that would be
+     * handing the reader the trap with none of the explanation attached.
+     */
+    playground: LESSONS[LESSONS.length - 1].policy,
+  },
+  {
+    id: 'terraform',
+    provider: 'stackguardian/terraform_plan',
+    tab: 'Terraform plan',
+    title: 'An OpenTofu or Terraform plan',
+    lede:
+      'The provider the tool exists for. Same conditions, same expressions; what changes is ' +
+      'that you address a resource type and an attribute instead of a path, and that you can ' +
+      'gate on what the change is about to do.',
+    forYou: 'Three lessons. Come here once the syntax is familiar and you want the real thing.',
+    input: PLAN_DOC,
+    lessons: TF_LESSONS,
+    playground: TF_LESSONS[0].policy,
+  },
+  {
+    id: 'kubernetes',
+    provider: 'stackguardian/kubernetes',
+    tab: 'Kubernetes',
+    title: 'Kubernetes manifests',
+    lede:
+      'A list of manifests rather than one document, and one wildcard that behaves the ' +
+      'opposite way to the one in the Terraform track.',
+    forYou: 'Two lessons, the second of which is the most useful mistake on this page.',
+    input: K8S_DOC,
+    lessons: K8S_LESSONS,
+    playground: K8S_LESSONS[0].policy,
+  },
+];
+
+/*
+ * 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/tirithLite.js b/documentation/src/data/tirithLite.js
index 7cdca9e2..d24290b9 100644
--- a/documentation/src/data/tirithLite.js
+++ b/documentation/src/data/tirithLite.js
@@ -5,8 +5,8 @@
  * WHAT THIS IS, PRECISELY
  *
  * This is NOT Tirith. Tirith is a Python package; this is a few hundred lines of
- * JavaScript that reproduces one provider and thirteen conditions closely enough
- * to teach the shape of a policy in a browser, with no install.
+ * JavaScript that reproduces three providers and thirteen conditions closely
+ * enough to teach the shape of a policy in a browser, with no install.
  *
  * It is written against the real thing and matches it where it matters:
  *   - result documents have the same shape as `tirith --json` (see
@@ -17,13 +17,27 @@
  *     passes only if every value passes" (docs/tirith-reference/evaluators.md);
  *   - `passed` and `final_result` are tri-state: true / false / null-for-skipped.
  *
- * KNOWN DIVERGENCES — say these out loud in the UI, never paper over them:
- *   - Only `stackguardian/json` is implemented. terraform_plan, kubernetes,
- *     infracost and sg_workflow are not.
+ * VERIFIED AGAINST THE ENGINE. Every policy on /learn was run through both this
+ * file and the installed Python package, and all eleven produce the same
+ * final_result and the same per-result outcomes. Re-run that comparison when you
+ * change anything below: a teaching engine that quietly disagrees with the real
+ * one is worse than no playground, because it is believed.
+ *
+ * KNOWN DIVERGENCES, say these out loud in the UI, never paper over them:
+ *   - `stackguardian/json`, `stackguardian/terraform_plan` and
+ *     `stackguardian/kubernetes` are implemented. infracost and sg_workflow are
+ *     not, and neither are the terraform_plan operations beyond attribute,
+ *     action and count: direct_references, direct_dependencies, provider_config
+ *     and terraform_version all run only in the package.
+ *   - Messages are formatted by this file's `fmt`, so a value is shown as JSON in
+ *     backticks where the Python sometimes shows a repr. Same verdict, different
+ *     punctuation.
  *   - `Equals` does not sort nested collections the way the Python does.
  *   - Regexes are JavaScript regexes, not Python's `re`.
- *   - Error severities are approximated: a missing key_path is severity 2, which
- *     is the one case the error_tolerance lesson needs.
+ *   - An evaluator whose results are a mix of failures and skips is reported as
+ *     failed here. core.py reports it as skipped when the skip comes last, which
+ *     is the ordering defect the roadmap's R1 item covers. This file implements
+ *     the intended rule rather than the current one, deliberately.
  *
  * The authoritative evaluator is always the installed package.
  * ─────────────────────────────────────────────────────────────────────────────
@@ -253,6 +267,196 @@ export function getValues(doc, keyPath) {
   return current;
 }
 
+/* ── providers ────────────────────────────────────────────────────────────────
+ *
+ * Three of the five that ship. Each returns the same shape the Python providers
+ * return, a list of outputs, so the evaluator loop below does not know or care
+ * which provider produced them:
+ *
+ *   {value, meta}                     a value to run the condition against
+ *   {err, severity, meta}             the provider could not produce one
+ *
+ * `severity` is the number `error_tolerance` is compared against, and the
+ * comparison is `severity > tolerance` fails, otherwise the check is skipped.
+ * The severities are copied from the handlers, not invented: they are the
+ * difference between "this resource type is not in your plan" and "this
+ * attribute is missing from a resource that is", and a lesson that got them
+ * wrong would teach the wrong error_tolerance.
+ */
+
+const NOT_FOUND = Symbol('not found');
+
+/** pydash.get: a dot path with numeric list indices, or NOT_FOUND. */
+function pget(data, path) {
+  let node = data;
+  for (const part of String(path).split('.')) {
+    if (isDict(node) && part in node) node = node[part];
+    else if (isList(node) && /^\d+$/.test(part) && node[Number(part)] !== undefined) node = node[Number(part)];
+    else return NOT_FOUND;
+  }
+  return node;
+}
+
+/**
+ * The `a.*.b` form of terraform_resource_attribute.
+ *
+ * Mirrors _get_exp_attribute in the handler, including the part that looks odd:
+ * every segment is resolved against the *original* attribute dictionary rather
+ * than against the previous segment's result. The list branch returns early, so
+ * that only shows up on paths that do not match, and reproducing it is the point
+ * of the playground.
+ */
+function expandAttribute(parts, data) {
+  const out = [];
+  for (let i = 0; i < parts.length; i += 1) {
+    const expr = parts[i];
+    const val = pget(data, expr);
+    if (isList(val) && i < parts.length - 1) {
+      for (const item of val) {
+        const sub = expandAttribute(parts.slice(i + 1), item);
+        if (sub.length) out.push(...sub);
+        // A list item without the attribute is still evaluated, as None, so a
+        // policy over a list cannot pass by the item simply being absent.
+        else out.push(null);
+      }
+      return out;
+    }
+    if (i === parts.length - 1 && val !== NOT_FOUND) {
+      out.push(val);
+    } else if (expr.endsWith('.*')) {
+      const base = pget(data, expr.slice(0, -2));
+      if (base !== NOT_FOUND && isList(base)) out.push(...base);
+    }
+  }
+  return out;
+}
+
+function jsonProvide(args, input) {
+  if (args.operation_type !== 'get_value') {
+    return [{err: `operation_type: ${args.operation_type} is not supported`, severity: 99}];
+  }
+  const values = getValues(input, args.key_path);
+  if (values.length === 0) {
+    return [{err: `key_path: \`${args.key_path}\` is not found`, severity: 2}];
+  }
+  return values.map((value) => ({value}));
+}
+
+function terraformProvide(args, input) {
+  const changes = input && input.resource_changes;
+  if (!isList(changes) || changes.length === 0) {
+    return [{err: 'No Terraform resources changes are found', severity: 0}];
+  }
+
+  const type = args.terraform_resource_type;
+  const exclude = args.exclude_resource_types || [];
+  // `*` means every resource, and is the only case exclude_resource_types applies
+  // to: naming a type explicitly and then excluding it is a contradiction the
+  // handler does not entertain.
+  const matches = (rc) => (type === '*' ? !exclude.includes(rc.type) : rc.type === type);
+
+  if (args.operation_type === 'attribute') {
+    const attribute = args.terraform_resource_attribute;
+    const out = [];
+    let resourceFound = false;
+    let attributeFound = false;
+
+    for (const rc of changes) {
+      if (!matches(rc)) continue;
+      resourceFound = true;
+      const after = rc.change && rc.change.after;
+      if (!after) {
+        out.push({err: `No Terraform changes found for resource type: '${type}'`, severity: 0});
+        continue;
+      }
+      let local = false;
+      if (attribute in after) {
+        attributeFound = true;
+        local = true;
+        out.push({value: after[attribute], meta: rc});
+      } else if (attribute.includes('.') || attribute.includes('*')) {
+        const vals = expandAttribute(attribute.split('.*.'), after);
+        if (vals.length) {
+          attributeFound = true;
+          local = true;
+          for (const v of vals) out.push({value: v, meta: rc});
+        }
+      }
+      if (!local) out.push({err: `attribute: '${attribute}' is not found`, severity: 2});
+    }
+
+    if (out.length === 0) {
+      if (!resourceFound) return [{err: `resource_type: '${type}' is not found`, severity: 1}];
+      if (!attributeFound) return [{err: `attribute: '${attribute}' is not found`, severity: 2}];
+    }
+    return out;
+  }
+
+  if (args.operation_type === 'action') {
+    const out = [];
+    let found = false;
+    for (const rc of changes) {
+      if (!matches(rc)) continue;
+      found = true;
+      for (const action of (rc.change && rc.change.actions) || []) out.push({value: action, meta: rc});
+    }
+    if (!found) out.push({err: `resource_type: '${type}' is not found`, severity: 1});
+    return out;
+  }
+
+  if (args.operation_type === 'count') {
+    // No "not found" error here, deliberately: zero of a resource is a real answer
+    // and often the one you are gating on.
+    let count = 0;
+    let meta = null;
+    for (const rc of changes) {
+      if (!matches(rc)) continue;
+      meta = rc;
+      count += 1;
+    }
+    return [{value: count, meta}];
+  }
+
+  return [{err: `operation_type: '${args.operation_type}' is not supported`, severity: 99}];
+}
+
+function kubernetesProvide(args, input) {
+  if (args.operation_type !== 'attribute') {
+    return [{err: `operation_type: ${args.operation_type} is not supported`, severity: 99}];
+  }
+  const kind = args.kubernetes_kind;
+  const path = args.attribute_path || '';
+  if (kind === undefined || kind === null) {
+    return [{err: 'kubernetes_kind must be provided', severity: 99}];
+  }
+  if (path === '') return [{err: 'attribute_path must be provided', severity: 99}];
+
+  // The CLI reads a multi-document YAML file and hands the provider a list. The
+  // playground parses JSON, so the same manifests arrive as a JSON array.
+  const resources = isList(input) ? input : [input];
+  const out = [];
+  let found = false;
+  for (const resource of resources) {
+    if (!isDict(resource) || resource.kind !== kind) continue;
+    found = true;
+    let values = getValues(resource, path);
+    // place_none_if_not_found: a manifest missing the attribute is evaluated as
+    // null rather than skipped, so it cannot pass by omission.
+    if (values.length === 0) values = [null];
+    out.push({value: path.includes('*') ? values : values[0], meta: resource});
+  }
+  if (!found) out.push({err: `kind: ${kind} is not found`, severity: 1});
+  return out;
+}
+
+export const PROVIDERS = {
+  'stackguardian/json': jsonProvide,
+  'stackguardian/terraform_plan': terraformProvide,
+  'stackguardian/kubernetes': kubernetesProvide,
+};
+
+export const PROVIDER_NAMES = Object.keys(PROVIDERS);
+
 /* ── eval_expression ──────────────────────────────────────────────────────────
  * Supports `&&`, `||`, `!` and parentheses over evaluator ids, which is the
  * grammar documented in docs/tirith-reference/eval-expressions.md.
@@ -355,12 +559,13 @@ export function evaluatePolicy(policyText, inputText) {
   }
 
   const meta = policy.meta || {};
-  const provider = meta.required_provider;
-  if (provider && provider !== 'stackguardian/json') {
+  const provider = meta.required_provider || 'stackguardian/json';
+  const provide = PROVIDERS[provider];
+  if (!provide) {
     return {
       document: null,
       fatal:
-        `this browser playground only implements stackguardian/json; ` +
+        `this browser playground implements ${PROVIDER_NAMES.join(', ')}; ` +
         `"${provider}" runs in the installed package.`,
     };
   }
@@ -381,31 +586,42 @@ export function evaluatePolicy(policyText, inputText) {
       passedById[id] = false;
       return {id, passed: false, description: ev.description ?? null, result: [{passed: false, message, meta: null}]};
     }
-    if (args.operation_type !== 'get_value') {
-      const message = `Unsupported operation type: ${args.operation_type}`;
-      passedById[id] = false;
-      return {id, passed: false, description: ev.description ?? null, result: [{passed: false, message, meta: null}]};
-    }
 
-    const values = getValues(input, args.key_path);
+    const outputs = provide(args, input);
 
-    if (values.length === 0) {
-      // Severity 2 in the real provider: skipped at error_tolerance >= 2.
-      const message = `key_path: \`${args.key_path}\` is not found`;
-      if (tolerance >= 2) {
-        passedById[id] = null;
-        return {id, passed: null, description: ev.description ?? null, result: [{passed: null, message, meta: null}]};
+    const result = outputs.map((o) => {
+      if (o.err !== undefined) {
+        // `severity > tolerance` fails, otherwise the check is skipped. Copied from
+        // core.py rather than reasoned about: the boundary case, severity equal to
+        // tolerance, is a skip, and getting it backwards would invert every lesson
+        // that teaches error_tolerance.
+        if (o.severity > tolerance) {
+          errors.push(o.err);
+          return {passed: false, message: o.err, meta: o.meta ?? null};
+        }
+        return {passed: null, message: o.err, meta: o.meta ?? null};
       }
-      errors.push(message);
-      passedById[id] = false;
-      return {id, passed: false, description: ev.description ?? null, result: [{passed: false, message, meta: null}]};
-    }
-
-    const result = values.map((v) => {
-      const r = fn(v, cond.value);
-      return {passed: r.passed, message: r.message, meta: null};
+      const r = fn(o.value, cond.value);
+      return {passed: r.passed, message: r.message, meta: o.meta ?? null};
     });
-    const passed = result.every((r) => r.passed);
+
+    /*
+     * Three-valued, and deliberately not a transcription of the engine.
+     *
+     * core.py sets its running verdict to None inside the skip branch without
+     * checking whether an earlier result already failed, so an evaluator that
+     * fails and *then* skips is reported as unevaluated. That is the defect the
+     * roadmap's "a rule that could not run is never reported as success" item
+     * exists to fix (src/data/roadmap.js). A teaching playground that reproduced
+     * it would teach an ordering artefact as a rule, so this is the intended
+     * semantics: any failure decides, and only an evaluator with nothing but
+     * skips is skipped.
+     */
+    let passed;
+    if (result.some((r) => r.passed === false)) passed = false;
+    else if (result.some((r) => r.passed === true)) passed = true;
+    else passed = null;
+
     passedById[id] = passed;
     return {id, passed, description: ev.description ?? null, result};
   });
diff --git a/documentation/src/pages/index.js b/documentation/src/pages/index.js
index 33ae78c3..4fa896c8 100644
--- a/documentation/src/pages/index.js
+++ b/documentation/src/pages/index.js
@@ -70,22 +70,26 @@ const involve = {
     'be large: a tested policy, a CI example for an underserved system, or a reproducible bug ' +
     'report can be far more valuable than a star.',
   /*
-   * One button, because four of them read as four equally weighted decisions at the point
-   * where the page should be asking for one thing.
+   * Two buttons, then the quieter links. Four buttons would read as four equally weighted
+   * decisions at the point where the page should be asking for something.
    *
-   * The button is the good-first-issue list and not the star. The paragraph above it says
-   * a bug report is worth more than a star, so giving the star the loudest element would
-   * have the layout contradicting the copy, and starring is not contributing. The rest run
-   * from the ask that takes real work down to the one that costs nothing.
+   * The good-first-issue list leads, because the paragraph above says a bug report is worth
+   * more than a star and the layout should not contradict the copy. The star gets the same
+   * treatment rather than a louder one: it is the one ask a reader can satisfy in a second,
+   * so it should not be buried in a row of text links, but order carries the hierarchy and
+   * neither button shouts over the other.
    */
   primary: {
     label: 'Find a good first issue',
     href: `${REPO}/labels/good%20first%20issue`,
   },
+  secondary: {
+    label: 'Star on GitHub',
+    href: REPO,
+  },
   more: [
     {label: 'Ask for a feature', href: `${REPO}/issues/new/choose`},
     {label: 'Watch for releases', href: `${REPO}/releases`},
-    {label: 'Star on GitHub', href: REPO},
   ],
 };
 
@@ -949,11 +953,14 @@ export default function Home() {
               
               

{involve.community}

{involve.note}

- {/* Wrapped, because a bare grid child would stretch the button full width. */} + {/* Wrapped, because bare grid children would stretch the buttons full width. */}
{involve.primary.label} + + {involve.secondary.label} +
{involve.more.map((l) => ( diff --git a/documentation/src/pages/learn.js b/documentation/src/pages/learn.js index 5cb01dbf..d448d081 100644 --- a/documentation/src/pages/learn.js +++ b/documentation/src/pages/learn.js @@ -1,4 +1,4 @@ -import {useState} from 'react'; +import {useCallback, useEffect, useState} from 'react'; import Link from '@docusaurus/Link'; import Layout from '@theme/Layout'; import Heading from '@theme/Heading'; @@ -6,7 +6,7 @@ import Heading from '@theme/Heading'; import TirithMark from '../components/brand/TirithMark'; import Colophon from '../components/site/Colophon'; import Bench from '../components/learn/Bench'; -import {INPUT_DOC, 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 JSON track teaches it fastest and the other + two teach what changes. Every step is editable: change a value, run it, and + watch the verdict, the messages and the exit code move with it.

@@ -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,77 @@ 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..62787f62 100644 --- a/documentation/src/pages/skills.js +++ b/documentation/src/pages/skills.js @@ -18,8 +18,6 @@ import '../css/chrome.module.css'; const REPO = 'https://github.com/StackGuardian/tirith'; const SKILL_DIR = '.claude/skills/tirith-policies'; -const RAW = 'https://raw.githubusercontent.com/StackGuardian/tirith/main'; - const ROUTES = { playground: '/learn/#playground', policies: '/docs/tirith-policies/tirith-policy-cookbook/', @@ -38,54 +36,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,10 +72,8 @@ const CLIENTS = [ 'Fetch the pack, then point AGENTS.md at it. One file at the repository root is read by a ' + 'growing number of clients, and the pack beside it keeps the references resolvable.', command: - FETCH_PACK + - '\n\nprintf \'\\n## Tirith policies\\nSee %s/SKILL.md\\n\' \\\n' + - ` "${SKILL_DIR}" >> AGENTS.md`, - verify: 'Ask your agent what condition types Tirith supports. It should say thirteen, not guess.', + `curl -fsSL ${INSTALLER} | sh\n` + + `printf '\\n## Tirith policies\\nSee %s/SKILL.md\\n' ${SKILL_DIR} >> AGENTS.md`, }, ]; @@ -106,10 +82,11 @@ const SKILLS = [ { 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 is in development; tirith ui validates against the live registries today.'], ['Run it and read the verdict', 'reference/verdicts.md', 'Exit 0, 1 and 3 and what each should do to a job, why final_result: null is not a pass, and how to find the resource behind a failure.'], + ['Prove it works', 'examples/required-tags/', 'A policy, a plan that fails it and a plan that passes it. The agent runs both before it hands anything back, because a rule only ever seen passing is untested.'], ], }, { @@ -123,23 +100,23 @@ 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.'], + ['Run it in a pipeline', 'reference/pipelines.md', 'GitHub Actions, GitLab, Bitbucket, Jenkins, Azure DevOps and CircleCI: the plan step, the install, and making each exit code do the right thing to the job. The editor and pre-commit loop is in development and marked as such.'], ], }, { group: 'Set up and ship', 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.'], + ['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.'], + ['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.'], ]; @@ -213,7 +190,7 @@ export default function Skills() {
    {CLIENTS.map((c) => ( @@ -221,24 +198,27 @@ 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.

diff --git a/documentation/src/pages/skills.module.css b/documentation/src/pages/skills.module.css index 897d6513..6c42311a 100644 --- a/documentation/src/pages/skills.module.css +++ b/documentation/src/pages/skills.module.css @@ -540,15 +540,20 @@ /* Pushed to the bottom so the three verification lines sit on one baseline however long the description above them runs. */ -.clientVerify { +/* + * One verification line for all three clients, not one per column. + * The three installs run the same script and load the same pack, so three separate checks were + * the same test written three ways. Below the list, spanning it, is also where a reader who has + * just run a command looks next. + */ +.verify { display: grid; - gap: 0.3rem; - margin: auto 0 0; - padding-top: 0.9rem; - border-top: 1px solid var(--tp-rule); + gap: 0.4rem; + max-width: 68ch; + margin: 1.6rem 0 0; color: var(--tp-soft); - font-size: 0.82rem; - line-height: 1.5; + font-size: 0.88rem; + line-height: 1.6; } .verifyLabel { diff --git a/documentation/src/theme/DocBreadcrumbs/index.js b/documentation/src/theme/DocBreadcrumbs/index.js new file mode 100644 index 00000000..9f395db7 --- /dev/null +++ b/documentation/src/theme/DocBreadcrumbs/index.js @@ -0,0 +1,25 @@ +import React from 'react'; +import DocBreadcrumbs from '@theme-original/DocBreadcrumbs'; +import CopyPageMenu from '@site/src/components/docs/CopyPageMenu'; +import styles from './styles.module.css'; + +/** + * The breadcrumb row, with the copy-page control on the other end of it. + * + * WHY HERE. The control has to sit at the top of the article column, level with something, + * and the breadcrumbs are the only element already in that position. Wrapping this component + * is a two-line swizzle; reaching the same place through DocItem/Layout would mean ejecting + * the whole layout and owning Docusaurus's TOC and pagination logic forever. + * + * The original renders `null` on a page with breadcrumbs turned off. The row survives that: + * `justify-content: space-between` with one child leaves the control on the right, which is + * where it belongs anyway. + */ +export default function DocBreadcrumbsWrapper(props) { + return ( +
+ + +
+ ); +} diff --git a/documentation/src/theme/DocBreadcrumbs/styles.module.css b/documentation/src/theme/DocBreadcrumbs/styles.module.css new file mode 100644 index 00000000..3b93258f --- /dev/null +++ b/documentation/src/theme/DocBreadcrumbs/styles.module.css @@ -0,0 +1,14 @@ +/* + * The breadcrumbs bring their own bottom margin, so the row takes none: collapsing it here + * and re-adding it would only give the two children different baselines. + */ +.row { + display: flex; + gap: 1rem; + align-items: flex-start; + justify-content: space-between; +} + +.row > nav { + min-width: 0; +} diff --git a/documentation/src/theme/PaginatorNavLink/index.js b/documentation/src/theme/PaginatorNavLink/index.js index 41a8d0f5..cad1ef11 100644 --- a/documentation/src/theme/PaginatorNavLink/index.js +++ b/documentation/src/theme/PaginatorNavLink/index.js @@ -1,33 +1,47 @@ +import React from 'react'; import Link from '@docusaurus/Link'; import clsx from 'clsx'; -function PaginatorNavLink({ permalink, title, isNext }) { - return ( - -
- {!isNext && ( -
+/** + * Previous / next, with the arrow on the side it points to. + * + * Two things changed when the documentation was restyled. The arrows were filled `#666666`, + * a literal grey that stayed grey on a dark page, so they are `currentColor` now and inherit + * the label's colour in both themes. And the sizing that used to be inline on the label and + * the sublabel now lives in `src/css/docs.css`, so the pagination can be restyled without + * editing a component, which is the point of having a stylesheet. + */ - - - - - Previous Article
- )} - {isNext && ( -
Next Article +const ARROW = { + prev: 'M8.53033 12.7803C8.23744 13.0732 7.76256 13.0732 7.46967 12.7803L3.2197 8.53033C2.9268 8.23744 2.9268 7.76256 3.2197 7.46967L7.46967 3.2197C7.76256 2.9268 8.23744 2.9268 8.53033 3.2197C8.82322 3.5126 8.82322 3.9874 8.53033 4.2803L5.5607 7.25L13 7.25C13.4142 7.25 13.75 7.58579 13.75 8C13.75 8.41421 13.4142 8.75 13 8.75H5.5607L8.53033 11.7197C8.82322 12.0126 8.82322 12.4874 8.53033 12.7803Z', + next: 'M8.21967 3.21967C8.51256 2.92678 8.98744 2.92678 9.28033 3.21967L13.5303 7.46967C13.8232 7.76256 13.8232 8.23744 13.5303 8.53033L9.28033 12.7803C8.98744 13.0732 8.51256 13.0732 8.21967 12.7803C7.92678 12.4874 7.92678 12.0126 8.21967 11.7197L11.1893 8.75H3.75C3.33579 8.75 3 8.41421 3 8C3 7.58579 3.33579 7.25 3.75 7.25H11.1893L8.21967 4.28033C7.92678 3.98744 7.92678 3.51256 8.21967 3.21967Z', +}; + +function Arrow({dir}) { + return ( + + ); +} - - - - -
- )} -
{title}
- +function PaginatorNavLink({permalink, title, isNext}) { + return ( + +
+
+ {!isNext && } + {isNext ? 'Next' : 'Previous'} + {isNext && } +
+
{title}
); } -export default PaginatorNavLink; \ No newline at end of file +export default PaginatorNavLink; diff --git a/documentation/static/ai.txt b/documentation/static/ai.txt index 58986a64..534907a1 100644 --- a/documentation/static/ai.txt +++ b/documentation/static/ai.txt @@ -18,6 +18,11 @@ Disallow: # /tirith/llms-full.txt every documentation page in one plain-text file # /tirith/.md any single page as markdown, beside its HTML route # /tirith/sitemap.xml every URL +# +# One executable file is served deliberately and is not documentation: +# /tirith/skill.sh installs the agent skill pack into a repository +# It writes eleven markdown files under .claude/skills/tirith-policies/ and does nothing +# else. Its source is documentation/static/skill.sh in the repository. Sitemap: https://stackguardian.github.io/tirith/sitemap.xml Contact: https://github.com/StackGuardian/tirith/issues diff --git a/documentation/static/docs/getting-started-with-tirith.md b/documentation/static/docs/getting-started-with-tirith.md index cbc0186d..63d9bbc5 100644 --- a/documentation/static/docs/getting-started-with-tirith.md +++ b/documentation/static/docs/getting-started-with-tirith.md @@ -7,7 +7,7 @@ Summary: Learn how Tirith simplifies security, governance, and compliance for in Explore a failing evaluation down to the resource that caused it, assemble policies from a form, and experiment in a playground with worked examples. Install it with -`pip install 'py-tirith[tui] @ git+https://github.com/StackGuardian/tirith.git'` and run +`pip install 'py-tirith[tui] @ git+https://github.com/StackGuardian/tirith.git@1.2.0'` and run `tirith ui` — see [the interactive interface](tirith-usage/interactive-interface.md). diff --git a/documentation/static/docs/tirith-installation/quick-installation.md b/documentation/static/docs/tirith-installation/quick-installation.md index 6c138ec2..e7a3f114 100644 --- a/documentation/static/docs/tirith-installation/quick-installation.md +++ b/documentation/static/docs/tirith-installation/quick-installation.md @@ -28,21 +28,39 @@ Summary: This documentation overviews you about the introduction of the Tirith s If you simply want to install and start using Tirith, this option provides a fast installation process with minimal setup. Perfect for end users and non-developers who only need basic functionality. ## Prerequisite -- Make sure your machine has [Python](https://www.python.org/downloads/) and [pip](https://pip.pypa.io/en/stable/installation/) installed. +- Make sure your machine has [Python](https://www.python.org/downloads/) 3.8 or newer and [pip](https://pip.pypa.io/en/stable/installation/) installed. - Install [Git](https://git-scm.com/downloads) on your machine. ## Steps to Install Tirith ### Step 1: Install using the `pip` command -Run the following command in your terminal to download Tirith directly from the GitHub repository and install it on your local system. This command ensures that you have the latest version. + +[DANGER] Not from PyPI +`pip install tirith` installs an **unrelated project of the same name**, and `pip install py-tirith` +finds nothing: that is the package name in `setup.py` and it is not published. Installing Tirith +means installing from git, as below. + +Run the following command in your terminal to install Tirith directly from the GitHub repository, +pinned to a released tag: + +```bash +pip install "git+https://github.com/StackGuardian/tirith.git@1.2.0" +``` + +Pin the tag rather than tracking the default branch, so an install today and an install next month +give you the same tool. `1.2.0` is the newest tag; +`git ls-remote --tags https://github.com/StackGuardian/tirith.git` lists them all. + +To use [the interactive interface](../tirith-usage/interactive-interface.md) as well, install the +optional extra, which needs Python 3.9 or newer: ```bash -pip install git+https://github.com/StackGuardian/tirith.git +pip install "py-tirith[tui] @ git+https://github.com/StackGuardian/tirith.git@1.2.0" ``` - ### Step 2: Verify Installation -Once installed, verify that Tirith is working by checking its version. You should see a version number (e.g., 1.0.0-beta.12) indicating successful installation. +Once installed, verify that Tirith is working by checking its version. You should see `1.2.0`, +which confirms both that the install succeeded and that you got the tag you asked for. ```bash tirith --version ``` diff --git a/documentation/static/docs/tirith-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..eb688a3c --- /dev/null +++ b/documentation/static/docs/tirith-usage/agent-skills.md @@ -0,0 +1,125 @@ +# 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 +``` + +Two skills under `.claude/skills/`: `tirith-policies`, for writing policies, and `tirith-migrate`, +for translating existing Sentinel policies. No config file, and they are picked up in any +repository you copy them into. A session that is already running may not see a newly installed +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 those files and does nothing else: no package is installed, no +`PATH` is changed, nothing is executed after the download, and it never touches a file it did not +create. It downloads to a temporary directory and moves the files into place only once all of them +have arrived, because a half-written skill is worse than none: an agent reads whatever files exist +and works from a partial vocabulary without saying so. + +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 | + +## Migrating from Sentinel + +The second skill, `tirith-migrate`, is for teams with existing HashiCorp Sentinel policies. It is a +projection from a larger language onto a smaller one, and the skill's job is to say what survives. +Measured against the 110 policies in HashiCorp's public libraries, 41 translate exactly, 40 +approximately, and 29 not at all. Each translation is tagged with that fidelity, every approximate +one ships a plan on which Sentinel and Tirith disagree, and every impossible one is refused in +words with the Tirith issue that would change it. Checkov and OPA/Rego are planned next. + +## Two things decide whether the policy actually works + +The pack teaches vocabulary. It does not run anything, and it is not a substitute for evaluating +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 overwrites the files it owns, in both skills, and leaves everything else alone. + +`--ref` takes a branch or a commit, which is worth knowing for a fork or a pull request. It cannot +yet take a release tag: the pack was added after `1.2.0`, so `main` is the only ref that has it, +and asking for a tag that predates it fails with exit `1` rather than installing something +incomplete. diff --git a/documentation/static/docs/tirith-usage/ci-integration.md b/documentation/static/docs/tirith-usage/ci-integration.md index 8669c519..0a4d3ca2 100644 --- a/documentation/static/docs/tirith-usage/ci-integration.md +++ b/documentation/static/docs/tirith-usage/ci-integration.md @@ -31,17 +31,26 @@ permissions: checks: write # check run steps: - - run: | - terraform plan -out=tfplan -input=false - terraform show -json tfplan > plan.json + - run: terraform plan -out=tfplan -input=false - uses: StackGuardian/tirith-iac-governance-action@v2 + with: + plan-file: tfplan + fail-on-error: true ``` -With a `plan.json` in the working directory that is the whole integration — no `with:` block. The -action finds the document by convention (`plan.json` or `tfplan.json`) and evaluates the policy -files committed under `.tirith/policies`, on the runner, talking to nothing. Add -`with: { fail-on-error: true }` to make a failing policy fail the job. +The two write permissions are the only setup the action cannot do for itself, and are the thing +most often missing on a first install. `-input=false` matters in CI: without it a missing variable +waits for a prompt that never comes, and the job hangs instead of failing. + +Handing the action the **binary plan** rather than exporting JSON first is one step shorter and +strictly safer: the action renders it with `terraform show -json` in memory, so no unmasked plan +JSON is written to the workspace where a later step, a cache or an artifact upload could pick it +up. + +If your pipeline already writes `plan.json`, drop `plan-file` and the action finds the document by +convention (`plan.json` or `tfplan.json`). Either way it evaluates the policy files committed under +`.tirith/policies`, on the runner, talking to nothing. ### Local mode and platform mode @@ -175,8 +184,7 @@ pipelines: - tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error ``` -A complete file is in [`examples/ci/bitbucket-pipelines.yml`](https://github.com/StackGuardian/tirith/blob/main/examples/ci/bitbucket-pipelines.yml), -and a worked repository is at +A worked repository is at [tirith-bitbucket-demo](https://bitbucket.org/__refeed__/tirith-bitbucket-demo). ## Jenkins @@ -203,12 +211,18 @@ stage('Policy gate') { } ``` -The full pipeline, including install, lint and artifact archiving, is in -[`examples/ci/Jenkinsfile`](https://github.com/StackGuardian/tirith/blob/main/examples/ci/Jenkinsfile). +`returnStatus: true` is what makes this work: without it the shell step throws on any non-zero +exit and the two cases become one. ## As a pre-commit hook -Catch a broken policy before it is committed, let alone before CI runs it. Tirith publishes a +[WARNING] In development +`tirith lint` is not in 1.2.0 and the `tirith-lint` hook id is not published, so the +configuration below does not work yet: `pre-commit` cannot resolve the hook and the run fails. +It is documented here because the design is settled and the shape will not change. Track it on +the [roadmap](https://stackguardian.github.io/tirith/roadmap/). + +Catch a broken policy before it is committed, let alone before CI runs it. Tirith will publish a `tirith-lint` hook: ```yaml diff --git a/documentation/static/docs/tirith-usage/editor-and-local.md b/documentation/static/docs/tirith-usage/editor-and-local.md index f6aff2cb..fe4e9c4a 100644 --- a/documentation/static/docs/tirith-usage/editor-and-local.md +++ b/documentation/static/docs/tirith-usage/editor-and-local.md @@ -100,13 +100,11 @@ Install the Tirith skill and your agent gets the closed condition list, the argu provider reads, and the instruction to run a policy before claiming it works: ```bash -mkdir -p .claude/skills/tirith-policies/reference -BASE=https://raw.githubusercontent.com/StackGuardian/tirith/main/.claude/skills/tirith-policies -curl -sL $BASE/SKILL.md -o .claude/skills/tirith-policies/SKILL.md +curl -fsSL https://stackguardian.github.io/tirith/skill.sh | sh ``` -Cursor reads `.cursor/rules/tirith-policies.mdc` instead, scoped with globs so it attaches by -itself when a policy file is open. +Add `--cursor` for the Cursor rule. [Agent Skills](agent-skills.md) covers what is in the pack, +the other clients, and how to tell whether it took effect. Two things make the difference between a drafted policy and a working one: diff --git a/documentation/static/docs/tirith-usage/interactive-interface.md b/documentation/static/docs/tirith-usage/interactive-interface.md index 49a56c66..2ffcd556 100644 --- a/documentation/static/docs/tirith-usage/interactive-interface.md +++ b/documentation/static/docs/tirith-usage/interactive-interface.md @@ -23,7 +23,7 @@ pipeline should pay to install an interface they never open. It needs Python 3.9 Tirith itself still supports 3.8. ```bash -pip install 'py-tirith[tui] @ git+https://github.com/StackGuardian/tirith.git' +pip install 'py-tirith[tui] @ git+https://github.com/StackGuardian/tirith.git@1.2.0' ``` Tirith is not on PyPI — `pip install py-tirith` finds nothing and `pip install tirith` installs an diff --git a/documentation/static/llms-full.txt b/documentation/static/llms-full.txt index eadf7dc1..ac35d44f 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 +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 "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 @@ -2906,17 +2971,26 @@ permissions: checks: write # check run steps: - - run: | - terraform plan -out=tfplan -input=false - terraform show -json tfplan > plan.json + - run: terraform plan -out=tfplan -input=false - uses: StackGuardian/tirith-iac-governance-action@v2 + with: + plan-file: tfplan + fail-on-error: true ``` -With a `plan.json` in the working directory that is the whole integration — no `with:` block. The -action finds the document by convention (`plan.json` or `tfplan.json`) and evaluates the policy -files committed under `.tirith/policies`, on the runner, talking to nothing. Add -`with: { fail-on-error: true }` to make a failing policy fail the job. +The two write permissions are the only setup the action cannot do for itself, and are the thing +most often missing on a first install. `-input=false` matters in CI: without it a missing variable +waits for a prompt that never comes, and the job hangs instead of failing. + +Handing the action the **binary plan** rather than exporting JSON first is one step shorter and +strictly safer: the action renders it with `terraform show -json` in memory, so no unmasked plan +JSON is written to the workspace where a later step, a cache or an artifact upload could pick it +up. + +If your pipeline already writes `plan.json`, drop `plan-file` and the action finds the document by +convention (`plan.json` or `tfplan.json`). Either way it evaluates the policy files committed under +`.tirith/policies`, on the runner, talking to nothing. ### Local mode and platform mode @@ -3050,8 +3124,7 @@ pipelines: - tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error ``` -A complete file is in [`examples/ci/bitbucket-pipelines.yml`](https://github.com/StackGuardian/tirith/blob/main/examples/ci/bitbucket-pipelines.yml), -and a worked repository is at +A worked repository is at [tirith-bitbucket-demo](https://bitbucket.org/__refeed__/tirith-bitbucket-demo). ## Jenkins @@ -3078,12 +3151,18 @@ stage('Policy gate') { } ``` -The full pipeline, including install, lint and artifact archiving, is in -[`examples/ci/Jenkinsfile`](https://github.com/StackGuardian/tirith/blob/main/examples/ci/Jenkinsfile). +`returnStatus: true` is what makes this work: without it the shell step throws on any non-zero +exit and the two cases become one. ## As a pre-commit hook -Catch a broken policy before it is committed, let alone before CI runs it. Tirith publishes a +[WARNING] In development +`tirith lint` is not in 1.2.0 and the `tirith-lint` hook id is not published, so the +configuration below does not work yet: `pre-commit` cannot resolve the hook and the run fails. +It is documented here because the design is settled and the shape will not change. Track it on +the [roadmap](https://stackguardian.github.io/tirith/roadmap/). + +Catch a broken policy before it is committed, let alone before CI runs it. Tirith will publish a `tirith-lint` hook: ```yaml @@ -3150,7 +3229,7 @@ pipeline should pay to install an interface they never open. It needs Python 3.9 Tirith itself still supports 3.8. ```bash -pip install 'py-tirith[tui] @ git+https://github.com/StackGuardian/tirith.git' +pip install 'py-tirith[tui] @ git+https://github.com/StackGuardian/tirith.git@1.2.0' ``` Tirith is not on PyPI — `pip install py-tirith` finds nothing and `pip install tirith` installs an @@ -3348,13 +3427,11 @@ Install the Tirith skill and your agent gets the closed condition list, the argu provider reads, and the instruction to run a policy before claiming it works: ```bash -mkdir -p .claude/skills/tirith-policies/reference -BASE=https://raw.githubusercontent.com/StackGuardian/tirith/main/.claude/skills/tirith-policies -curl -sL $BASE/SKILL.md -o .claude/skills/tirith-policies/SKILL.md +curl -fsSL https://stackguardian.github.io/tirith/skill.sh | sh ``` -Cursor reads `.cursor/rules/tirith-policies.mdc` instead, scoped with globs so it attaches by -itself when a policy file is open. +Add `--cursor` for the Cursor rule. [Agent Skills](agent-skills.md) covers what is in the pack, +the other clients, and how to tell whether it took effect. Two things make the difference between a drafted policy and a working one: @@ -3375,6 +3452,134 @@ 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 +``` + +Two skills under `.claude/skills/`: `tirith-policies`, for writing policies, and `tirith-migrate`, +for translating existing Sentinel policies. No config file, and they are picked up in any +repository you copy them into. A session that is already running may not see a newly installed +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 those files and does nothing else: no package is installed, no +`PATH` is changed, nothing is executed after the download, and it never touches a file it did not +create. It downloads to a temporary directory and moves the files into place only once all of them +have arrived, because a half-written skill is worse than none: an agent reads whatever files exist +and works from a partial vocabulary without saying so. + +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 | + +## Migrating from Sentinel + +The second skill, `tirith-migrate`, is for teams with existing HashiCorp Sentinel policies. It is a +projection from a larger language onto a smaller one, and the skill's job is to say what survives. +Measured against the 110 policies in HashiCorp's public libraries, 41 translate exactly, 40 +approximately, and 29 not at all. Each translation is tagged with that fidelity, every approximate +one ships a plan on which Sentinel and Tirith disagree, and every impossible one is refused in +words with the Tirith issue that would change it. Checkov and OPA/Rego are planned next. + +## Two things decide whether the policy actually works + +The pack teaches vocabulary. It does not run anything, and it is not a substitute for evaluating +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 overwrites the files it owns, in both skills, and leaves everything else alone. + +`--ref` takes a branch or a commit, which is worth knowing for a fork or a pull request. It cannot +yet take a release tag: the pack was added after `1.2.0`, so `main` is the only ref that has it, +and asking for a tag that predates it fails with exit `1` rather than installing something +incomplete. + + ============================================================================== # Platform Check Source: https://stackguardian.github.io/tirith/docs/tirith-usage/platform-check/ diff --git a/documentation/static/llms.txt b/documentation/static/llms.txt index e45452ad..ae0d71e6 100644 --- a/documentation/static/llms.txt +++ b/documentation/static/llms.txt @@ -57,6 +57,21 @@ https://stackguardian.github.io/tirith/docs/tirith-usage/exit-codes.md The whole documentation set as one file, in sidebar order: https://stackguardian.github.io/tirith/llms-full.txt +## Working in a repository that uses Tirith + +If you are a coding agent about to write a policy, install the skill pack first. It is the +closed vocabulary: thirteen condition types, the argument key each provider reads, and the +instruction to run a policy against a document that should fail before calling it done. + +``` +curl -fsSL https://stackguardian.github.io/tirith/skill.sh | sh +``` + +Eleven markdown files into .claude/skills/tirith-policies/. Add --cursor for the Cursor rule, +--global to install into the home directory instead of the repository. The script installs no +package, changes no PATH and executes nothing after the download; its source is +documentation/static/skill.sh in the repository. + ## Start here - [Getting started](https://stackguardian.github.io/tirith/docs/getting-started-with-tirith/): what Tirith is and the shortest path to a first verdict. @@ -97,7 +112,7 @@ https://stackguardian.github.io/tirith/llms-full.txt - [Source](https://github.com/StackGuardian/tirith): the repository, Apache-2.0. - [Roadmap](https://stackguardian.github.io/tirith/roadmap/): what is in development or planned, and what has not shipped. -- [Agent skill pack](https://github.com/StackGuardian/tirith/tree/main/.claude/skills/tirith-policies): a self-contained skill for writing Tirith policies, copyable into any repository. +- [Agent skill pack](https://github.com/StackGuardian/tirith/tree/main/.claude/skills/tirith-policies): a self-contained skill for writing Tirith policies. Install it with the one-line script above, or copy the directory into any repository. - [Origins](https://stackguardian.github.io/tirith/origins/): where the name and the mark come from. ## Optional diff --git a/documentation/static/skill.sh b/documentation/static/skill.sh new file mode 100644 index 00000000..261819c4 --- /dev/null +++ b/documentation/static/skill.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env sh +# +# Install the Tirith policy skill for a coding agent. +# +# curl -fsSL https://stackguardian.github.io/tirith/skill.sh | sh +# +# Read this file before you run it. It is served over HTTPS from the documentation site and +# its source is documentation/static/skill.sh in StackGuardian/tirith, so the version you +# are about to pipe into a shell is the version you can read in the repository. +# +# What it does: downloads two skills into .claude/skills/ -- tirith-policies (eleven markdown +# files plus a worked example) and tirith-migrate (Sentinel-to-Tirith translation, with its +# classified corpus and five worked examples) -- and, with --cursor, one rule file into +# .cursor/rules/. It creates directories, writes those files, and nothing else. No package is installed, no PATH is changed, nothing is executed +# after download, and it never touches a file it did not create. +# +# Flags: +# --global install into ~/.claude/skills/ instead of ./.claude/skills/ +# --cursor also install the Cursor rule into .cursor/rules/ +# --ref REF install from a branch or tag instead of main +# +# POSIX sh on purpose: it runs under dash, ash and busybox, which is what a slim CI image +# gives you. + +set -eu + +REPO="StackGuardian/tirith" +REF="main" +PACK=".claude/skills/tirith-policies" +DEST="." +CURSOR=0 +MIGRATE_PACK=".claude/skills/tirith-migrate" + +REFERENCES="schema validate verdicts terraform-plan other-providers variables install pipelines platform debug-ci" +EXAMPLE="examples/required-tags" +EXAMPLE_FILES="README.md policy.json should-fail.json should-pass.json" + +# tirith-migrate, relative to its own pack root. One path per line so the list stays diffable. +MIGRATE_FILES="SKILL.md +reference/sentinel.md +reference/sentinel-corpus.md +examples/sentinel/README.md +examples/sentinel/restrict-instance-type/source.sentinel +examples/sentinel/restrict-instance-type/notes.md +examples/sentinel/restrict-instance-type/policy.json +examples/sentinel/restrict-instance-type/variables.json +examples/sentinel/restrict-instance-type/should-fail.json +examples/sentinel/restrict-instance-type/should-pass.json +examples/sentinel/mandatory-tags/source.sentinel +examples/sentinel/mandatory-tags/notes.md +examples/sentinel/mandatory-tags/policy.json +examples/sentinel/mandatory-tags/should-fail.json +examples/sentinel/mandatory-tags/should-pass.json +examples/sentinel/prevent-database-destroy/source.sentinel +examples/sentinel/prevent-database-destroy/notes.md +examples/sentinel/prevent-database-destroy/policy.json +examples/sentinel/prevent-database-destroy/should-fail.json +examples/sentinel/prevent-database-destroy/should-pass.json +examples/sentinel/prevent-database-destroy/should-fail-replacement.json +examples/sentinel/restrict-ssh-ingress/source.sentinel +examples/sentinel/restrict-ssh-ingress/notes.md +examples/sentinel/restrict-ssh-ingress/policy.json +examples/sentinel/restrict-ssh-ingress/should-fail.json +examples/sentinel/restrict-ssh-ingress/should-pass.json +examples/sentinel/restrict-ssh-ingress/diverges.json +examples/sentinel/require-private-registry-modules/source.sentinel +examples/sentinel/require-private-registry-modules/notes.md" + +while [ $# -gt 0 ]; do + case "$1" in + --global) DEST="$HOME" ;; + --cursor) CURSOR=1 ;; + --ref) REF="${2:?--ref needs a branch or tag}"; shift ;; + -h|--help) + sed -n '3,25p' "$0" 2>/dev/null | sed 's/^# \{0,1\}//' + exit 0 + ;; + *) printf 'skill.sh: unknown option %s\n' "$1" >&2; exit 2 ;; + esac + shift +done + +command -v curl >/dev/null 2>&1 || { echo "skill.sh: curl is required" >&2; exit 1; } + +BASE="https://raw.githubusercontent.com/$REPO/$REF/$PACK" +TARGET="$DEST/$PACK" + +# Download to a temporary directory first, then move into place. A half-written skill is +# worse than no skill: an agent will read whatever files exist and quietly work from a +# partial vocabulary. +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT INT TERM +mkdir -p "$TMP/reference" "$TMP/$EXAMPLE" + +fetch() { + curl -fsSL "$1" -o "$2" || { printf 'skill.sh: failed to download %s\n' "$1" >&2; exit 1; } +} + +fetch "$BASE/SKILL.md" "$TMP/SKILL.md" +for f in $REFERENCES; do + fetch "$BASE/reference/$f.md" "$TMP/reference/$f.md" +done +for f in $EXAMPLE_FILES; do + fetch "$BASE/$EXAMPLE/$f" "$TMP/$EXAMPLE/$f" +done +MBASE="https://raw.githubusercontent.com/$REPO/$REF/$MIGRATE_PACK" +for f in $MIGRATE_FILES; do + mkdir -p "$TMP/migrate/$(dirname "$f")" + fetch "$MBASE/$f" "$TMP/migrate/$f" +done + +mkdir -p "$TARGET/reference" "$TARGET/$EXAMPLE" +cp "$TMP/SKILL.md" "$TARGET/SKILL.md" +for f in $REFERENCES; do + cp "$TMP/reference/$f.md" "$TARGET/reference/$f.md" +done +for f in $EXAMPLE_FILES; do + cp "$TMP/$EXAMPLE/$f" "$TARGET/$EXAMPLE/$f" +done + +MTARGET="$DEST/$MIGRATE_PACK" +for f in $MIGRATE_FILES; do + mkdir -p "$MTARGET/$(dirname "$f")" + cp "$TMP/migrate/$f" "$MTARGET/$f" +done + +printf 'Installed the Tirith skill: %s\n' "$TARGET" +printf 'Installed the migration skill: %s\n' "$MTARGET" + +if [ "$CURSOR" -eq 1 ]; then + mkdir -p "$DEST/.cursor/rules" + fetch "https://raw.githubusercontent.com/$REPO/$REF/.cursor/rules/tirith-policies.mdc" \ + "$TMP/tirith-policies.mdc" + cp "$TMP/tirith-policies.mdc" "$DEST/.cursor/rules/tirith-policies.mdc" + printf 'Installed the Cursor rule: %s\n' "$DEST/.cursor/rules/tirith-policies.mdc" +fi + +printf 'Ask your agent to write a Tirith policy. It should name real condition types.\n'