From f9b63cd0848c61950e1a1f36a08ef024033c672c Mon Sep 17 00:00:00 2001 From: AKSHAT TANDON Date: Wed, 21 Oct 2026 19:29:49 +0100 Subject: [PATCH 1/3] feat: standalone skills layout, Tirith sync, marketplace and catalog - standalone/ holds skills that need no StackGuardian account; tirith-policies is synced from StackGuardian/tirith at the tag in standalone/TIRITH_VERSION (1.2.1) by scripts/sync-tirith-skills.mjs, and CI fails on hand edits. - The six sg-* skills tell the agent to run npx @stackguardian/mcp-wizard when the StackGuardian MCP server is missing. - .claude-plugin/marketplace.json: plugins terraform (standalone/) and stackguardian (skills/), skills only. - catalog/skills.json and skills.sh.json, with structure tests. Co-Authored-By: Claude Opus 5.5 --- .claude-plugin/marketplace.json | 22 ++ .github/workflows/ci.yml | 3 + .prettierignore | 2 + catalog/skills.json | 189 ++++++++++++ scripts/sync-tirith-skills.mjs | 110 +++++++ skills.sh.json | 22 ++ skills/sg-create-policy/SKILL.md | 11 +- skills/sg-create-stack/SKILL.md | 15 +- skills/sg-create-workflow/SKILL.md | 37 ++- skills/sg-manage-secrets/SKILL.md | 4 +- skills/sg-update-workflow/SKILL.md | 26 +- skills/sg-upgrade-workflow/SKILL.md | 4 + standalone/README.md | 5 + standalone/TIRITH_VERSION | 1 + standalone/tirith-policies/SKILL.md | 135 +++++++++ .../tirith-policies/reference/debug-ci.md | 72 +++++ .../tirith-policies/reference/install.md | 55 ++++ .../reference/other-providers.md | 78 +++++ .../tirith-policies/reference/pipelines.md | 212 ++++++++++++++ .../tirith-policies/reference/platform.md | 61 ++++ .../tirith-policies/reference/schema.md | 79 +++++ .../reference/terraform-plan.md | 88 ++++++ .../tirith-policies/reference/validate.md | 85 ++++++ .../tirith-policies/reference/variables.md | 60 ++++ .../tirith-policies/reference/verdicts.md | 62 ++++ tests/run.mjs | 1 + tests/skills-structure.test.ts | 276 ++++++++++++++++++ 27 files changed, 1678 insertions(+), 37 deletions(-) create mode 100644 .claude-plugin/marketplace.json create mode 100644 catalog/skills.json create mode 100644 scripts/sync-tirith-skills.mjs create mode 100644 skills.sh.json create mode 100644 standalone/README.md create mode 100644 standalone/TIRITH_VERSION create mode 100644 standalone/tirith-policies/SKILL.md create mode 100644 standalone/tirith-policies/reference/debug-ci.md create mode 100644 standalone/tirith-policies/reference/install.md create mode 100644 standalone/tirith-policies/reference/other-providers.md create mode 100644 standalone/tirith-policies/reference/pipelines.md create mode 100644 standalone/tirith-policies/reference/platform.md create mode 100644 standalone/tirith-policies/reference/schema.md create mode 100644 standalone/tirith-policies/reference/terraform-plan.md create mode 100644 standalone/tirith-policies/reference/validate.md create mode 100644 standalone/tirith-policies/reference/variables.md create mode 100644 standalone/tirith-policies/reference/verdicts.md create mode 100644 tests/skills-structure.test.ts diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..de77b54 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,22 @@ +{ + "name": "stackguardian", + "owner": { + "name": "StackGuardian", + "url": "https://www.stackguardian.io" + }, + "description": "Agent skills for Terraform and OpenTofu, and for StackGuardian.", + "plugins": [ + { + "name": "terraform", + "source": ".", + "description": "Terraform and OpenTofu skills that work in any repository and need no StackGuardian account.", + "skills": ["./standalone"] + }, + { + "name": "stackguardian", + "source": ".", + "description": "Create and manage StackGuardian workflows, stacks, policies and secrets. Needs the StackGuardian MCP server: run npx @stackguardian/mcp-wizard.", + "skills": ["./skills"] + } + ] +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c46a52a..32a91ec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,9 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile + - name: Check the Tirith skill copies + run: node scripts/sync-tirith-skills.mjs --check + - name: Type check run: bun run typecheck diff --git a/.prettierignore b/.prettierignore index e69de29..84beb89 100644 --- a/.prettierignore +++ b/.prettierignore @@ -0,0 +1,2 @@ +# Copies synced from StackGuardian/tirith (scripts/sync-tirith-skills.mjs); formatting them would break the sync check. +standalone/tirith-*/ diff --git a/catalog/skills.json b/catalog/skills.json new file mode 100644 index 0000000..3e8045f --- /dev/null +++ b/catalog/skills.json @@ -0,0 +1,189 @@ +{ + "version": 1, + "skills": [ + { + "slug": "tirith-policies", + "title": "Tirith Policies", + "tier": "standalone", + "requiresStackGuardian": false, + "category": "Policy", + "summary": "Write, validate and run Tirith policy-as-code checks on Terraform and OpenTofu plans, and add them to a CI pipeline.", + "description": "Tirith checks the plan your pipeline already produces against JSON policies and exits non-zero when a change breaks one. This skill helps your agent write those policies, run them against a document that should fail, read Tirith's results and exit codes, and add the check to GitHub Actions, GitLab CI, Bitbucket Pipelines, Jenkins, Azure DevOps or CircleCI.", + "examplePrompts": [ + "Write a Tirith policy that fails the plan if any S3 bucket is public.", + "Add a Tirith check to our GitHub Actions workflow before terraform apply.", + "Why did the Tirith step fail in CI?" + ], + "supportedAgents": [], + "prerequisites": [ + "Terraform or OpenTofu", + "Python 3.8 or later for Tirith" + ], + "install": { + "skillsCli": null, + "claudePlugin": null + }, + "githubPath": "standalone/tirith-policies", + "demoUrl": null, + "relatedSlugs": ["sg-create-policy"] + }, + { + "slug": "sg-create-workflow", + "title": "Create a StackGuardian Workflow", + "tier": "platform", + "requiresStackGuardian": true, + "category": "Platform", + "summary": "Create a StackGuardian workflow for Terraform, OpenTofu or a custom step pipeline, from your git repository or a library template.", + "description": "Your agent asks whether the code comes from your own git repository or a StackGuardian library template, then walks you through the workflow group, cloud connector, variables and template inputs. You see every input with the value that will be used before anything is created. Creating a workflow does not deploy anything; a deployment happens when a run starts.", + "examplePrompts": [ + "Create a workflow for the Terraform in github.com/acme/infra, folder envs/staging, branch main.", + "Create an OpenTofu workflow from our repo that needs approval before every apply.", + "Set up a workflow that runs our Ansible playbook as a custom step." + ], + "supportedAgents": [], + "prerequisites": [ + "A StackGuardian account", + "The StackGuardian MCP server: npx @stackguardian/mcp-wizard" + ], + "install": { + "skillsCli": null, + "claudePlugin": null + }, + "githubPath": "skills/sg-create-workflow", + "demoUrl": null, + "relatedSlugs": [ + "sg-update-workflow", + "sg-upgrade-workflow", + "sg-create-stack" + ] + }, + { + "slug": "sg-update-workflow", + "title": "Update a StackGuardian Workflow", + "tier": "platform", + "requiresStackGuardian": true, + "category": "Platform", + "summary": "Change an existing StackGuardian workflow's connector, variables, schedules, approvals, runner or sizing. Only what you change is touched.", + "description": "Your agent finds the workflow, confirms it is the one you mean, and changes only the settings you ask for. For settings saved as a whole list, such as tags or schedules, it reads the current list first so adding one entry keeps the others. You confirm a summary before anything changes.", + "examplePrompts": [ + "Add a nightly 2 AM schedule to prod-vpc.", + "Require approval from alice@example.com or bob@example.com before prod-vpc applies.", + "prod-vpc runs keep running out of memory. Give them 4 GB." + ], + "supportedAgents": [], + "prerequisites": [ + "A StackGuardian account", + "The StackGuardian MCP server: npx @stackguardian/mcp-wizard" + ], + "install": { + "skillsCli": null, + "claudePlugin": null + }, + "githubPath": "skills/sg-update-workflow", + "demoUrl": null, + "relatedSlugs": ["sg-create-workflow", "sg-upgrade-workflow"] + }, + { + "slug": "sg-upgrade-workflow", + "title": "Upgrade a StackGuardian Workflow", + "tier": "platform", + "requiresStackGuardian": true, + "category": "Platform", + "summary": "Move a StackGuardian workflow to another template revision, with a dry run that shows every change first.", + "description": "Your agent finds the workflow and the revision to move to, and asks whether to keep your settings or reset to the template. A dry run shows how many settings would be added, changed or removed before anything is applied. You review template inputs, environment variables, the cloud connector and custom step settings, then confirm.", + "examplePrompts": [ + "Upgrade prod-vpc to the latest revision of its template.", + "What would change if I moved prod-vpc to revision 5 of its template?", + "Roll prod-vpc back to the previous template revision." + ], + "supportedAgents": [], + "prerequisites": [ + "A StackGuardian account", + "The StackGuardian MCP server: npx @stackguardian/mcp-wizard" + ], + "install": { + "skillsCli": null, + "claudePlugin": null + }, + "githubPath": "skills/sg-upgrade-workflow", + "demoUrl": null, + "relatedSlugs": ["sg-update-workflow", "sg-create-workflow"] + }, + { + "slug": "sg-create-stack", + "title": "Create a StackGuardian Stack", + "tier": "platform", + "requiresStackGuardian": true, + "category": "Platform", + "summary": "Create several related StackGuardian workflows together from a stack template, reviewing each workflow's inputs.", + "description": "Your agent helps you pick a stack template, then walks through each workflow's inputs one workflow at a time. Inputs that come from another workflow's output are filled in for you, and the agent says where each one comes from. Creating a stack does not deploy anything until you start it.", + "examplePrompts": [ + "Find a stack template that sets up a VPC, a security group and an EC2 instance.", + "Create a stack from that template in the staging group with our AWS staging connector." + ], + "supportedAgents": [], + "prerequisites": [ + "A StackGuardian account", + "The StackGuardian MCP server: npx @stackguardian/mcp-wizard" + ], + "install": { + "skillsCli": null, + "claudePlugin": null + }, + "githubPath": "skills/sg-create-stack", + "demoUrl": null, + "relatedSlugs": ["sg-create-workflow", "sg-create-policy"] + }, + { + "slug": "sg-create-policy", + "title": "Create a StackGuardian Policy", + "tier": "platform", + "requiresStackGuardian": true, + "category": "Platform", + "summary": "Add a StackGuardian guardrail that blocks, warns on or requires approval for infrastructure changes.", + "description": "Your agent offers a library policy template when one fits, a Tirith rule it writes and explains back to you, or your own git repository of Rego or Tirith policies. You choose what happens when a rule fails, where the policy applies and who can approve. An organization-wide policy is confirmed explicitly.", + "examplePrompts": [ + "Block any deployment in the prod group that makes an S3 bucket public.", + "Warn when the estimated monthly cost of an EC2 instance goes over $100." + ], + "supportedAgents": [], + "prerequisites": [ + "A StackGuardian account", + "The StackGuardian MCP server: npx @stackguardian/mcp-wizard" + ], + "install": { + "skillsCli": null, + "claudePlugin": null + }, + "githubPath": "skills/sg-create-policy", + "demoUrl": null, + "relatedSlugs": ["tirith-policies", "sg-create-workflow"] + }, + { + "slug": "sg-manage-secrets", + "title": "Manage StackGuardian Secrets", + "tier": "platform", + "requiresStackGuardian": true, + "category": "Platform", + "summary": "Store, rotate or delete StackGuardian vault secrets and use them in workflows.", + "description": "Your agent stores a token, password or key in the StackGuardian vault and shows you how workflows refer to it by name. Before deleting a secret, it lists the workflows that mention it and deletes only after you agree. A secret's value can't be read back, only replaced.", + "examplePrompts": [ + "Store a GitHub token as a secret called GITHUB_TOKEN.", + "Rotate the DB_PASSWORD secret.", + "Delete the OLD_API_KEY secret. Which workflows still use it?" + ], + "supportedAgents": [], + "prerequisites": [ + "A StackGuardian account", + "The StackGuardian MCP server: npx @stackguardian/mcp-wizard" + ], + "install": { + "skillsCli": null, + "claudePlugin": null + }, + "githubPath": "skills/sg-manage-secrets", + "demoUrl": null, + "relatedSlugs": ["sg-create-workflow", "sg-update-workflow"] + } + ] +} diff --git a/scripts/sync-tirith-skills.mjs b/scripts/sync-tirith-skills.mjs new file mode 100644 index 0000000..7376f5e --- /dev/null +++ b/scripts/sync-tirith-skills.mjs @@ -0,0 +1,110 @@ +#!/usr/bin/env node +// Copies the Tirith skills from StackGuardian/tirith, pinned to the tag in +// standalone/TIRITH_VERSION, into standalone/. The copies are never edited by +// hand: change them upstream in Tirith, then bump the tag and run this again. +// +// Usage: +// node scripts/sync-tirith-skills.mjs overwrite standalone/ +// node scripts/sync-tirith-skills.mjs --check fail if the committed copy differs +import { spawnSync } from "node:child_process"; +import { + cpSync, + existsSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, relative } from "node:path"; +import { fileURLToPath } from "node:url"; + +const TIRITH_REPO = "https://github.com/StackGuardian/tirith.git"; +// tirith-standards and tirith-migrate join this list once a Tirith release +// contains them; 1.2.1 ships only tirith-policies. +const TIRITH_SKILLS = ["tirith-policies"]; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); +const standaloneDir = join(repoRoot, "standalone"); +const tag = readFileSync(join(standaloneDir, "TIRITH_VERSION"), "utf-8").trim(); +const check = process.argv.includes("--check"); + +function listFiles(dir) { + return readdirSync(dir, { recursive: true }) + .map(String) + .filter((path) => statSync(join(dir, path)).isFile()) + .sort(); +} + +/** Returns a description of every difference between two directory trees. */ +function diffTrees(expected, actual) { + if (!existsSync(actual)) return [`${relative(repoRoot, actual)} is missing`]; + const want = listFiles(expected); + const have = listFiles(actual); + const problems = []; + for (const path of want) { + if (!have.includes(path)) problems.push(`missing ${path}`); + else if ( + !readFileSync(join(expected, path)).equals( + readFileSync(join(actual, path)), + ) + ) + problems.push(`changed ${path}`); + } + for (const path of have) { + if (!want.includes(path)) problems.push(`unexpected ${path}`); + } + return problems; +} + +const work = mkdtempSync(join(tmpdir(), "tirith-skills-")); +try { + const clone = spawnSync( + "git", + [ + "-c", + "advice.detachedHead=false", + "clone", + "--quiet", + "--depth", + "1", + "--branch", + tag, + TIRITH_REPO, + work, + ], + { stdio: "inherit" }, + ); + if (clone.status !== 0) { + throw new Error(`git clone of ${TIRITH_REPO} at ${tag} failed`); + } + + const problems = []; + for (const skill of TIRITH_SKILLS) { + const source = join(work, ".claude", "skills", skill); + if (!existsSync(join(source, "SKILL.md"))) { + throw new Error(`Tirith ${tag} has no .claude/skills/${skill}/SKILL.md`); + } + const target = join(standaloneDir, skill); + if (check) { + problems.push(...diffTrees(source, target).map((p) => `${skill}: ${p}`)); + } else { + rmSync(target, { recursive: true, force: true }); + cpSync(source, target, { recursive: true }); + console.log(`synced ${skill} from Tirith ${tag}`); + } + } + + if (problems.length > 0) { + console.error( + `standalone/ does not match Tirith ${tag}. Do not edit these files by hand; run node scripts/sync-tirith-skills.mjs.`, + ); + for (const problem of problems) console.error(` ${problem}`); + process.exitCode = 1; + } else if (check) { + console.log(`standalone/ matches Tirith ${tag}`); + } +} finally { + rmSync(work, { recursive: true, force: true }); +} diff --git a/skills.sh.json b/skills.sh.json new file mode 100644 index 0000000..7ebac78 --- /dev/null +++ b/skills.sh.json @@ -0,0 +1,22 @@ +{ + "notGrouped": "bottom", + "groupings": [ + { + "title": "Works with any repo", + "description": "Open source. No account needed.", + "skills": ["tirith-policies"] + }, + { + "title": "With StackGuardian", + "description": "Needs the StackGuardian MCP server: npx @stackguardian/mcp-wizard", + "skills": [ + "sg-create-workflow", + "sg-update-workflow", + "sg-upgrade-workflow", + "sg-create-stack", + "sg-create-policy", + "sg-manage-secrets" + ] + } + ] +} diff --git a/skills/sg-create-policy/SKILL.md b/skills/sg-create-policy/SKILL.md index 154f9e9..656472d 100644 --- a/skills/sg-create-policy/SKILL.md +++ b/skills/sg-create-policy/SKILL.md @@ -5,6 +5,8 @@ description: Guided flow for creating (and adjusting) a StackGuardian policy wit # Create Policy — Guided Flow +> **Before you start:** this skill needs the StackGuardian MCP server. If no StackGuardian tools are available (for example `search_workflows`), stop. Tell the user to run `npx @stackguardian/mcp-wizard` and restart the agent. Do not call StackGuardian APIs another way. + Create a StackGuardian policy with the `create_policy` tool. Follow the steps IN ORDER. Never ask the user to type ids from memory — fetch options with the listed tools and present choices. In user-facing @@ -47,14 +49,16 @@ Rule `name` is a slug (letters, digits, `_`, `-`). ## Step B — What happens when a rule trips? Ask per rule and set `onFail`: + - "block the deployment" → `FAIL` - "just warn" → `WARN` - "pause for someone to approve" → `APPROVAL_REQUIRED` → go to Step D -Leave `onPass` as `PASS` unless the user explicitly wants otherwise. + Leave `onPass` as `PASS` unless the user explicitly wants otherwise. ## Step C — Where does it apply? Offer exactly these options and set `enforcedOn`: + - **Whole organization** → `["*"]`. Confirm explicitly: it applies to every workflow and stack. - **Workflow group(s)** → resolve with @@ -92,10 +96,11 @@ and re-call — never guess. ## Adjusting a policy later Use `update_policy(policyId, ...)`: + - change scope: `addEnforcedOn` / `removeEnforcedOn` (or `enforcedOn` to replace the whole scope); - change rules: `addRules` / `replaceRules` (by rule name) / `removeRules`; - change approvers: `approvers` + `numberOfApprovalsRequired` together; - rename / describe / tag: `resourceName`, `description`, `tags`. -The response's `changed` map shows exactly what moved. Find the policy -first with `search_policies`. + The response's `changed` map shows exactly what moved. Find the policy + first with `search_policies`. diff --git a/skills/sg-create-stack/SKILL.md b/skills/sg-create-stack/SKILL.md index 2f76198..07e80b5 100644 --- a/skills/sg-create-stack/SKILL.md +++ b/skills/sg-create-stack/SKILL.md @@ -5,6 +5,8 @@ description: Guided flow for creating a StackGuardian STACK (a bundle of workflo # Create Stack — Guided Flow +> **Before you start:** this skill needs the StackGuardian MCP server. If no StackGuardian tools are available (for example `search_workflows`), stop. Tell the user to run `npx @stackguardian/mcp-wizard` and restart the agent. Do not call StackGuardian APIs another way. + Create a StackGuardian stack with the `create_stack` tool on the StackGuardian MCP server. A stack is a set of workflows created together from a **stack template** (library templateType `IAC_GROUP`), plus the template's @@ -35,7 +37,7 @@ step (Step F). production; never silently pick one. (The tool repeats this as a `warnings` entry.) 4. Immediately call `get_marketplace_template_by_id(templateType='IAC_GROUP', - templateId=)` and read `WorkflowsConfig.workflows` (a JSON +templateId=)` and read `WorkflowsConfig.workflows` (a JSON string in search results — parse it). Each entry has an `id` (a UUID — this is how you address the workflow later) and `VCSConfig.iacVCSConfig.iacTemplateId`, the workflow template it runs. @@ -54,7 +56,7 @@ step (Step F). "recommend" arbitrary groups: - **Name or keyword**: resolve via `search_orchestrator_resources(resourceTypes=['WORKFLOW_GROUP'], - searchQuery=)`; on a near-miss suggest the closest match. The +searchQuery=)`; on a near-miss suggest the closest match. The confirmed pick becomes `wfGrp` — pass EVERYTHING AFTER `/wfgrps/` in its `ResourceId` (the id can differ from the display name; the tool accepts either and translates a name, but carry the id forward — every later @@ -66,8 +68,8 @@ step (Step F). refuses an unknown group unless this flag is set. - **Browse in the dev portal**: share the workflow-groups page — `https://app.stackguardian.io/orchestrator/orgs/?tab=workflowGroups`. - This step is mandatory — never call `create_stack` without a - user-confirmed group. + This step is mandatory — never call `create_stack` without a + user-confirmed group. 2. Ask the user for: - stack name (`resourceName`, 1–100 chars; its id is the slugified name) - optional description (max 512 chars) @@ -105,7 +107,7 @@ This is the step that differs from a single workflow: **walk the workflows one at a time**, in the template's order, and for EACH: 1. Call `get_marketplace_template_by_id(templateType='IAC', templateId=)` and decode `InputSchemas` (a LIST; the +workflow's iacTemplateId>)` and decode `InputSchemas` (a LIST; the `FORM_JSONSCHEMA` entry's `encodedData` is base64 JSON Schema; walk `properties` — each `default` is what ships if the user says nothing). 2. Overlay the stack template's stored `iacInputData.data` for that workflow @@ -153,7 +155,7 @@ workflow the inputs being overridden and the ones accepted as-is. Then call The response is `{success, msg, stackId, data: {stack, workflows}, link?, warnings?, note}`. Tell the user the stack was created and where (`stackId`, and `link` if present) and that **nothing has run yet**. To -deploy: run the stack's *apply* action from the dashboard link, or run each +deploy: run the stack's _apply_ action from the dashboard link, or run each workflow with `create_workflow_run` in the order the stack's Actions define (each `data.workflows[].SubResourceId` is the id those tools take; ask before running — `action` is required there and always a user decision). @@ -161,6 +163,7 @@ before running — `action` is required there and always a user decision). ## Error handling (teaching errors) On error, present the offered options and re-call — do not guess: + - `availableWorkflowGroups` / `availableWorkflowGroupNames` / `devPortalWorkflowGroupsUrl` → unknown group (see Step B) - `candidateWorkflowGroups` → the name is ambiguous; re-call with the id diff --git a/skills/sg-create-workflow/SKILL.md b/skills/sg-create-workflow/SKILL.md index 49a69d3..85a3956 100644 --- a/skills/sg-create-workflow/SKILL.md +++ b/skills/sg-create-workflow/SKILL.md @@ -5,6 +5,8 @@ description: Guided flow for creating a StackGuardian workflow via the StackGuar # Create Workflow — Guided Flow +> **Before you start:** this skill needs the StackGuardian MCP server. If no StackGuardian tools are available (for example `search_workflows`), stop. Tell the user to run `npx @stackguardian/mcp-wizard` and restart the agent. Do not call StackGuardian APIs another way. + Create a StackGuardian IaC workflow with the `create_workflow` tool on the StackGuardian MCP server. Follow the steps IN ORDER. Never ask the user to type IDs or names from memory — always fetch options with the listed tools @@ -26,10 +28,11 @@ the **StackGuardian template library** or from **their own git repository**? Ask — don't infer it from a stray URL or template name. Build exactly one of these two shapes for `source` — never both, and never mix fields from one into the other: + - `{"type": "template", "templateId": ..., "iacInputs": {...}}` - `{"type": "git", "repo": ..., "ref": ..., "workingDir": ..., - "isPrivateRepo": ..., "gitAuthRef": ..., "includeSubModule": ..., - "iacInputs": {...}}` +"isPrivateRepo": ..., "gitAuthRef": ..., "includeSubModule": ..., +"iacInputs": {...}}` ### Template mode @@ -84,7 +87,7 @@ into the other: or "recommend" arbitrary groups: - **Name or keyword**: resolve it via `search_orchestrator_resources(resourceTypes=['WORKFLOW_GROUP'], - searchQuery=)`; on a near-miss suggest the closest match +searchQuery=)`; on a near-miss suggest the closest match ("no `production` — did you mean `prod`?"). The confirmed pick becomes `wfGrp` — pass EVERYTHING AFTER `/wfgrps/` IN ITS `ResourceId` (e.g. `mcp-test-qypm75mb` from `/wfgrps/mcp-test-qypm75mb`, and the whole @@ -99,12 +102,12 @@ into the other: user explicitly asks for a brand-new group. - **Browse in the dev portal**: share the workflow-groups page — `https://app.stackguardian.io/orchestrator/orgs/?tab=workflowGroups`. - This step is mandatory — never call `create_workflow` without a - user-confirmed `wfGrp`. - **Nested groups are accepted** — `PROD/eu-central-1` is a real group that - holds workflows. Pass the whole path exactly as it appears; the last - segment alone is a different group. A full resource id is not a group - path — drop any `/wfs/...` or `/stacks/...` part. + This step is mandatory — never call `create_workflow` without a + user-confirmed `wfGrp`. + **Nested groups are accepted** — `PROD/eu-central-1` is a real group that + holds workflows. Pass the whole path exactly as it appears; the last + segment alone is a different group. A full resource id is not a group + path — drop any `/wfs/...` or `/stacks/...` part. 2. Ask the user for: - workflow name (`resourceName`, 1–100 chars) - optional description (max 512 chars) @@ -155,9 +158,9 @@ their own container/script rather than Terraform. the workflow's own. Each step is: `{"name": "", "wfStepTemplateId": "/owner/name:rev", - "wfStepInputData": {"schemaType": "FORM_JSONSCHEMA", "data": {...}}, - "approval": false, "timeout": 5400, "environmentVariables": [...], - "mountPoints": [...]}` +"wfStepInputData": {"schemaType": "FORM_JSONSCHEMA", "data": {...}}, +"approval": false, "timeout": 5400, "environmentVariables": [...], +"mountPoints": [...]}` Only `name` and `wfStepTemplateId` are required. 4. Rules worth knowing before you call: - `approval: true` pauses the run for manual approval BEFORE that step — @@ -221,13 +224,14 @@ Defaults are agent-safe; only surface these if the user asks or they matter. Each bullet below is a top-level `create_workflow` parameter — build the nested object only if you have something to put in it, and omit the parameter entirely otherwise (its defaults still apply omitted): + - `wfType`: TERRAFORM (default), OPENTOFU, or CUSTOM (see Step C2 below). Note ANSIBLE / HELM / KUBERNETES / CLOUDFORMATION are NOT workflow types — they are search filters for CUSTOM workflows whose step template matches that keyword. To build one, use `wfType: CUSTOM` with the matching step template. - `terraformConfig`: `{"approvalPreApply": true, "managedTerraformState": true, - "driftCheck": false, "terraformVersion": ..., "driftCron": ...}` — these +"driftCheck": false, "terraformVersion": ..., "driftCron": ...}` — these three booleans already match this tool's agent-safe defaults, so omit the whole object unless something needs to differ. `driftCron` (AWS cron) is stored independently of `driftCheck` — a schedule set while `driftCheck` is @@ -261,8 +265,8 @@ parameter entirely otherwise (its defaults still apply omitted): ...) — chain other workflows or stacks, send email notifications, fire webhooks: `{"wfChaining": {"COMPLETED": [{"workflowGroupId": ..., "workflowId": ...}]}, - "notifications": {"email": {"COMPLETED": [{"recipients": [...]}]}}, - "webhooks": {"COMPLETED": [{"webhookName": ..., "webhookUrl": ...}]}}`. +"notifications": {"email": {"COMPLETED": [{"recipients": [...]}]}}, +"webhooks": {"COMPLETED": [{"webhookName": ..., "webhookUrl": ...}]}}`. All three groups and every event key are optional. A `stackId` can stand in for `workflowId` when chaining to a stack. `DRIFT_DETECTED` only ever fires when `terraformConfig.driftCheck` is enabled — don't offer it otherwise. @@ -275,7 +279,7 @@ parameter entirely otherwise (its defaults still apply omitted): chaining) — omit it and scheduled runs use the workflow's own settings. - `cacheConfig`: persist runner directories between runs so repeats warm-start — `{"enabled": true, "paths": [".terraform"], "key": - "tf-plugins-v1", "policy": "PULL_PUSH"}`. If you pass it at all, all four of +"tf-plugins-v1", "policy": "PULL_PUSH"}`. If you pass it at all, all four of `enabled`/`paths`/`key`/`policy` are REQUIRED. `key` is slug format (letters, digits, `_`, `-`); runs sharing a key share the cache, so change it to force a fresh one. `policy` is PULL_PUSH (restore + save, typical), @@ -304,6 +308,7 @@ call `create_workflow` with all parameters. The tool validates live and returns actionable errors instead of opaque 400s. On error, present the offered options and re-call — do not guess: + - `availableWorkflowGroups` → unknown group; a list of group IDS (capped at 50) — re-call with one verbatim; `availableWorkflowGroupNames` maps any id whose display name differs, show those to the user; plus diff --git a/skills/sg-manage-secrets/SKILL.md b/skills/sg-manage-secrets/SKILL.md index 3ac9679..c5d386b 100644 --- a/skills/sg-manage-secrets/SKILL.md +++ b/skills/sg-manage-secrets/SKILL.md @@ -5,6 +5,8 @@ description: Guided flow for creating, rotating and deleting StackGuardian vault # Manage Secrets — Guided Flow +> **Before you start:** this skill needs the StackGuardian MCP server. If no StackGuardian tools are available (for example `search_workflows`), stop. Tell the user to run `npx @stackguardian/mcp-wizard` and restart the agent. Do not call StackGuardian APIs another way. + A StackGuardian secret is an org-wide, KMS-encrypted vault entry with a name and a value. It has no description, tags or type, and its value can never be read back. Workflows use it by reference. Follow the steps IN @@ -47,7 +49,7 @@ to the user; speak of "the secret's name" and "the value". ## Step B — Rotate 1. Find the exact name: `search_orchestrator_resources(resourceTypes=['SECRET'], - searchQuery=)`, or use the `availableSecrets` list a tool error +searchQuery=)`, or use the `availableSecrets` list a tool error returns. Names are case-sensitive. 2. Ask for the new value and call `update_secret(resourceName=, secret={"value": })`. diff --git a/skills/sg-update-workflow/SKILL.md b/skills/sg-update-workflow/SKILL.md index b9f0f5b..09a7b6e 100644 --- a/skills/sg-update-workflow/SKILL.md +++ b/skills/sg-update-workflow/SKILL.md @@ -2,8 +2,11 @@ name: sg-update-workflow description: Guided flow for changing an existing StackGuardian workflow's settings via the StackGuardian MCP update_workflow tool — locate the workflow, then change any of description/tags, cloud connector, environment variables, Terraform options, custom step pipelines (CUSTOM workflows), approvals, runner placement, cron schedules, post-run notifications/chaining/webhooks, runner cache, job sizing, activation, or template inputs. Only the fields you change are touched. Use when the user wants to edit/modify/reconfigure an existing workflow's settings (NOT to upgrade its template revision — that's the sg-upgrade-workflow skill). --- + # Update Workflow — Guided Flow +> **Before you start:** this skill needs the StackGuardian MCP server. If no StackGuardian tools are available (for example `search_workflows`), stop. Tell the user to run `npx @stackguardian/mcp-wizard` and restart the agent. Do not call StackGuardian APIs another way. + Change an existing workflow's settings with the StackGuardian MCP `update_workflow` tool. This is for editing a workflow that already exists — to CREATE one use the `sg-create-workflow` skill; to UPGRADE its template @@ -50,12 +53,12 @@ and stop, don't rebuild the payload by hand. otherwise) A path like `/wfgrps/dev/wfs/api` gives `wfGrp="dev"`, `resourceName="api"`. A path like `/wfgrps/dev/stacks/net/wfs/api` adds `stack="net"`. - **Nested workflow groups** are real and `update_workflow` accepts them: in - `/wfgrps/platform/eu/wfs/api` the group is the whole `"platform/eu"`, NOT - `"platform"`. Taking only the first segment silently targets a different - group — usually a 404, or the wrong workflow if a same-named one exists - there. Keep the inner slashes exactly as they appear; don't URL-encode them - and don't collapse them. (Only `..` and backslashes are rejected.) + **Nested workflow groups** are real and `update_workflow` accepts them: in + `/wfgrps/platform/eu/wfs/api` the group is the whole `"platform/eu"`, NOT + `"platform"`. Taking only the first segment silently targets a different + group — usually a 404, or the wrong workflow if a same-named one exists + there. Keep the inner slashes exactly as they appear; don't URL-encode them + and don't collapse them. (Only `..` and backslashes are rejected.) ## Step B — Decide what to change @@ -75,9 +78,9 @@ Ask the user what they want to change, then build ONLY those parameters: deletes the other two and reports success. Whenever the user wants to ADD a tag, read the current list first (it's on the `search_workflows` result you already have from Step A) and send the full intended set. - (The `contextTags` merge is the tool's doing — it reads the current workflow - and merges before sending, because the platform overwrites the whole map - either way.) + (The `contextTags` merge is the tool's doing — it reads the current workflow + and merges before sending, because the platform overwrites the whole map + either way.) - **Cloud connector** (`deployment`) — `{"connectorId": "/integrations/"}`. Find connectors via `search_orchestrator_resources(resourceTypes=['INTEGRATION.CLOUD'])`. The @@ -91,7 +94,7 @@ Ask the user what they want to change, then build ONLY those parameters: verified live; an unknown one returns `availableSecrets`. - **Terraform/OpenTofu options** (`terraformConfig`) — pass only the keys to change, e.g. `{"terraformVersion": "1.6.2"}` or `{"driftCheck": true, - "driftCron": "0 */6 * * ? *"}`. Keys you omit are kept, and a key set to +"driftCron": "0 */6 * * ? *"}`. Keys you omit are kept, and a key set to `null` is removed. That preservation is the tool's, not the platform's: it reads the workflow and merges your partial into the stored options before sending. So if the read fails the update is refused outright rather than @@ -128,7 +131,7 @@ Ask the user what they want to change, then build ONLY those parameters: `wfStepInputData.data` are NOT verified by the platform). Cannot be combined with a template upgrade: upgrade first, then adjust the steps. - **Approvals** (`approvals`) — `{"approvers": [...], - "numberOfApprovalsRequired": N}`. REPLACES the stored approval settings, so +"numberOfApprovalsRequired": N}`. REPLACES the stored approval settings, so send the full intended state. Two settings make a gate: whether a run stops is `terraformConfig.approvalPreApply` (TERRAFORM/OPENTOFU) or a step's own `approval: true` (CUSTOM); `approvals` only says who may release it, and a @@ -206,6 +209,7 @@ else stays the same). On confirmation, call `update_workflow` with `wfGrp`, ## Error handling (teaching errors) Present the offered options and re-call — don't guess: + - `availableConnectors` -> unknown `deployment` connector; present list, re-call. - `availableSecrets` -> a referenced `/secrets/` doesn't exist; present list or create the secret first. diff --git a/skills/sg-upgrade-workflow/SKILL.md b/skills/sg-upgrade-workflow/SKILL.md index 3ec41c3..955e747 100644 --- a/skills/sg-upgrade-workflow/SKILL.md +++ b/skills/sg-upgrade-workflow/SKILL.md @@ -5,6 +5,8 @@ description: Guided flow for upgrading (or downgrading) an existing StackGuardia # Upgrade Workflow — Guided Flow +> **Before you start:** this skill needs the StackGuardian MCP server. If no StackGuardian tools are available (for example `search_workflows`), stop. Tell the user to run `npx @stackguardian/mcp-wizard` and restart the agent. Do not call StackGuardian APIs another way. + Move an existing workflow to a different template revision with the StackGuardian MCP `update_workflow` tool (using its `upgradeMode` + `dryRun`). This is a SEPARATE flow from a normal settings edit: an upgrade @@ -40,6 +42,7 @@ gives `wfGrp="platform/eu"`, not `"platform"`. ## Step C — Choose the upgrade mode Explain the two modes plainly and let the user choose: + - **Keep my settings** (`upgradeMode="PRESERVE_SETTINGS"`) — upgrade the code but preserve the customizations the user made on this workflow (e.g. extra approvers, a pinned Terraform version). New env-var keys from the template are @@ -55,6 +58,7 @@ Call `update_workflow` with `dryRun=true`: "templateId":""}, upgradeMode=, dryRun=true)`. This mutates nothing. Present the returned `preview` to the user: + - `summary` — how many settings are added / modified / removed. - `changes` — the specific before/after per key. - `reviewDimensions` — the four things worth reviewing: template **iacInputs**, diff --git a/standalone/README.md b/standalone/README.md new file mode 100644 index 0000000..0bf5501 --- /dev/null +++ b/standalone/README.md @@ -0,0 +1,5 @@ +# Standalone skills + +Agent skills for Terraform and OpenTofu that work in any repository and need no StackGuardian account. Install them with the [skills CLI](https://github.com/vercel-labs/skills) or the Claude Code plugin; the [README](../README.md#skills-that-work-with-any-repo) has the commands. + +The `tirith-*` folders are copies of the skills in [StackGuardian/tirith](https://github.com/StackGuardian/tirith), pinned to the tag in `TIRITH_VERSION`. Do not edit them here: change them in Tirith, bump the tag and run `node scripts/sync-tirith-skills.mjs`. diff --git a/standalone/TIRITH_VERSION b/standalone/TIRITH_VERSION new file mode 100644 index 0000000..6085e94 --- /dev/null +++ b/standalone/TIRITH_VERSION @@ -0,0 +1 @@ +1.2.1 diff --git a/standalone/tirith-policies/SKILL.md b/standalone/tirith-policies/SKILL.md new file mode 100644 index 0000000..e0de797 --- /dev/null +++ b/standalone/tirith-policies/SKILL.md @@ -0,0 +1,135 @@ +--- +name: tirith-policies +description: Write, validate, run and debug Tirith IaC governance policies, install Tirith, and add it to a CI pipeline (GitHub Actions, GitLab CI, Bitbucket Pipelines, Jenkins, Azure DevOps, CircleCI or any container runner). Use when writing or editing files under .tirith/policies, when a Tirith check fails in CI, when asked to add a guardrail to a Terraform or OpenTofu pipeline, or when reading a Tirith result document or exit code. +--- + +# Tirith + +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. Tirith does the traversal and returns resource-level evidence. + +## 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: + +```bash +pip install "git+https://github.com/StackGuardian/tirith.git@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. + +```bash +tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error +``` + +## Exit codes are a contract + +| Exit | Meaning | What CI should do | +| --- | --- | --- | +| `0` | Every check passed | Continue to `apply` | +| `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`. + +## Write a policy + +Work in this order. Guessing any of the four 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`. + +```json +{ + "meta": { + "version": "v1", + "required_provider": "stackguardian/terraform_plan", + "name": "Every resource carries a costcenter tag" + }, + "evaluators": [{ + "id": "costcenter_tag_present", + "description": "Every taggable resource declares a costcenter tag", + "provider_args": { + "operation_type": "attribute", + "terraform_resource_type": "*", + "terraform_resource_attribute": "tags.costcenter" + }, + "condition": {"type": "IsNotEmpty"} + }], + "eval_expression": "costcenter_tag_present" +} +``` + +`eval_expression` 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. + +```json +{"condition": {"type": "IsNotEmpty", "error_tolerance": 2}} +``` + +**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`? + +## 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/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 | +| `reference/variables.md` | One policy across environments with `-var` | +| `reference/install.md` | Installing Tirith, and why the install is a git URL | +| `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 | + +Worked policy/input pairs live in `src/tirith/tui/examples/` in the Tirith repository. diff --git a/standalone/tirith-policies/reference/debug-ci.md b/standalone/tirith-policies/reference/debug-ci.md new file mode 100644 index 0000000..892bf9c --- /dev/null +++ b/standalone/tirith-policies/reference/debug-ci.md @@ -0,0 +1,72 @@ +# Debug a red CI check + +Start from a failed build and end at the rule and the resource. Work in this order — it is +ordered by how often each step is the answer. + +## 1. Which exit code? + +```bash +echo $? +``` + +| Exit | What it means | Where to look | +| --- | --- | --- | +| `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 | + +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. + +## 2. Which check failed, and on which resource? + +```bash +tirith --json -policy-path .tirith/policies -input-path plan.json > result.json +``` + +In `result.json`, find the evaluator with `"passed": false`. Each entry in its `result[]` array +carries a `meta` with the resource: `address`, `type`, and the `change` with `actions`, `before` +and `after`. Name the resource from `meta.address` rather than quoting the message — on a wildcard +policy every message reads identically. + +`tirith ui --result result.json` opens the same document in an explorer, if the extra is installed. + +## 3. Is the finding correct? + +Read `change.after` for the address and compare it against `condition.value`. Three outcomes: + +- The value really does violate the rule → fix the Terraform. +- The value is fine but the rule tests the wrong thing → fix the policy. +- The value is **absent** → the failure will arrive *without* a resource address, because there is + no value to attach one to. Search the plan for the resource lacking that attribute. + +## 4. Exit `1` — no verdict + +Check `final_result` in the result document. + +- **`final_result: null`** — every check was skipped, so nothing was evaluated. Almost always + `provider_args` matching nothing. Verify the resource type is in the plan, then the attribute + 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. + +## 5. Exit `0` when you expected a failure + +- Is `--fail-on-error` present? Without it the exit code is always `0` and the verdict is only in + the output. +- Did the policy match anything? A cost policy over a misspelled `resource_type` sums to `0` and + passes. A wildcard attribute policy with `error_tolerance: 2` skips every resource lacking the + attribute. +- Prove the gate works by running it against a document that **should** fail it. A guardrail only + ever seen passing is a guardrail nobody has tested. + +## The two commands + +```bash +tirith --json -policy-path .tirith/policies \ + -input-path plan.json | head -40 # what did it actually decide? +``` diff --git a/standalone/tirith-policies/reference/install.md b/standalone/tirith-policies/reference/install.md new file mode 100644 index 0000000..518055c --- /dev/null +++ b/standalone/tirith-policies/reference/install.md @@ -0,0 +1,55 @@ +# Install Tirith + +## Tirith is not on PyPI + +`pip install tirith` installs an **unrelated project of the same name**, and `pip install +py-tirith` finds nothing. Always install from git. + +```bash +pip install git+https://github.com/StackGuardian/tirith.git +``` + +Pin a tag rather than tracking the default branch, so a CI job cannot change behaviour underneath +you: + +```bash +pip install "git+https://github.com/StackGuardian/tirith.git@1.2.0" +``` + +`git ls-remote --tags https://github.com/StackGuardian/tirith.git` lists the available tags. + +## Verify + +```bash +tirith --version +``` + +## Python versions + +| | Needs | +| --- | --- | +| Tirith itself | Python 3.8 or newer | +| The `[tui]` extra (`tirith ui`) | Python 3.9 or newer | + +## The optional interface + +`tirith ui` is an interactive terminal interface — explore a failing evaluation down to the +resource, assemble a policy from a form, or experiment in a playground. It is an extra rather than +a dependency, because using Tirith as a CI gate should not pay for an interface it never opens. + +```bash +pip install 'py-tirith[tui] @ git+https://github.com/StackGuardian/tirith.git' +tirith ui +``` + +The extra is requested against the git URL for the same reason as above. On Python 3.8 the extra +installs and simply provides nothing. + +## Developing against a checkout + +```bash +git clone https://github.com/StackGuardian/tirith.git +cd tirith +python -m venv .venv && source .venv/bin/activate +pip install -e . +``` diff --git a/standalone/tirith-policies/reference/other-providers.md b/standalone/tirith-policies/reference/other-providers.md new file mode 100644 index 0000000..8afb189 --- /dev/null +++ b/standalone/tirith-policies/reference/other-providers.md @@ -0,0 +1,78 @@ +# Kubernetes, Infracost and JSON + +Choosing a provider is choosing what document you have to feed it. Each names the value it reads +with a **different key** — the wrong key is ignored, not rejected, so the evaluator reads nothing +and the check does not measure what you think. + +## Kubernetes + +`"required_provider": "stackguardian/kubernetes"`, reading YAML or JSON manifests. Multi-document +YAML is supported. + +- `operation_type`: `attribute` +- Requires `kubernetes_kind` — `Pod`, `Deployment`, `Service`, … +- Names the value with **`attribute_path`** + +```json +{ + "id": "containers_have_liveness_probe", + "provider_args": { + "operation_type": "attribute", + "kubernetes_kind": "Pod", + "attribute_path": "spec.containers.*.livenessProbe" + }, + "condition": {"type": "Contains", "value": null, "error_tolerance": 2} +} +``` + +with `"eval_expression": "!containers_have_liveness_probe"`. + +**Why it is written as a detector.** `spec.containers.*.livenessProbe` returns a *list* — one entry +per container, `null` where the probe is missing. `IsNotEmpty` over that list is true as soon as +one container has a probe, which is the wrong question. Test for the presence of `null` and invert. + +## Infracost + +`"required_provider": "stackguardian/infracost"`, reading an `infracost breakdown --format json` +document. + +- `operation_type`: `total_monthly_cost` or `total_hourly_cost` +- `resource_type`: a list. `["*"]` totals everything. + +```json +{ + "id": "monthly_cost_ceiling", + "provider_args": {"operation_type": "total_monthly_cost", "resource_type": ["*"]}, + "condition": {"type": "LessThanEqualTo", "value": 500} +} +``` + +**The trap: it fails open.** A `resource_type` that matches nothing — a typo, or a type absent from +this plan — sums to `0`, and `0` is less than any ceiling, so the check **passes**. A cost policy +that always passes looks exactly like one that works. Verify against a breakdown that should +exceed the ceiling, and prefer `["*"]` unless you specifically need one type. + +## JSON — anything else + +`"required_provider": "stackguardian/json"` reads any JSON document: a Terraform state file, an API +response, a CI configuration, a lockfile. + +- `operation_type`: `get_value` +- Names the value with **`key_path`** + +```json +{ + "id": "approval_required", + "provider_args": {"operation_type": "get_value", "key_path": "settings.requireApproval"}, + "condition": {"type": "Equals", "value": true} +} +``` + +`key_path` accepts `*` across a list: `list_of_dicts.*.key1` returns one value per entry, and the +condition is applied to each. + +## StackGuardian workflows + +`"required_provider": "stackguardian/sg_workflow"` reads a workflow definition, naming the value +with **`workflow_attribute`**, for rules about the pipeline itself rather than the infrastructure — +for example that a Terraform workflow requires approval before apply. diff --git a/standalone/tirith-policies/reference/pipelines.md b/standalone/tirith-policies/reference/pipelines.md new file mode 100644 index 0000000..f118c99 --- /dev/null +++ b/standalone/tirith-policies/reference/pipelines.md @@ -0,0 +1,212 @@ +# Add Tirith to a pipeline + +Three pieces make a gate, and they are the same three on every platform: + +1. a policy committed under `.tirith/policies/`, +2. the plan as JSON, +3. Tirith running with `--fail-on-error`. + +Only the way you invoke it changes. Everything below is that pattern. + +## Produce the input first + +This is the step people leave out, and without it `plan.json` never exists: + +```bash +terraform plan -out=tfplan -input=false # or: tofu plan -out=tfplan -input=false +terraform show -json tfplan > plan.json # or: tofu show -json tfplan > plan.json +``` + +Either binary works: they emit the same plan JSON and Tirith does not inspect which produced it. + +**`-input=false` matters in CI.** Without it, a missing variable waits for a prompt nobody will +answer and the job hangs instead of failing. Locally you can leave it off. + +## Install + +```bash +pip install "git+https://github.com/StackGuardian/tirith.git@1.2.0" +``` + +Not PyPI: `pip install tirith` fetches an unrelated project. Pin the tag so a job cannot change +behaviour underneath you. Python 3.8 or newer, so any `python:3.x` image works. + +Do **not** add `tirith lint` to a pipeline: it is not in the released package. See +`reference/validate.md`. + +--- + +## GitHub Actions + +Use the action. It finds the plan, posts a sticky pull-request comment, creates a check run and +sets the job's exit code. + +```yaml +permissions: + contents: read + pull-requests: write # the sticky comment + checks: write # the check run + +steps: + - uses: actions/checkout@v4 + - run: terraform plan -out=tfplan -input=false + + - uses: StackGuardian/tirith-iac-governance-action@v2 + with: + plan-file: tfplan + fail-on-error: true +``` + +`plan-file` takes the **binary** plan and renders it with `terraform show -json` in memory, so no +unmasked plan JSON is written to the workspace. Prefer it on GitHub: it is one step shorter than +exporting first, and a plan file on disk is a plan file something else can read. + +Exporting JSON yourself also works, and then no `with:` keys are needed at all: the action finds +the document by convention (`plan.json` or `tfplan.json`) and the policies under +`.tirith/policies`. `plan-file` and `input-path` cannot be combined. + +Without `fail-on-error` it reports findings but does not block. + +The two write permissions are the only setup the action cannot do for itself, and the usual cause +of a first install that runs but posts nothing. + +## GitLab CI + +No GitLab-native equivalent, so call the CLI directly, which is all the action does underneath. +Two jobs: one produces the artifact, the next gates on it. + +```yaml +plan: + stage: plan + script: + - terraform plan -out=tfplan -input=false + - terraform show -json tfplan > plan.json + artifacts: + paths: [plan.json] + +policy: + stage: test + image: python:3.12 + needs: [plan] + script: + - pip install "git+https://github.com/StackGuardian/tirith.git@1.2.0" + - tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error +``` + +If a plan job already exists, keep it: add `plan.json` to its `artifacts` and point the gate at it +with `needs`. + +## Bitbucket Pipelines + +Plan in one step, gate in the next, passing `plan.json` between them as an artifact. + +```yaml +image: python:3.12 + +pipelines: + pull-requests: + '**': + - step: + name: Terraform plan + script: + - terraform plan -out=tfplan -input=false + - terraform show -json tfplan > plan.json + artifacts: [plan.json] + - step: + name: Policy gate + script: + - pip install "git+https://github.com/StackGuardian/tirith.git@1.2.0" + - tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error +``` + +## Jenkins + +Capture the exit code rather than letting `sh` fail, so `3` and `1` can be reported differently: + +```groovy +stage('Policy gate') { + steps { + script { + def code = sh(returnStatus: true, script: ''' + pip install "git+https://github.com/StackGuardian/tirith.git@1.2.0" + tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error + ''') + if (code == 3) { error('Tirith: a policy refused this change.') } + else if (code != 0) { error("Tirith could not reach a verdict (exit ${code}).") } + } + } +} +``` + +## Azure DevOps + +```yaml +- script: | + terraform plan -out=tfplan -input=false + terraform show -json tfplan > plan.json + displayName: Terraform plan + +- script: | + pip install "git+https://github.com/StackGuardian/tirith.git@1.2.0" + tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error + displayName: Policy gate +``` + +`script` fails the task on any non-zero exit. To separate `3` from `1`, run the gate without +`--fail-on-error`, capture `$?`, and fail the task yourself. + +## CircleCI + +```yaml +jobs: + policy-gate: + docker: + - image: cimg/python:3.12 + steps: + - checkout + - attach_workspace: {at: .} + - run: pip install "git+https://github.com/StackGuardian/tirith.git@1.2.0" + - run: tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error +``` + +Persist `plan.json` to the workspace from the plan job. + +## Any other runner + +Anything that can run a container and produce a plan works the same way, including a cron job: + +```bash +pip install "git+https://github.com/StackGuardian/tirith.git@1.2.0" +tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error +``` + +Gate on the exit code, which every CI system already does. + +--- + +## Make the exit code mean something + +| Exit | What the job should do | +| --- | --- | +| `0` | Continue to `apply` | +| `3` | Fail the job: a policy refused the change | +| `1` | Fail the job, but report a **tool or input problem**, not a violation | + +Collapsing `1` and `3` is the most common mistake in a Tirith pipeline. Exit `1` includes +`final_result: null`, which means every check was skipped and the policy evaluated nothing: a gate +that is not gating, reported as if the infrastructure were at fault. + +## Keep the report + +```bash +tirith --json -policy-path .tirith/policies -input-path plan.json > tirith-result.json +``` + +Publish it as a build artifact. It carries every evaluator, its result and the value that produced +it, which is what makes a failure explainable after the fact. + +## Not yet available + +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. diff --git a/standalone/tirith-policies/reference/platform.md b/standalone/tirith-policies/reference/platform.md new file mode 100644 index 0000000..cd06856 --- /dev/null +++ b/standalone/tirith-policies/reference/platform.md @@ -0,0 +1,61 @@ +# Evaluate against an organization's policies + +`tirith platform check` evaluates against the policies a StackGuardian organization enforces, +instead of policy files committed to the repository — so policy lives in one place rather than +being copied into every repository that needs gating. + +This is the **only** Tirith surface that talks to a network. Everything else runs locally. + +## Minimum invocation + +```bash +export SG_API_TOKEN=sgo_... # an organization token +export SG_ORG=my-org + +tirith platform check --workflow-id my-repo --input-path plan.json --fail-on-error +``` + +`--input-path` is optional when a `plan.json` or `tfplan.json` is in the working directory. + +## What it does, in order + +1. Masks Terraform-sensitive values **on your machine**, before anything leaves it. +2. Packs the masked documents with your Terraform source into an archive. +3. Uploads it, creates a run, polls it. +4. Prints the verdict, using the same exit codes as local evaluation. + +## Flags worth knowing + +| Flag | Use | +| --- | --- | +| `--region {eu,us}` | Which region. Default `eu`, or `$SG_REGION` | +| `--api-key -` | Read the key from stdin instead of the environment | +| `--plan-file tfplan` | A binary plan, rendered through `terraform show -json` in memory | +| `--input-kind` | `terraform_plan`, `terraform_state`, `kubernetes` or `json` | +| `--state-path` / `--infracost-path` | Add a state document or a cost breakdown to the run | +| `--no-source` | Send only the documents, not the Terraform source | +| `--timeout` | Seconds to wait for the run. Default `1800` | +| `--output-json` / `--output-markdown` | Write the verdict to files for a later CI step | +| `--api-url` | Overrides `--region` for a self-hosted or dedicated host | + +## Exit codes + +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 | +| `3` | A policy failed (with `--fail-on-error`) | + +## Credentials + +Never commit `SG_API_TOKEN`. In CI, supply it as a secret. `--api-key -` lets you pipe it in +without it appearing in a process list. + +## When not to use it + +If policies live in the repository and one team owns them, local evaluation is simpler, needs no +account, and sends nothing anywhere. Reach for `platform check` when the same policy has to apply +across many repositories and the results need to be visible in one place. diff --git a/standalone/tirith-policies/reference/schema.md b/standalone/tirith-policies/reference/schema.md new file mode 100644 index 0000000..44e9caf --- /dev/null +++ b/standalone/tirith-policies/reference/schema.md @@ -0,0 +1,79 @@ +# 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. + +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: + +```bash +python -c "from tirith.core.evaluators import EVALUATORS_DICT; print(sorted(EVALUATORS_DICT))" +``` + +## Policy shape + +| Key | Required | Notes | +| --- | --- | --- | +| `meta.version` | yes | `"v1"` | +| `meta.required_provider` | yes | One of the providers below | +| `meta.name` / `description` / `severity` / `tags` | no | Passed through to the result document | +| `evaluators[]` | yes | Each needs `id`, `provider_args`, `condition` | +| `eval_expression` | yes | Combines evaluator ids with `&&`, `\|\|`, `!`, parentheses | + +## Condition types — all 13 + +``` +ContainedIn Contains Equals GreaterThan GreaterThanEqualTo IsEmpty +IsNotEmpty LessThan LessThanEqualTo NotContainedIn NotContains +NotEquals RegexMatch +``` + +There is no `Exists`, no `Matches`, no `In`, and no `NotRegexMatch`. Use `IsNotEmpty` for +presence, and `!` in `eval_expression` for negation. + +`condition.value` keeps its JSON type: `true` and `"true"` are different questions. + +## Providers and operations + +| `required_provider` | Reads | `operation_type` | +| --- | --- | --- | +| `stackguardian/terraform_plan` | `terraform show -json` output | `action`, `attribute`, `count`, `direct_dependencies`, `direct_references`, `provider_config`, `terraform_version` | +| `stackguardian/kubernetes` | Kubernetes manifests (YAML or JSON) | `attribute` | +| `stackguardian/infracost` | An Infracost breakdown | `total_monthly_cost`, `total_hourly_cost` | +| `stackguardian/json` | Any JSON document, including a Terraform state file | `get_value` | +| `stackguardian/sg_workflow` | A StackGuardian workflow definition | `attribute` | + +## The argument key differs per provider + +This is the highest-cost mistake in the schema, because the wrong key is **ignored rather than +rejected** — the evaluator then reads nothing and the check does not measure what you think. + +| Provider | Key naming the value | Also needs | +| --- | --- | --- | +| `terraform_plan` | `terraform_resource_attribute` | `terraform_resource_type` (`"*"` = all) | +| `kubernetes` | `attribute_path` | `kubernetes_kind` | +| `json` | `key_path` | — | +| `sg_workflow` | `workflow_attribute` | — | + +Paths are dot-separated and accept `*` as a wildcard across a list: `spec.containers.*.image`. + +## `error_tolerance` + +Lives **inside `condition`**. Anywhere else it has no effect. It forgives *problems reading the +input*, not policy failures, and the severities are specific: + +| Severity | Means | Forgiven by | +| --- | --- | --- | +| `0` | The resource is being deleted (`change.after` is null) | `error_tolerance: 0` (the default forgives nothing) | +| `1` | The resource type is absent from the document | `error_tolerance: 1` or higher | +| `2` | The attribute is absent from the resource | `error_tolerance: 2` | + +A forgiven problem makes the check **skipped**, not passed. A skipped check is removed from +`eval_expression` before evaluation. If every check is skipped the policy reports +`final_result: null` — see `reference/verdicts.md`. + +## Not supported, despite appearances + +`jmespath` and `jq_query` appear in some repository test fixtures. Neither ships. The `json` +provider supports `get_value`. diff --git a/standalone/tirith-policies/reference/terraform-plan.md b/standalone/tirith-policies/reference/terraform-plan.md new file mode 100644 index 0000000..d3b546e --- /dev/null +++ b/standalone/tirith-policies/reference/terraform-plan.md @@ -0,0 +1,88 @@ +# The plan provider — OpenTofu and Terraform + +`"required_provider": "stackguardian/terraform_plan"` reads the output of +`tofu show -json tfplan` or `terraform show -json tfplan`. The provider is named for +Terraform because it predates the fork, but it reads either tool's plan: both emit the same +`resource_changes` structure, and nothing in the provider inspects which binary produced it. + +**There is no `stackguardian/terraform_state` provider.** The registry holds five providers and +that is not one of them. To write a policy about a state file, read it with +`stackguardian/json` and `key_path` — a state document is ordinary JSON. (`tirith platform check +--input-kind terraform_state` is a different thing: it tells the uploader to mask the document as +state before it leaves your machine, and the evaluation still runs through the json provider.) + +Arguments: `terraform_resource_type` selects the resources (`"*"` = every type), and +`terraform_resource_attribute` names the value. Dot-separated, `*` wildcards a list. + +## The seven operations + +| `operation_type` | Returns | Use it for | +| --- | --- | --- | +| `attribute` | The value at `terraform_resource_attribute` | Most policies: tags, encryption flags, sizes | +| `action` | The planned actions for each resource | Blocking destroys and replacements | +| `count` | How many resources of the type exist | Ceilings on resource counts | +| `direct_references` | Whether resources reference a given type | "Every ELB has a security group" | +| `direct_dependencies` | The resource's declared dependencies | Ordering and coupling rules | +| `provider_config` | Provider-level configuration | Pinning a region or a provider setting | +| `terraform_version` | The version recorded in the plan | Requiring a minimum OpenTofu or Terraform version | + +## `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`: + +```json +{ + "id": "no_database_destroy", + "provider_args": { + "operation_type": "action", + "terraform_resource_type": "aws_db_instance" + }, + "condition": {"type": "ContainedIn", "value": ["destroy"]} +} +``` + +with `"eval_expression": "!no_database_destroy"` — the check *detects* a destroy, and `!` turns +detection into refusal. + +## Replacement is two actions, not one + +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`. + +## `count` measures the module, not the change + +`count` has no action filter, and both tools report unchanged resources as `no-op`. So `count` with +`terraform_resource_type: "*"` measures **root-module size**, not the size of the change. Blast +radius is not expressible today — do not write a policy that claims to cap it. + +## `direct_references` + +Answers "is every X referenced by a Y", which attribute checks cannot express: + +```json +{ + "id": "elb_has_security_group", + "provider_args": { + "operation_type": "direct_references", + "terraform_resource_type": "aws_elb", + "references_to": "aws_security_group" + }, + "condition": {"type": "Equals", "value": true, "error_tolerance": 0} +} +``` + +`referenced_by` inverts the direction — "every bucket is referenced by a tiering configuration". + +## Wildcards and missing attributes + +`terraform_resource_type: "*"` with a specific attribute will hit resources that do not have that +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". + +On a wildcard policy every message reads identically, and only the resource address in the result +distinguishes one finding from another. diff --git a/standalone/tirith-policies/reference/validate.md b/standalone/tirith-policies/reference/validate.md new file mode 100644 index 0000000..9f5c599 --- /dev/null +++ b/standalone/tirith-policies/reference/validate.md @@ -0,0 +1,85 @@ +# Validate a policy + +## `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. + +Until it ships there are two ways to validate, and both are available today. + +## The interactive validator does ship + +`tirith ui` carries one. `src/tirith/tui/validate.py` reads the live `EVALUATORS_DICT` and +`PROVIDERS_DICT` and returns errors and warnings as data, and the Playground runs it on every +keystroke while the Builder refuses to add a check that fails it. So the registry-checking that +`tirith lint` will do from the command line is already in the product, just interactively: + +```bash +pip install 'py-tirith[tui] @ git+https://github.com/StackGuardian/tirith.git' +tirith ui --policy .tirith/policies/my-policy.json +``` + +It is advisory by design: it reports a malformed policy rather than refusing to evaluate it, +because experimenting with a half-written policy is the point of a playground. + +## Without the interface + +Check the shape against the closed vocabulary by hand, then evaluate the policy against a document +that should fail it. The second is the one that matters. + +## Check the shape + +Every trap here produces a policy that is structurally plausible and gates nothing, or that fails +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. | +| 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. | +| An evaluator nothing references | If `eval_expression` never names it, it cannot affect the verdict, however carefully it was written. | +| A single `&` where `&&` was meant | `&` and `\|` are not operators. | +| A provider that does not exist | Five ship. There is no `stackguardian/cloudformation`: a CloudFormation template is read by `stackguardian/json`. | + +The closed lists are in `reference/schema.md`. Read them rather than recalling them: the cost of a +wrong key is a policy that passes everything. + +## Then evaluate it + +Shape is not meaning. A policy whose `provider_args` match no resource at all is structurally +perfect and gates nothing. + +```bash +# Against input that SHOULD be refused. Exit 3 is the pass condition for this test. +tirith -policy-path .tirith/policies -input-path should-fail.json --fail-on-error +echo "exit: $?" +``` + +| Exit | Reading | +| --- | --- | +| `3` | The policy works. It refused a change it was supposed to refuse. | +| `0` | **The policy matched nothing.** Wrong provider, wrong operation, or a key the provider ignores. | +| `1` | Every check was skipped, so `final_result` is `null`. Check `error_tolerance` and whether the resource type exists in the document. | + +Then run it against input that should pass, and confirm `0`. A rule only ever seen failing is as +untested as one only ever seen passing. + +## Read the report rather than the summary + +```bash +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/standalone/tirith-policies/reference/variables.md b/standalone/tirith-policies/reference/variables.md new file mode 100644 index 0000000..72db5a7 --- /dev/null +++ b/standalone/tirith-policies/reference/variables.md @@ -0,0 +1,60 @@ +# Parameterise a policy + +One policy, different thresholds per environment, without copying the file. + +A variable is referenced as `{{ var.NAME }}`. **The `var.` prefix is required.** A placeholder +written without it — `{{ max_epoch }}` — is not recognised as a variable and is compared as the +literal string, so the check quietly measures the wrong thing instead of failing. + +## From a file + +```json title="variables.prod.json" +{"max_monthly_cost": 500} +``` + +```json title="policy.json" +{ + "meta": {"version": "v1", "required_provider": "stackguardian/infracost"}, + "evaluators": [{ + "id": "within_budget", + "provider_args": {"operation_type": "total_monthly_cost", "resource_type": ["*"]}, + "condition": {"type": "LessThanEqualTo", "value": "{{ var.max_monthly_cost }}"} + }], + "eval_expression": "within_budget" +} +``` + +```bash +tirith -policy-path policy.json -input-path infracost.json \ + -var-path variables.prod.json --fail-on-error +``` + +`-var-path` may be repeated. Later files override earlier ones on the same key. + +## Inline + +```bash +tirith -policy-path policy.json -input-path infracost.json \ + -var 'max_monthly_cost=500' --fail-on-error +``` + +The value is parsed as JSON, so quote strings as JSON strings and pass lists and objects directly: + +```bash +-var 'environment="production"' +-var 'allowed_regions=["eu-west-1","eu-central-1"]' +``` + +## If a variable is not found + +The evaluation stops and returns an `errors` entry naming the missing variables, rather than +substituting an empty value and producing a verdict from a policy that was never fully resolved. + +## Use it for + +- One cost ceiling per environment. +- An allow-list of regions or instance types that differs per team. +- A tag key your organisation renames without editing every policy. + +Keep the *shape* of the rule in the policy and only the *values* in variables. A variable that +changes which attribute is read makes the policy unreadable. diff --git a/standalone/tirith-policies/reference/verdicts.md b/standalone/tirith-policies/reference/verdicts.md new file mode 100644 index 0000000..4d1ea67 --- /dev/null +++ b/standalone/tirith-policies/reference/verdicts.md @@ -0,0 +1,62 @@ +# Run a policy and read the verdict + +```bash +tirith -policy-path .tirith/policies -input-path plan.json --fail-on-error +echo $? +``` + +`-policy-path` takes a file or a directory. `-input-path` takes the document the provider expects. +Add `--json` to get the result document instead of the pretty printer. + +## Exit codes are a contract + +| Exit | Meaning | +| --- | --- | +| `0` | Policies passed, or nothing was in scope to gate on | +| `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 | + +**`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. + +**Without `--fail-on-error` the exit code is always `0`** and the verdict is only in the output. +That is the historical behaviour, kept so upgrading cannot turn a passing pipeline red. Any real +gate needs the flag. + +## `final_result: null` is not a pass + +It means **every check was skipped** — nothing was evaluated. Under `--fail-on-error` that exits +`1`, not `0` and not `3`. + +It almost always means `provider_args` matched nothing. Check, in this order: + +1. `terraform_resource_type` — is that type actually in the plan? +2. The attribute path — is it under `change.after`, and spelled as the plan spells it? +3. `error_tolerance` — is it forgiving the very problem you wanted to catch? + +Do not reach for the condition until the provider is returning values. + +## Reading the result document + +`--json` returns `final_result`, an `evaluators[]` array, `errors`, and the `eval_expression` that +was evaluated. Each evaluator carries `passed` (`true`, `false`, or `null` for skipped) and a +`result[]` of individual findings. + +Each finding's `meta` carries the resource behind it: `address`, `type`, `name`, and the `change` +with `actions`, `before`, `after` and `after_unknown`. That is how you name the resource that +failed rather than only quoting the message. + +**One asymmetry:** when a check fails because an attribute is *absent*, there is no value to +attach a resource to, so the failure arrives **without a resource address**. Find the culprit by +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. diff --git a/tests/run.mjs b/tests/run.mjs index 9a769a8..32e63fd 100644 --- a/tests/run.mjs +++ b/tests/run.mjs @@ -25,6 +25,7 @@ const UNIT = [ "tests/sg-auth.test.ts", "tests/sg-oauth.test.ts", "tests/sg-skills.test.ts", + "tests/skills-structure.test.ts", ]; const E2E = [ "tests/e2e/install.test.ts", diff --git a/tests/skills-structure.test.ts b/tests/skills-structure.test.ts new file mode 100644 index 0000000..6d44c86 --- /dev/null +++ b/tests/skills-structure.test.ts @@ -0,0 +1,276 @@ +#!/usr/bin/env tsx +// Structure checks for every skill this repository publishes: the six sg-* +// skills the wizard installs (skills/) and the standalone skills (standalone/), +// plus the catalog, the skills.sh grouping and the Claude Code marketplace. +import assert from "node:assert"; +import { + existsSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import yaml from "js-yaml"; +import { SG_SKILL_NAMES, installSkills } from "../src/sg/skills.js"; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const SKILL_ROOTS = ["skills", "standalone"] as const; +const WIZARD_NOTE = + "> **Before you start:** this skill needs the StackGuardian MCP server. If no StackGuardian tools are available (for example `search_workflows`), stop. Tell the user to run `npx @stackguardian/mcp-wizard` and restart the agent. Do not call StackGuardian APIs another way."; + +let passed = 0; +let failed = 0; +const tempDirs: string[] = []; + +function test(name: string, fn: () => void) { + try { + fn(); + console.log(`✓ ${name}`); + passed++; + } catch (err) { + console.log(`✗ ${name}`); + console.error(` ${(err as Error).message}`); + failed++; + } +} + +function readJson(path: string): T { + return JSON.parse(readFileSync(join(repoRoot, path), "utf-8")) as T; +} + +type Skill = { root: string; dir: string; path: string }; + +/** Every `//SKILL.md` folder under the published skill roots. */ +function skillFolders(): Skill[] { + return SKILL_ROOTS.flatMap((root) => + readdirSync(join(repoRoot, root)) + .filter((name) => statSync(join(repoRoot, root, name)).isDirectory()) + .map((name) => ({ root, dir: name, path: `${root}/${name}` })), + ); +} + +function frontmatter(skillPath: string): Record { + const text = readFileSync(join(repoRoot, skillPath, "SKILL.md"), "utf-8"); + const match = /^---\n([\s\S]*?)\n---\n/.exec(text); + assert.ok(match, `${skillPath}/SKILL.md has no frontmatter block`); + const data = yaml.load(match[1]); + assert.ok( + data && typeof data === "object", + `${skillPath}/SKILL.md frontmatter is not a mapping`, + ); + return data as Record; +} + +function markdownFiles(dir: string): string[] { + return readdirSync(dir, { recursive: true }) + .map(String) + .filter((path) => path.endsWith(".md")) + .map((path) => join(dir, path)); +} + +/** Relative link targets outside fenced code blocks. */ +function relativeLinks(markdown: string): string[] { + const prose = markdown.replace(/```[\s\S]*?```/g, ""); + return [...prose.matchAll(/\]\(([^)\s]+)\)/g)] + .map((m) => m[1]) + .filter((target) => !/^(https?:|mailto:|#)/.test(target)) + .map((target) => decodeURIComponent(target.split("#")[0])); +} + +type CatalogEntry = { + slug: string; + title: string; + tier: "standalone" | "platform"; + requiresStackGuardian: boolean; + category: string; + summary: string; + description: string; + examplePrompts: string[]; + supportedAgents: string[]; + prerequisites: string[]; + install: { skillsCli: string | null; claudePlugin: string | null }; + githubPath: string; + demoUrl: string | null; + relatedSlugs: string[]; +}; +const catalog = readJson<{ version: number; skills: CatalogEntry[] }>( + "catalog/skills.json", +); + +test("every SKILL.md has a valid name and a what-and-when description", () => { + const skills = skillFolders(); + assert.ok(skills.length > SG_SKILL_NAMES.length, "found the skill folders"); + for (const skill of skills) { + const meta = frontmatter(skill.path); + assert.strictEqual(meta.name, skill.dir, `${skill.path}: name`); + assert.match( + skill.dir, + /^[a-z0-9-]{1,64}$/, + `${skill.path}: name must be lowercase letters, digits and hyphens, at most 64 characters`, + ); + const description = meta.description; + assert.ok( + typeof description === "string" && description.trim().length > 0, + `${skill.path}: description missing`, + ); + assert.ok( + description.length <= 1024, + `${skill.path}: description is ${description.length} characters`, + ); + assert.match( + description, + /\bwhen\b/i, + `${skill.path}: description should say when to use the skill`, + ); + } +}); + +test("every relative link inside a skill resolves", () => { + for (const skill of skillFolders()) { + for (const file of markdownFiles(join(repoRoot, skill.path))) { + for (const target of relativeLinks(readFileSync(file, "utf-8"))) { + assert.ok( + existsSync(resolve(dirname(file), target)), + `${relative(repoRoot, file)} links to missing ${target}`, + ); + } + } + } +}); + +test("the sg-* skills tell the agent how to connect when the server is missing", () => { + for (const name of SG_SKILL_NAMES) { + const text = readFileSync( + join(repoRoot, "skills", name, "SKILL.md"), + "utf-8", + ); + assert.ok(text.includes("npx @stackguardian/mcp-wizard"), name); + assert.ok(text.includes(WIZARD_NOTE), `${name}: note differs`); + } +}); + +test("the wizard still installs exactly the six sg-* skills", () => { + const folders = readdirSync(join(repoRoot, "skills")).filter((name) => + statSync(join(repoRoot, "skills", name)).isDirectory(), + ); + assert.deepStrictEqual(folders.sort(), [...SG_SKILL_NAMES].sort()); + + const pkg = readJson<{ files: string[]; scripts: Record }>( + "package.json", + ); + assert.deepStrictEqual(pkg.files, ["dist", "skills", "README.md"]); + const buildBinary = pkg.scripts["build:binary"] ?? ""; + assert.ok(buildBinary.includes(" skills/*/SKILL.md ")); + assert.ok(!buildBinary.includes("standalone")); + + const home = mkdtempSync(join(tmpdir(), "add-sg-mcp-structure-home-")); + const cwd = mkdtempSync(join(tmpdir(), "add-sg-mcp-structure-cwd-")); + tempDirs.push(home, cwd); + process.env.XDG_CONFIG_HOME = join(home, ".config"); + const results = installSkills(["claude-code", "codex"], "global", cwd, { + home, + }); + assert.ok( + results.every((r) => !r.error), + JSON.stringify(results.filter((r) => r.error)), + ); + for (const dir of [ + join(home, ".agents", "skills"), + join(home, ".claude", "skills"), + ]) { + assert.deepStrictEqual( + readdirSync(dir).sort(), + [...SG_SKILL_NAMES].sort(), + dir, + ); + } +}); + +test("catalog entries point at real skills with matching names", () => { + assert.strictEqual(catalog.version, 1); + const slugs = catalog.skills.map((entry) => entry.slug); + assert.strictEqual(new Set(slugs).size, slugs.length, "slugs are unique"); + const categories = ["Review", "Deploy", "Policy", "Migrate", "Platform"]; + for (const entry of catalog.skills) { + assert.ok( + existsSync(join(repoRoot, entry.githubPath, "SKILL.md")), + `${entry.slug}: ${entry.githubPath}/SKILL.md`, + ); + assert.strictEqual(frontmatter(entry.githubPath).name, entry.slug); + const platform = entry.githubPath.startsWith("skills/"); + assert.strictEqual(entry.requiresStackGuardian, platform, entry.slug); + assert.strictEqual( + entry.tier, + platform ? "platform" : "standalone", + entry.slug, + ); + assert.ok(categories.includes(entry.category), entry.slug); + assert.ok(entry.summary.length <= 160, `${entry.slug}: summary length`); + for (const related of entry.relatedSlugs) { + assert.ok(slugs.includes(related), `${entry.slug}: ${related}`); + } + } +}); + +test("every skill folder has a catalog entry", () => { + const paths = catalog.skills.map((entry) => entry.githubPath); + for (const skill of skillFolders()) { + assert.ok(paths.includes(skill.path), `${skill.path} is not catalogued`); + } +}); + +test("skills.sh groupings list only catalogued skills in the right tier", () => { + const grouping = readJson<{ + groupings: { title: string; skills: string[] }[]; + }>("skills.sh.json"); + const tierOf = new Map(catalog.skills.map((e) => [e.slug, e.tier])); + const expected: Record = { + "Works with any repo": "standalone", + "With StackGuardian": "platform", + }; + for (const group of grouping.groupings) { + for (const slug of group.skills) { + assert.strictEqual( + tierOf.get(slug), + expected[group.title], + `${group.title}: ${slug}`, + ); + } + } +}); + +test("the marketplace plugins carry skills only", () => { + const marketplace = readJson<{ + name: string; + plugins: Record[]; + }>(".claude-plugin/marketplace.json"); + assert.strictEqual(marketplace.name, "stackguardian"); + const plugins = Object.fromEntries( + marketplace.plugins.map((p) => [p.name as string, p]), + ); + assert.deepStrictEqual(plugins.terraform?.skills, ["./standalone"]); + assert.deepStrictEqual(plugins.stackguardian?.skills, ["./skills"]); + for (const plugin of marketplace.plugins) { + for (const key of [ + "mcpServers", + "hooks", + "agents", + "commands", + "lspServers", + ]) { + assert.ok(!(key in plugin), `${plugin.name as string} declares ${key}`); + } + } + // A plugin whose source is the repository root would load these too. + for (const path of [".mcp.json", "hooks", "agents", "commands", "bin"]) { + assert.ok(!existsSync(join(repoRoot, path)), `repository root has ${path}`); + } +}); + +for (const dir of tempDirs) rmSync(dir, { recursive: true, force: true }); +console.log(`\n${passed} passed, ${failed} failed`); +process.exit(failed ? 1 : 0); From d85271c50b4996cf9961ff17fcda9b0ad22987a7 Mon Sep 17 00:00:00 2001 From: AKSHAT TANDON Date: Wed, 21 Oct 2026 19:30:53 +0100 Subject: [PATCH 2/3] test: satisfy noUncheckedIndexedAccess in the structure test Co-Authored-By: Claude Opus 5.5 --- tests/skills-structure.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/skills-structure.test.ts b/tests/skills-structure.test.ts index 6d44c86..3f04598 100644 --- a/tests/skills-structure.test.ts +++ b/tests/skills-structure.test.ts @@ -57,7 +57,7 @@ function frontmatter(skillPath: string): Record { const text = readFileSync(join(repoRoot, skillPath, "SKILL.md"), "utf-8"); const match = /^---\n([\s\S]*?)\n---\n/.exec(text); assert.ok(match, `${skillPath}/SKILL.md has no frontmatter block`); - const data = yaml.load(match[1]); + const data = yaml.load(match[1] ?? ""); assert.ok( data && typeof data === "object", `${skillPath}/SKILL.md frontmatter is not a mapping`, @@ -76,9 +76,9 @@ function markdownFiles(dir: string): string[] { function relativeLinks(markdown: string): string[] { const prose = markdown.replace(/```[\s\S]*?```/g, ""); return [...prose.matchAll(/\]\(([^)\s]+)\)/g)] - .map((m) => m[1]) + .map((m) => m[1] ?? "") .filter((target) => !/^(https?:|mailto:|#)/.test(target)) - .map((target) => decodeURIComponent(target.split("#")[0])); + .map((target) => decodeURIComponent(target.split("#")[0] ?? "")); } type CatalogEntry = { From b9161e3f93e049af22b8055524cea612f35c685a Mon Sep 17 00:00:00 2001 From: AKSHAT TANDON Date: Wed, 21 Oct 2026 19:34:54 +0100 Subject: [PATCH 3/3] docs: two-tier README top with verified install commands Co-Authored-By: Claude Opus 5.5 --- README.md | 37 +++++++++++++++++++++++++++++++++---- catalog/skills.json | 42 +++++++++++++++++++++--------------------- standalone/README.md | 2 +- 3 files changed, 55 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index ee5a18e..a476feb 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# StackGuardian MCP - Deploy Terraform, OpenTofu and Ansible with your AI coding agent +# Deploy Terraform with AI, without losing control [![npm](https://img.shields.io/npm/v/@stackguardian/mcp-wizard)](https://www.npmjs.com/package/@stackguardian/mcp-wizard) [![CI](https://github.com/StackGuardian/stackguardian-mcp/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/StackGuardian/stackguardian-mcp/actions/workflows/ci.yml) @@ -6,9 +6,38 @@ [![MCP](https://img.shields.io/badge/MCP-compatible-blue)](https://modelcontextprotocol.io) [![Agent Skills](https://img.shields.io/badge/Agent%20Skills-included-blue)](skills/README.md) -One-command MCP setup and ready-to-use agent skills for Claude Code, Cursor, Codex and [more](#supported-agents). +Review Terraform changes, gate them with policy and deploy from Claude Code, Cursor or Codex. Start locally; add managed execution, approvals and run history when your team needs them. -Connect your coding agent to [StackGuardian](https://www.stackguardian.io), the platform that runs and governs your infrastructure deployments. Then ask in plain language to turn your existing infrastructure code into deployment workflows, review changes, require approvals, add policies and find out why a run failed. +| | Works with any repo | With StackGuardian | +| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| Skills | [`tirith-policies`](standalone/tirith-policies/SKILL.md) | [Six `sg-*` skills](#mcp-and-skills) for workflows, stacks, policies and secrets | +| What your agent can do | Write and run [Tirith](https://github.com/StackGuardian/tirith) policy checks on your Terraform and OpenTofu plans, and add them to CI | Create and run deployment workflows, require approvals, add policies and find out why a run failed, in your StackGuardian organization | +| You need | Terraform or OpenTofu, and Python 3.8 or later for Tirith. No account. | A StackGuardian account and the StackGuardian MCP server | +| Install | [`npx skills add …`](#works-with-any-repo) | [`npx @stackguardian/mcp-wizard`](#with-stackguardian) | + +## Works with any repo + +Install the standalone skills with the [skills CLI](https://github.com/vercel-labs/skills). It asks which agents to install to; add `-a claude-code`, `-a cursor` or `-a codex` to choose, and `-g` to install for your user instead of the current project. + +```bash +npx skills add https://github.com/StackGuardian/stackguardian-mcp/tree/main/standalone +``` + +In Claude Code you can install them as a plugin instead: + +```bash +claude plugin install terraform --marketplace StackGuardian/stackguardian-mcp +``` + +**Then try asking your agent:** + +> Write a Tirith policy that fails the plan if any S3 bucket is public, and test it against our plan. + +> Add a Tirith check to our GitHub Actions workflow before `terraform apply`. + +## With StackGuardian + +Connect your coding agent to [StackGuardian](https://www.stackguardian.io), the platform that runs and governs your infrastructure deployments. One command adds the StackGuardian MCP server to your agents and installs the six `sg-*` skills: ```bash npx @stackguardian/mcp-wizard @@ -26,7 +55,7 @@ No Node.js? Use the [one-line installer](#install) for macOS, Linux or Windows. [Get started](#get-started) · [Supported agents](#supported-agents) · [Skills](#mcp-and-skills) · [Skills guide](skills/README.md) -## What you can do +### What you can do with StackGuardian | Goal | Ask your agent, for example | What happens | | ------------------------------ | ---------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | diff --git a/catalog/skills.json b/catalog/skills.json index 3e8045f..c300997 100644 --- a/catalog/skills.json +++ b/catalog/skills.json @@ -14,14 +14,14 @@ "Add a Tirith check to our GitHub Actions workflow before terraform apply.", "Why did the Tirith step fail in CI?" ], - "supportedAgents": [], + "supportedAgents": ["Claude Code"], "prerequisites": [ "Terraform or OpenTofu", "Python 3.8 or later for Tirith" ], "install": { - "skillsCli": null, - "claudePlugin": null + "skillsCli": "npx skills add https://github.com/StackGuardian/stackguardian-mcp/tree/main/standalone --skill tirith-policies", + "claudePlugin": "claude plugin install terraform --marketplace StackGuardian/stackguardian-mcp" }, "githubPath": "standalone/tirith-policies", "demoUrl": null, @@ -40,14 +40,14 @@ "Create an OpenTofu workflow from our repo that needs approval before every apply.", "Set up a workflow that runs our Ansible playbook as a custom step." ], - "supportedAgents": [], + "supportedAgents": ["Claude Code"], "prerequisites": [ "A StackGuardian account", "The StackGuardian MCP server: npx @stackguardian/mcp-wizard" ], "install": { - "skillsCli": null, - "claudePlugin": null + "skillsCli": "npx skills add StackGuardian/stackguardian-mcp --skill sg-create-workflow", + "claudePlugin": "claude plugin install stackguardian --marketplace StackGuardian/stackguardian-mcp" }, "githubPath": "skills/sg-create-workflow", "demoUrl": null, @@ -70,14 +70,14 @@ "Require approval from alice@example.com or bob@example.com before prod-vpc applies.", "prod-vpc runs keep running out of memory. Give them 4 GB." ], - "supportedAgents": [], + "supportedAgents": ["Claude Code"], "prerequisites": [ "A StackGuardian account", "The StackGuardian MCP server: npx @stackguardian/mcp-wizard" ], "install": { - "skillsCli": null, - "claudePlugin": null + "skillsCli": "npx skills add StackGuardian/stackguardian-mcp --skill sg-update-workflow", + "claudePlugin": "claude plugin install stackguardian --marketplace StackGuardian/stackguardian-mcp" }, "githubPath": "skills/sg-update-workflow", "demoUrl": null, @@ -96,14 +96,14 @@ "What would change if I moved prod-vpc to revision 5 of its template?", "Roll prod-vpc back to the previous template revision." ], - "supportedAgents": [], + "supportedAgents": ["Claude Code"], "prerequisites": [ "A StackGuardian account", "The StackGuardian MCP server: npx @stackguardian/mcp-wizard" ], "install": { - "skillsCli": null, - "claudePlugin": null + "skillsCli": "npx skills add StackGuardian/stackguardian-mcp --skill sg-upgrade-workflow", + "claudePlugin": "claude plugin install stackguardian --marketplace StackGuardian/stackguardian-mcp" }, "githubPath": "skills/sg-upgrade-workflow", "demoUrl": null, @@ -121,14 +121,14 @@ "Find a stack template that sets up a VPC, a security group and an EC2 instance.", "Create a stack from that template in the staging group with our AWS staging connector." ], - "supportedAgents": [], + "supportedAgents": ["Claude Code"], "prerequisites": [ "A StackGuardian account", "The StackGuardian MCP server: npx @stackguardian/mcp-wizard" ], "install": { - "skillsCli": null, - "claudePlugin": null + "skillsCli": "npx skills add StackGuardian/stackguardian-mcp --skill sg-create-stack", + "claudePlugin": "claude plugin install stackguardian --marketplace StackGuardian/stackguardian-mcp" }, "githubPath": "skills/sg-create-stack", "demoUrl": null, @@ -146,14 +146,14 @@ "Block any deployment in the prod group that makes an S3 bucket public.", "Warn when the estimated monthly cost of an EC2 instance goes over $100." ], - "supportedAgents": [], + "supportedAgents": ["Claude Code"], "prerequisites": [ "A StackGuardian account", "The StackGuardian MCP server: npx @stackguardian/mcp-wizard" ], "install": { - "skillsCli": null, - "claudePlugin": null + "skillsCli": "npx skills add StackGuardian/stackguardian-mcp --skill sg-create-policy", + "claudePlugin": "claude plugin install stackguardian --marketplace StackGuardian/stackguardian-mcp" }, "githubPath": "skills/sg-create-policy", "demoUrl": null, @@ -172,14 +172,14 @@ "Rotate the DB_PASSWORD secret.", "Delete the OLD_API_KEY secret. Which workflows still use it?" ], - "supportedAgents": [], + "supportedAgents": ["Claude Code"], "prerequisites": [ "A StackGuardian account", "The StackGuardian MCP server: npx @stackguardian/mcp-wizard" ], "install": { - "skillsCli": null, - "claudePlugin": null + "skillsCli": "npx skills add StackGuardian/stackguardian-mcp --skill sg-manage-secrets", + "claudePlugin": "claude plugin install stackguardian --marketplace StackGuardian/stackguardian-mcp" }, "githubPath": "skills/sg-manage-secrets", "demoUrl": null, diff --git a/standalone/README.md b/standalone/README.md index 0bf5501..b78b6cb 100644 --- a/standalone/README.md +++ b/standalone/README.md @@ -1,5 +1,5 @@ # Standalone skills -Agent skills for Terraform and OpenTofu that work in any repository and need no StackGuardian account. Install them with the [skills CLI](https://github.com/vercel-labs/skills) or the Claude Code plugin; the [README](../README.md#skills-that-work-with-any-repo) has the commands. +Agent skills for Terraform and OpenTofu that work in any repository and need no StackGuardian account. Install them with the [skills CLI](https://github.com/vercel-labs/skills) or the Claude Code plugin; the [README](../README.md#works-with-any-repo) has the commands. The `tirith-*` folders are copies of the skills in [StackGuardian/tirith](https://github.com/StackGuardian/tirith), pinned to the tag in `TIRITH_VERSION`. Do not edit them here: change them in Tirith, bump the tag and run `node scripts/sync-tirith-skills.mjs`.