diff --git a/docs.json b/docs.json
index 759e19b..4de7e53 100644
--- a/docs.json
+++ b/docs.json
@@ -21,7 +21,12 @@
"styling": {
"eyebrows": "breadcrumbs",
"codeblocks": {
- "theme": "dracula"
+ "theme": "dracula",
+ "languages": {
+ "custom": [
+ "/languages/rego.json"
+ ]
+ }
}
},
"background": {
@@ -135,6 +140,12 @@
"pages": [
"tutorials/unauthorized_iac_changes"
]
+ },
+ {
+ "group": "Evaluation",
+ "pages": [
+ "tutorials/evaluate_trails_with_opa"
+ ]
}
]
},
@@ -410,6 +421,18 @@
}
]
},
+ {
+ "item": "Policy Reference",
+ "icon": "scroll",
+ "groups": [
+ {
+ "group": "Policies",
+ "pages": [
+ "policy-reference/rego_policy"
+ ]
+ }
+ ]
+ },
{
"item": "API Reference",
"icon": "code",
diff --git a/languages/rego.json b/languages/rego.json
new file mode 100644
index 0000000..01d5824
--- /dev/null
+++ b/languages/rego.json
@@ -0,0 +1,286 @@
+{
+ "fileTypes": [
+ "rego"
+ ],
+ "name": "rego",
+ "patterns": [
+ {
+ "include": "#comment"
+ },
+ {
+ "include": "#keyword"
+ },
+ {
+ "include": "#comparison-operators"
+ },
+ {
+ "include": "#assignment-operators"
+ },
+ {
+ "include": "#term"
+ }
+ ],
+ "repository": {
+ "call": {
+ "captures": {
+ "1": {
+ "name": "support.function.any-method.rego"
+ }
+ },
+ "match": "([a-zA-Z_][a-zA-Z0-9_]*)\\(",
+ "name": "meta.function-call.rego"
+ },
+ "comment": {
+ "patterns": [
+ {
+ "match": "(#)\\s*(METADATA)\\s*$\\n?",
+ "captures": {
+ "1": {
+ "name": "punctuation.definition.comment.rego"
+ },
+ "2": {
+ "name": "strong"
+ }
+ },
+ "name": "comment.line.number-sign.rego"
+ },
+ {
+ "match": "(#)\\s*(scope|title|description|related_resources|authors|organizations|schemas|entrypoint|custom):.*$\\n?",
+ "captures": {
+ "1": {
+ "name": "punctuation.definition.comment.rego"
+ },
+ "2": {
+ "name": "strong"
+ }
+ },
+ "name": "comment.line.number-sign.rego"
+ },
+ {
+ "captures": {
+ "1": {
+ "name": "punctuation.definition.comment.rego"
+ }
+ },
+ "match": "(#).*$\\n?",
+ "name": "comment.line.number-sign.rego"
+ }
+ ]
+ },
+ "constant": {
+ "match": "\\b(?:true|false|null)\\b",
+ "name": "constant.language.rego"
+ },
+ "root-document": {
+ "match": "(?|<|<\\=|>\\=|\\+|-|\\*|%|/|\\||&",
+ "name": "keyword.operator.comparison.rego"
+ },
+ "assignment-operators": {
+ "match": ":\\=|\\=",
+ "name": "keyword.operator.assignment.rego"
+ },
+ "interpolated-string-double": {
+ "begin": "(\\$)(\")",
+ "beginCaptures": {
+ "1": {
+ "name": "punctuation.definition.template-expression.begin.rego"
+ },
+ "2": {
+ "name": "punctuation.definition.string.begin.rego"
+ }
+ },
+ "end": "\"",
+ "endCaptures": {
+ "0": {
+ "name": "punctuation.definition.string.end.rego"
+ }
+ },
+ "name": "string.template.rego",
+ "patterns": [
+ {
+ "include": "#interpolation-expression"
+ },
+ {
+ "include": "#string-escape"
+ },
+ {
+ "include": "#interpolation-escape"
+ },
+ {
+ "include": "#string-escape-invalid"
+ }
+ ]
+ },
+ "interpolated-string-raw": {
+ "begin": "(\\$)(`)",
+ "beginCaptures": {
+ "1": {
+ "name": "punctuation.definition.template-expression.begin.rego"
+ },
+ "2": {
+ "name": "punctuation.definition.string.begin.rego"
+ }
+ },
+ "end": "`",
+ "endCaptures": {
+ "0": {
+ "name": "punctuation.definition.string.end.rego"
+ }
+ },
+ "name": "string.template.rego",
+ "patterns": [
+ {
+ "include": "#interpolation-expression"
+ }
+ ]
+ },
+ "interpolation-expression": {
+ "begin": "(?Rego evaluator — no OPA installation required.
+
+## Policy contract
+
+These rules are Kosli-specific conventions, not OPA built-ins. Kosli queries `data.policy.*` to find them.
+
+
+ Every policy must declare `package policy`. Kosli queries `data.policy.allow` and `data.policy.violations` to read the result.
+
+
+
+ Must evaluate to a boolean. Kosli exits with code `0` when `true`, code `1` when `false`. Typically defined as:
+
+ ```rego
+ default allow = false
+
+ allow if {
+ count(violations) == 0
+ }
+ ```
+
+
+
+ Optional but recommended. A set of human-readable strings describing why the policy failed. Kosli displays these when `allow` is `false`. Each message should identify the offending resource and the reason.
+
+ ```rego
+ violations contains msg if {
+ # ... rule body ...
+ msg := sprintf("descriptive message about %v", [resource])
+ }
+ ```
+
+
+## Input data
+
+The data structure passed to the policy as `input` depends on which command you use.
+
+### `kosli evaluate trail` — single trail
+
+The policy receives `input.trail`, a single trail object.
+
+
+ The trail being evaluated.
+
+
+
+ The trail name (git commit SHA or custom name).
+
+
+
+ Compliance data for the trail.
+
+
+
+ Whether the trail is compliant against its flow template.
+
+
+
+ Compliance status string, e.g. `"COMPLIANT"` or `"INCOMPLIANT"`.
+
+
+
+ Map of attestation name → attestation status object. Each object contains the attestation's data, including type-specific fields enriched via `--attestations`. For example, a `pull-request` attestation includes a `pull_requests` array, each with an `approvers` array and a `url` string.
+
+
+
+ Map of artifact name → artifact status object. Each artifact has its own `attestations_statuses` map with the same structure as above.
+
+
+
+
+
+
+### `kosli evaluate trails` — multiple trails
+
+The policy receives `input.trails`, an array of trail objects with the same structure as `input.trail` above.
+
+
+ Array of trail objects. Each element has the same structure as `input.trail` described above.
+
+
+
+Use `--show-input` with `--output json` to print the full input structure for a given trail. Pipe through `jq` to explore specific fields:
+
+```shell
+kosli evaluate trail "$TRAIL_NAME" \
+ --policy my-policy.rego \
+ --org "$ORG" \
+ --flow "$FLOW" \
+ --show-input \
+ --output json 2>/dev/null | jq '.input'
+```
+
+
+## Exit codes
+
+| Code | Meaning |
+|------|---------|
+| `0` | Policy allowed (`allow = true`) |
+| `1` | Policy denied (`allow = false`) **or** command error (network failure, invalid Rego, policy file not found) |
+
+Exit code `1` is used for both denial and failure. To distinguish between them in CI, use `--output json` and read the `allow` field directly from the output rather than relying on the exit code.
+
+## Examples
+
+### Check pull request approvals across multiple trails
+
+```rego
+package policy
+
+import rego.v1
+
+default allow = false
+
+violations contains msg if {
+ some trail in input.trails
+ some pr in trail.compliance_status.attestations_statuses["pull-request"].pull_requests
+ count(pr.approvers) == 0
+ msg := sprintf("trail '%v': pull-request %v has no approvers", [trail.name, pr.url])
+}
+
+allow if {
+ count(violations) == 0
+}
+```
+
+### Check Snyk scan results on a single trail
+
+```rego
+package policy
+
+import rego.v1
+
+default allow = false
+
+violations contains msg if {
+ some name, artifact in input.trail.compliance_status.artifacts_statuses
+ snyk := artifact.attestations_statuses["snyk-container-scan"]
+ some result in snyk.processed_snyk_results.results
+ result.high_count > 0
+ msg := sprintf("artifact '%v': snyk scan found %d high severity vulnerabilities", [name, result.high_count])
+}
+
+allow if {
+ count(violations) == 0
+}
+```
+
+## Further reading
+
+- [Rego Style Guide](https://docs.styra.com/opa/rego-style-guide) — naming, rule structure, and test conventions
+- [OPA Annotations](https://www.openpolicyagent.org/docs/latest/annotations/) — including `entrypoint: true` for use with `opa build`
+- [OPA Best Practices](https://www.openpolicyagent.org/docs/latest/best-practices/)
+- [Tutorial: Evaluate trails with OPA policies](/tutorials/evaluate_trails_with_opa)
diff --git a/tutorials/evaluate_trails_with_opa.mdx b/tutorials/evaluate_trails_with_opa.mdx
new file mode 100644
index 0000000..5007165
--- /dev/null
+++ b/tutorials/evaluate_trails_with_opa.mdx
@@ -0,0 +1,286 @@
+---
+title: "Evaluate trails with OPA policies"
+description: "Learn how to use kosli evaluate trail and kosli evaluate trails to check your Kosli trails against custom OPA/Rego policies. This tutorial walks through writing a policy that verifies pull requests have been approved."
+---
+
+The `kosli evaluate` commands let you evaluate Kosli trails against custom policies written in [Rego](https://www.openpolicyagent.org/docs/latest/policy-language/). This is useful for enforcing rules like "every artifact must have an approved pull request" or "all security scans must pass", and for gating deployments in CI/CD pipelines based on those rules.
+
+In this tutorial, we'll write a policy that checks whether pull requests on a trail have been approved, then evaluate it against real trails in public Kosli orgs.
+
+
+
+
+
+To follow this tutorial, you need to:
+
+* [Install Kosli CLI](/getting_started/install).
+* [Get a Kosli API token](/getting_started/service-accounts).
+* Set the `KOSLI_API_TOKEN` environment variable to your token:
+
+ ```shell
+ export KOSLI_API_TOKEN=
+ ```
+
+
+You don't need OPA installed — the Kosli CLI has a built-in Rego evaluator. You just need to write a `.rego` policy file.
+
+
+
+
+
+
+Create a file called `pr-approved.rego` with the following content:
+
+```rego pr-approved.rego
+package policy
+
+import rego.v1
+
+default allow = false
+
+violations contains msg if {
+ some trail in input.trails
+ some pr in trail.compliance_status.attestations_statuses["pull-request"].pull_requests
+ count(pr.approvers) == 0
+ msg := sprintf("trail '%v': pull-request %v has no approvers", [trail.name, pr.url])
+}
+
+allow if {
+ count(violations) == 0
+}
+```
+
+Let's break down what this policy does:
+
+* **`package policy`** — every evaluate policy must use the `policy` package.
+* **`import rego.v1`** — use Rego v1 syntax (the `if`/`contains` keywords).
+* **`default allow = false`** — trails are denied unless explicitly allowed.
+* **`violations`** — a set of messages describing why the policy failed. The rule iterates over trails, then over pull requests within the `pull-request` attestation, looking for PRs where `approvers` is empty.
+* **`allow`** — trails are allowed only when there are no violations.
+
+
+See the [Rego Policy reference](/policy-reference/rego_policy) for the full policy contract, input data shape, and exit code behaviour.
+
+
+
+
+
+
+Let's evaluate several trails from the public `cyber-dojo` org against our policy. The `kosli evaluate trails` command fetches trail data from Kosli and passes it to the policy as `input.trails`:
+
+```shell
+kosli evaluate trails \
+ --policy pr-approved.rego \
+ --org cyber-dojo \
+ --flow dashboard-ci \
+ 9978a1ca82c273a68afaa85fc37dd60d1e394f84 \
+ b334d371eb85c9a5c811776de1b65fb80b52d952 \
+ 5abd63aa1d64af7be5b5900af974dc73ae425bd6 \
+ cb3ec71f5ce1103779009abaf4e8f8a3ed97d813
+```
+
+The cyber-dojo project doesn't require PR approvals, so you'll see violations:
+
+```plaintext
+RESULT: DENIED
+VIOLATIONS: trail '5abd63aa1d64af7be5b5900af974dc73ae425bd6': pull-request https://github.com/cyber-dojo/dashboard/pull/342 has no approvers
+ trail '9978a1ca82c273a68afaa85fc37dd60d1e394f84': pull-request https://github.com/cyber-dojo/dashboard/pull/344 has no approvers
+ trail 'b334d371eb85c9a5c811776de1b65fb80b52d952': pull-request https://github.com/cyber-dojo/dashboard/pull/343 has no approvers
+ trail 'cb3ec71f5ce1103779009abaf4e8f8a3ed97d813': pull-request https://github.com/cyber-dojo/dashboard/pull/341 has no approvers
+```
+
+Now try the `kosli-public` org, where PRs do have approvers:
+
+```shell
+kosli evaluate trails \
+ --policy pr-approved.rego \
+ --org kosli-public \
+ --flow cli \
+ 5a0f3c0 \
+ 167ed93 \
+ 030cc31
+```
+
+```plaintext
+RESULT: ALLOWED
+```
+
+
+
+
+
+The `kosli evaluate trail` (singular) command evaluates facts within a single trail, which is a different use case from comparing across multiple trails. For example, you might check that a snyk container scan found no high-severity vulnerabilities.
+
+Save this as `snyk-no-high-vulns.rego`:
+
+```rego
+package policy
+
+import rego.v1
+
+default allow = false
+
+violations contains msg if {
+ some name, artifact in input.trail.compliance_status.artifacts_statuses
+ snyk := artifact.attestations_statuses["snyk-container-scan"]
+ some result in snyk.processed_snyk_results.results
+ result.high_count > 0
+ msg := sprintf("artifact '%v': snyk container scan found %d high severity vulnerabilities", [name, result.high_count])
+}
+
+allow if {
+ count(violations) == 0
+}
+```
+
+This policy iterates over every artifact in the trail, looks up its `snyk-container-scan` attestation, and checks whether any result has a non-zero `high_count`.
+
+Use `--attestations` to enrich only the snyk data (faster than fetching all attestation details).
+The value uses the format `artifact-name.attestation-type`. Here, `dashboard` is the artifact name and `snyk-container-scan` is the attestation name:
+
+```shell
+kosli evaluate trail \
+ --policy snyk-no-high-vulns.rego \
+ --org cyber-dojo \
+ --flow dashboard-ci \
+ --attestations dashboard.snyk-container-scan \
+ 44ca5fa2630947cf375fdbda10972a4bedaaaba3
+```
+
+```plaintext
+RESULT: ALLOWED
+```
+
+The trail has zero high-severity vulnerabilities, so the policy allows it.
+
+
+The `input.trail` / `input.trails` distinction and the full input data shape are documented in the [Rego Policy reference](/policy-reference/rego_policy#input-data).
+
+
+
+
+
+
+When writing policies, it helps to see exactly what data is available. Use `--show-input` combined with `--output json` to see the full input that gets passed to the policy:
+
+```shell
+kosli evaluate trail \
+ --policy snyk-no-high-vulns.rego \
+ --org cyber-dojo \
+ --flow dashboard-ci \
+ --attestations dashboard.snyk-container-scan \
+ --show-input \
+ --output json \
+ 44ca5fa2630947cf375fdbda10972a4bedaaaba3
+```
+
+This outputs the evaluation result along with the complete `input` object. You can pipe it through `jq` to explore the structure:
+
+```shell
+kosli evaluate trail \
+ --policy snyk-no-high-vulns.rego \
+ --org cyber-dojo \
+ --flow dashboard-ci \
+ --attestations dashboard.snyk-container-scan \
+ --show-input \
+ --output json \
+ 44ca5fa2630947cf375fdbda10972a4bedaaaba3 2>/dev/null | jq '.input.trail.compliance_status | keys'
+```
+
+```plaintext
+[
+ "artifacts_statuses",
+ "attestations_statuses",
+ "evaluated_at",
+ "flow_template_id",
+ "is_compliant",
+ "status"
+]
+```
+
+
+Use the `--attestations` flag to limit which attestations are enriched with full detail. The flag filters by **attestation name** (not type). For example, `--attestations pull-request` fetches only details for attestations named `pull-request`, which speeds up evaluation and reduces noise when exploring the input.
+
+
+
+
+
+
+The `kosli evaluate` commands exit with `0` on allow and `1` on deny or error — making them straightforward to use as pipeline gates. See the [Rego Policy reference](/policy-reference/rego_policy#exit-codes) for details on distinguishing denial from command failure.
+
+
+
+```shell
+# Example: gate a deployment on policy evaluation
+if kosli evaluate trail \
+ --policy policies/pr-approved.rego \
+ --org "$KOSLI_ORG" \
+ --flow "$FLOW_NAME" \
+ "$GIT_COMMIT"; then
+ echo "Policy passed — proceeding with deployment"
+ # ... deploy commands ...
+else
+ echo "Policy denied — blocking deployment"
+ exit 1
+fi
+```
+
+This pattern lets you enforce custom compliance rules as part of your delivery pipeline, using the same trail data that Kosli already collects.
+
+
+
+
+
+After evaluating a trail, you can record the result as an attestation. This creates an audit record in Kosli that captures the policy, the full evaluation report, and any violations.
+
+This step requires write access to your Kosli org. The examples below use variables you'd set in your CI/CD pipeline. In your own pipeline you'd use your own policy file — here we use `my-policy.rego` as a placeholder:
+
+```shell
+# Run the evaluation and save the full JSON report to a file
+# (|| true prevents the step from failing when the policy denies)
+kosli evaluate trail "$TRAIL_NAME" \
+ --policy my-policy.rego \
+ --org "$KOSLI_ORG" \
+ --flow "$FLOW_NAME" \
+ --show-input \
+ --output json > eval-report.json 2>/dev/null || true
+
+# Read the allow/deny result from the report
+is_compliant=$(jq -r '.allow' eval-report.json)
+
+# Extract violations as structured user-data
+jq '{violations: .violations}' eval-report.json > eval-violations.json
+
+# Attest the result
+kosli attest generic \
+ --name opa-evaluation \
+ --flow "$FLOW_NAME" \
+ --trail "$TRAIL_NAME" \
+ --org "$KOSLI_ORG" \
+ --compliant="$is_compliant" \
+ --attachments my-policy.rego,eval-report.json \
+ --user-data eval-violations.json
+```
+
+This creates a generic attestation on the trail with:
+
+* **`--compliant`** set based on whether the policy allowed or denied — read directly from the JSON report rather than relying on the exit code, which avoids issues with `set -e` in CI environments like GitHub Actions
+* **`--attachments`** containing the Rego policy (for reproducibility) and the full JSON evaluation report (including the input data the policy evaluated)
+* **`--user-data`** containing the violations, which appear in the Kosli UI as structured metadata on the attestation
+
+
+Use `--compliant=value` (with `=`) not `--compliant value` (with a space). Boolean flags in Kosli CLI require the `=` syntax when passing `false` — otherwise `false` is interpreted as a positional argument.
+
+
+
+
+
+
+## What you've accomplished
+
+You have written OPA/Rego policies and evaluated Kosli trails against them, both across multiple trails and within a single trail. You've also recorded evaluation results as attestations, creating a tamper-proof audit record of every policy decision linked to a specific trail.
+
+From here you can:
+* Explore evaluated trails in the [Kosli app](https://app.kosli.com)
+* Gate deployments in CI/CD pipelines using `kosli evaluate trail` exit codes
+* Extend your policies to check other attestation types. See [`kosli evaluate trail`](/client_reference/kosli_evaluate_trail) and [`kosli evaluate trails`](/client_reference/kosli_evaluate_trails) for the full flag reference