Skip to Content
WorkshopsPolicy authoring (local dev)

Policy authoring workshop

Write your first overlay rule: a piece of your team’s knowledge, turned into signed Rego that every TruStacks proposal is judged against. The Coordinator drafts the Rego in chat. You review it, test it, and land it with the trustacks rule CLI. The agent does the syntax. You do the judgment.

⚠️

This workshop needs a local development environment. Landing a rule uses the trustacks rule CLI, which signs with a local key and publishes to the local registry, and works against local and self-hosted control planes. There is no supported way yet to deliver your own overlay bundle to a hosted runner. Set up with Local development first (make up, then make seed).

On the hosted product? You can still do Part A (drafting in the Activity chat) and Part B’s review and tests on your own machine. The rule will not run on your hosted runner yet.

What you will build

A rule that requires every GitHub Actions workflow TruStacks proposes to set top-level permissions: explicitly, so jobs never run with the default token scope. The rule id is acme.requires_explicit_workflow_permissions. Substitute your own namespace for acme.

Rule ids are <namespace>.<name>: lowercase letters, digits and underscores, at most 64 characters. The namespaces proposal, argocd, constitution and trustacks are reserved for TruStacks.

Time required

About 30 minutes.

How overlay rules work

The overlay is a set of Rego rules that lives in your overlay repo, next to environment-profile.yaml. It is built into a bundle, signed with cosign, and loaded by the runner alongside the constitution. Your rules’ denies are added to the constitution’s, so a proposal must satisfy both. That is why an overlay can only make the gate stricter: it can add denies, never remove one.

Each rule is two files:

rules/<rule_id>/<rule_id>.rego # package, rule_metadata, deny rules/<rule_id>/<rule_id>_test.rego # opa tests

Part A. Draft the rule with the Coordinator

  1. In the app, open the Activity chat: Rules → + Draft a rule with the Coordinator (or go to #/activity).

  2. Type:

    /rule new acme.requires_explicit_workflow_permissions
  3. The Coordinator asks what the rule should deny. Describe it plainly:

    Deny any proposed file under .github/workflows/ that does not declare a top-level permissions: key. The message should name the file.

  4. The Coordinator narrates as it drafts, then shows the Rego in a block headed rules/acme.requires_explicit_workflow_permissions/acme.requires_explicit_workflow_permissions.rego with Copy and Download.

You can also start from prose (/rule new deny workflows without explicit permissions); the Coordinator proposes a rule id for you.

Drafts are ephemeral. The draft is not saved anywhere and does not go live. It becomes a rule only when you land it in Part C.

Part B. Review the draft

Read what the Coordinator produced. A good draft has:

  • a header comment, package overlay.acme_requires_explicit_workflow_permissions and import rego.v1;
  • a rule_metadata map keyed on the rule id, with a description and required_tooling_categories (empty here, because the rule only reads the proposal), and rule_index := rule_metadata;
  • a deny contains msg if { … } block with a real condition, emitting {"rule_id": …, "message": …}.

The judgment questions are yours:

  • Does it check what you actually mean? (Top-level permissions:, not a permissions: nested inside a job.)
  • What about edge cases? A workflow that sets permissions: {} is explicit and should pass.
  • Is the message something a reviewer can act on?

The deny body for this rule should look roughly like this:

deny contains msg if { some f in input.proposal.files startswith(f.path, ".github/workflows/") not regex.match(`(?m)^permissions:`, f.content) msg := { "rule_id": "acme.requires_explicit_workflow_permissions", "message": sprintf("workflow %s must declare top-level permissions: explicitly", [f.path]), } }

Part C. Land it with the CLI

  1. Clone your overlay repo. In local development it is the seeded overlay repo in Gitea:

    git clone http://gitea.localtest.me:8080/trustacks/overlay.git ~/overlay export OVERLAY=~/overlay

    The CLI checks that the directory has environment-profile.yaml at its root, which is how it recognizes an overlay repo. Run the trustacks commands below from the root of your trustacks-mvp checkout, passing --overlay "$OVERLAY". (rule sign looks for the local signing key in .policy-keys/ relative to where you run it, which is the repo root.)

  2. Scaffold the rule. This creates both files, with a test file wired to the right package:

    uv run --project runner trustacks rule new acme.requires_explicit_workflow_permissions \ --overlay "$OVERLAY" \ --description "Workflows must declare top-level permissions explicitly"

    The scaffolded rule’s condition is false, so it never fires. Replace the whole .rego file with the Coordinator’s draft (keep the package name overlay.acme_requires_explicit_workflow_permissions).

  3. Write the tests. The scaffold includes one passing baseline test. Add a negative and a positive case to the _test.rego file:

    test_denies_workflow_without_permissions if { bad := {"proposal": {"files": [{ "path": ".github/workflows/ci-api.yaml", "content": "name: ci\non:\n workflow_call:\njobs: {}\n", }]}} violations := acme_requires_explicit_workflow_permissions.deny with input as bad some v in violations v.rule_id == "acme.requires_explicit_workflow_permissions" } test_allows_workflow_with_permissions if { good := {"proposal": {"files": [{ "path": ".github/workflows/ci-api.yaml", "content": "name: ci\non:\n workflow_call:\npermissions:\n contents: read\njobs: {}\n", }]}} count(acme_requires_explicit_workflow_permissions.deny) == 0 with input as good }
  4. Test and lint:

    uv run --project runner trustacks rule test acme.requires_explicit_workflow_permissions --overlay "$OVERLAY" -v uv run --project runner trustacks rule lint acme.requires_explicit_workflow_permissions --overlay "$OVERLAY"

    rule test runs opa test on the rule. rule lint checks the rule’s structure: the directory name is a valid rule id, both files exist, the package and rule_metadata are present and name the rule id, and opa check passes. Lint does not prove anything about how the rule relates to the constitution; the “stricter, never looser” property comes from how denies are combined, not from a linter.

  5. Sign and publish the overlay bundle:

    uv run --project runner trustacks rule sign --overlay "$OVERLAY"

    This builds the overlay bundle, pushes it to the local registry (zot.localtest.me:8080 by default) and signs it with the local development cosign key under .policy-keys/. It does not run lint, so run lint first.

  6. Commit and open a PR on the overlay repo, so the rule has a reviewed history like any other code. Explain why the rule exists, what it denies, and the edge cases it handles.

  7. Restart the runner so its init container pulls and verifies the new bundle. In local development:

    kubectl -n trustacks-system rollout restart deployment runner-runner

Part D. Watch it decide

  1. Open Rules in the app. Filter by Overlay: your rule is listed with a Your overlay badge and its Rego source.
  2. Promote an analysis to a proposal (dry run is fine), as in the CI/CD generation workshop. If the emitted workflow has no top-level permissions:, the Constitution reports denied and cites acme.requires_explicit_workflow_permissions. No PR opens. (If the emitted workflow already declares top-level permissions, the rule passes, which is the rule working too.)
  3. On Overview, Policy in force counts the denial against your rule.

What just happened

You turned a piece of team knowledge into a rule that is:

  • reviewed, in a PR on your overlay repo;
  • tested, with positive and negative cases;
  • signed, and verified by the runner before it loads;
  • enforced on every proposal, with denials that cite it by id;
  • visible to everyone on the Rules screen.

The person who knew this rule no longer has to be the one who remembers it.

Where to go next

Last updated on