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. Set up with Local development
first (make up, then make seed).
On the hosted product? Parts A, B and B2 work there: draft the rule in the Workspace chat, review it, and check what it would have blocked against your real recent proposals. Instead of Part C, click Propose as PR under the draft (runner 0.2.18) and merge the pull request it opens on your platform repo; the next run enforces the rule. See Add your own rule.
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 argocd, constitution,
posture, practice, proposal 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. From runner 0.2.14 the runner enforces this: every overlay .rego file
must be package overlay.<name>, and an overlay that reaches outside
data.overlay gets no verdict at all. The full rules are in
the constitution reference.
Each rule is two files:
rules/<rule_id>/<rule_id>.rego # package, rule_metadata, deny
rules/<rule_id>/<rule_id>_test.rego # opa testsPart A. Draft the rule with the Coordinator
-
In the app, open Workspace in the sidebar (or Rules → + Draft a rule with the Coordinator).
-
Type:
/rule new acme.requires_explicit_workflow_permissions -
The Coordinator asks what the rule should deny. Describe it plainly:
Deny any proposed file under
.github/workflows/that does not declare a top-levelpermissions:key. The message should name the file. -
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.regowith Copy and Download.
You can also start from prose (/rule new deny workflows without explicit permissions); the Coordinator proposes a rule id for you.
A draft is not live. It becomes a rule only when it is merged into your overlay: in this workshop, by landing 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_permissionsandimport rego.v1; - a
rule_metadatamap keyed on the rule id that declares every required field: adescription,kind: "structural"(it checks the shape of a workflow),required_tooling_categories: []andpractice_dimensions: [](it only reads the proposal), andtier_scopeandtarget_kindsof["any"]. It may add"subjects": ["proposal", "repo"], so the repo audit also judges your service repo’s own workflows with it. Thenrule_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 apermissions: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 B2. Check what it would have blocked
Under the finished draft, click Would it have blocked my recent proposals?
TruStacks replays your Application’s 10 most recent proposals through the policy engine, with the rules you have today and with your draft added, and lists each one as would be blocked (with your rule’s message) or passes this rule. The policy engine decides each line; the assistant isn’t involved.

Read the result as a review of your rule:
- It blocks proposals you expected it to block. Good: that’s the gap the rule closes.
- It blocks nothing, though you expected it to. The rule may not be looking where your changes are. When this workshop was written, a draft that defined a “production deploy job” as one particular deploy action blocked 0 of 10 proposals, because the service deploys a different way. Tighten the description and draft again.
- It blocks everything. The rule may be too broad, or it may ask for something no proposal can contain. A rule must check what the gate can see: the proposed files, the target (cluster, tier, kind) and your EnvironmentProfile. A ticket number in another system isn’t something a proposal carries. Check for a change-approval step in the pipeline instead.
If the check refuses the draft, for example because its package isn’t
package overlay.<name> and the gate would never consult it, the message
says so. Fix the draft before landing it.
Part C. Land it with the CLI
-
Clone your overlay repo. In local development it is the seeded
overlayrepo in Gitea:git clone http://gitea.localtest.me:8080/trustacks/overlay.git ~/overlay export OVERLAY=~/overlayThe CLI checks that the directory has
environment-profile.yamlat its root, which is how it recognizes an overlay repo. Run thetrustackscommands below from the root of yourtrustacks-mvpcheckout, passing--overlay "$OVERLAY". (rule signlooks for the local signing key in.policy-keys/relative to where you run it, which is the repo root.) -
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.regofile with the Coordinator’s draft (keep the package nameoverlay.acme_requires_explicit_workflow_permissions).⚠️Keep every package under
overlay.. A file whose package is anything else (package proposal, say) is refused byrule lintandrule sign, and the runner rejects the whole overlay.rule new --packagerefuses a package that does not start withoverlay.. -
Write the tests. The scaffold includes one passing baseline test. Add a negative and a positive case to the
_test.regofile: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 } -
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 testrunsopa teston the rule.rule lintchecks the rule’s structure: the directory name is a valid rule id, both files exist, thepackageandrule_metadataare present and name the rule id,opa checkpasses, the overlay is confined todata.overlay(the same check the runner makes before it evaluates an overlay), andrule_metadatadeclares every required field with values in the schema. Without OPA on yourPATH, lint skipsopa check, the package part of the confinement check and the metadata check. -
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:8080by default) and signs it with the local development cosign key under.policy-keys/. Before building, it runs the confinement check and refuses to sign an overlay that reaches outsidedata.overlay. It does not run the rest of lint or your tests, so run those first. Note the bundle ref it prints, for examplezot.localtest.me:8080/trustacks/policy/overlay/trustacks-mvp-local:dev(the name comes from yourenvironment-profile.yaml). -
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.
-
Point the runner at the overlay so its init container pulls and verifies it. The local runner is installed with no overlay (
policyBundle.overlayRefis empty), so the first time, set it. Inside the cluster the registry iszot.trustacks-system.svc.cluster.local:5000, so use that host with the repository and tagrule signprinted:helm upgrade runner charts/runner -n trustacks-system \ --reset-then-reuse-values \ --set policyBundle.overlayRef=zot.trustacks-system.svc.cluster.local:5000/trustacks/policy/overlay/trustacks-mvp-local:devThe upgrade restarts the runner. After later re-signs to the same tag, a restart is enough:
kubectl -n trustacks-system rollout restart deployment runner-runner
Part D. Watch it decide
- Open Rules in the app. Filter by Overlay: your rule is listed with a Your overlay badge and its Rego source.
- 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 citesacme.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.) - 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
- Rule CLI reference: every
trustacks ruleoption. - Rules and Workspace: the screens used here.
- Constitution reference: the rules your overlay adds to.
- CI/CD generation workshop