diff --git a/config/navigation.json b/config/navigation.json index e2f857e..cdb5f2b 100644 --- a/config/navigation.json +++ b/config/navigation.json @@ -126,7 +126,8 @@ { "group": "Evaluation", "pages": [ - "tutorials/evaluate_trails_with_opa" + "tutorials/evaluate_trails_with_opa", + "tutorials/evaluate_policies_server_side" ] }, { diff --git a/images/tutorials/policy-decision-detail.png b/images/tutorials/policy-decision-detail.png new file mode 100644 index 0000000..dfded4c Binary files /dev/null and b/images/tutorials/policy-decision-detail.png differ diff --git a/images/tutorials/policy-decisions.png b/images/tutorials/policy-decisions.png new file mode 100644 index 0000000..c63cf71 Binary files /dev/null and b/images/tutorials/policy-decisions.png differ diff --git a/images/tutorials/policy-list.png b/images/tutorials/policy-list.png new file mode 100644 index 0000000..ea5d1b8 Binary files /dev/null and b/images/tutorials/policy-list.png differ diff --git a/images/tutorials/policy-source.png b/images/tutorials/policy-source.png new file mode 100644 index 0000000..d194097 Binary files /dev/null and b/images/tutorials/policy-source.png differ diff --git a/mockups/policies-ui/.gitignore b/mockups/policies-ui/.gitignore new file mode 100644 index 0000000..f3f494d --- /dev/null +++ b/mockups/policies-ui/.gitignore @@ -0,0 +1,5 @@ +# generated by build.py + Chrome headless +# NB: the stylesheet must NOT be committed -- Mintlify auto-applies any .css in the project +kosli-mock.css +policy-*.html +policy-*.png diff --git a/mockups/policies-ui/README.md b/mockups/policies-ui/README.md new file mode 100644 index 0000000..0dea1c8 --- /dev/null +++ b/mockups/policies-ui/README.md @@ -0,0 +1,25 @@ +# Proposed Policies UI — wireframe mockups + +Sources for the mockup images used by +[`tutorials/evaluate_policies_server_side.mdx`](../../tutorials/evaluate_policies_server_side.mdx). +They depict a **proposed** UI that does not exist yet, so every rendered page carries a +"Mockup · proposed design" marker. + +Chrome and palette were matched against the real Controls screenshots in +`images/tutorials/controls-*.png` (sidebar `#101c2b`, primary `#1e55cd`, compliant `#3ca36f`, +non-compliant `#ce4a3e`). `logo.png` / `logo-footer.png` are crops of the real logo with the ICC +profile stripped, so they blend with the CSS colors. + +## Regenerate + +```bash +python3 build.py # also writes kosli-mock.css (gitignored) +for p in policy-list policy-source policy-decisions policy-decision-detail; do + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \ + --headless=new --disable-gpu --hide-scrollbars --force-device-scale-factor=1 \ + --window-size=1535,1011 --screenshot="$p.png" "file://$PWD/$p.html" +done +cp policy-*.png ../../images/tutorials/ +``` + +1535x1011 at 1x matches the existing Controls screenshots. diff --git a/mockups/policies-ui/_chrome.html b/mockups/policies-ui/_chrome.html new file mode 100644 index 0000000..a9f0568 --- /dev/null +++ b/mockups/policies-ui/_chrome.html @@ -0,0 +1,24 @@ + diff --git a/mockups/policies-ui/_footer.html b/mockups/policies-ui/_footer.html new file mode 100644 index 0000000..d1c7831 --- /dev/null +++ b/mockups/policies-ui/_footer.html @@ -0,0 +1,6 @@ + diff --git a/mockups/policies-ui/_topbar.html b/mockups/policies-ui/_topbar.html new file mode 100644 index 0000000..221ce3c --- /dev/null +++ b/mockups/policies-ui/_topbar.html @@ -0,0 +1,5 @@ +
+
CRUMB
+ +
+
diff --git a/mockups/policies-ui/build.py b/mockups/policies-ui/build.py new file mode 100644 index 0000000..09aa301 --- /dev/null +++ b/mockups/policies-ui/build.py @@ -0,0 +1,464 @@ +#!/usr/bin/env python3 +"""Assemble the proposed-Policies-UI wireframe mockups from shared partials.""" +import pathlib, re + +HERE = pathlib.Path(__file__).parent + +CSS = r"""/* Wireframe mockup chrome for proposed Kosli Policies UI. + Palette sampled from real screenshots in images/tutorials/controls-*.png */ +* { box-sizing: border-box; margin: 0; padding: 0; } +:root { + --navy: #101c2b; + --ink: #14263a; + --grey: #6b7280; + --line: #e6e7e9; + --line-soft: #eef0f2; + --band: #f7f9fc; + --topbar: #f2f2f6; + --blue: #1e55cd; + --green: #3ca36f; + --red: #ce4a3e; + --chip: #eceef2; +} +body { + width: 1535px; min-height: 1011px; display: flex; + font: 400 15px/1.45 -apple-system, "Segoe UI", Inter, Helvetica, Arial, sans-serif; + color: var(--ink); background: #fcfcff; -webkit-font-smoothing: antialiased; +} + +/* ---------- sidebar ---------- */ +.sidebar { width: 250px; flex: 0 0 250px; background: var(--navy); padding: 16px 0 0; } +.logo { display: block; width: 130px; height: 48px; margin: 0 0 18px 10px; } +.org { display: flex; align-items: center; gap: 10px; color: #fff; font-weight: 600; + padding: 0 24px 0 20px; height: 34px; } +.org .home { opacity: .85; } +.org .name { flex: 1; } +.org .chev { opacity: .7; } +hr.sep { border: 0; border-top: 1px solid rgba(255,255,255,.16); margin: 14px 24px 12px; } +nav a { display: flex; align-items: center; gap: 14px; padding: 9px 20px; + color: #b9c2cd; text-decoration: none; font-size: 15px; } +nav a.on { color: #fff; font-weight: 600; } +nav svg { width: 19px; height: 19px; flex: 0 0 19px; } + +/* ---------- top bar ---------- */ +.main { flex: 1; min-width: 0; display: flex; flex-direction: column; } +.topbar { height: 51px; background: var(--topbar); display: flex; align-items: center; + padding: 0 22px 0 36px; gap: 14px; } +.crumb { font-size: 13.5px; color: var(--grey); flex: 1; } +.crumb b { color: var(--ink); } +.search { width: 278px; height: 32px; border: 1px solid #d8dbe0; border-radius: 999px; + background: #fff; display: flex; align-items: center; gap: 8px; padding: 0 14px; + font-size: 13px; color: #9aa1ab; } +.avatar { display: flex; align-items: center; gap: 5px; color: #5b6472; } +.avatar .ring { width: 26px; height: 26px; border-radius: 50%; background: #dfe3e9; + display: grid; place-items: center; } + +/* ---------- page ---------- */ +.page { flex: 1; padding: 26px 36px 0; } +h1 { font-size: 34px; font-weight: 700; letter-spacing: -.4px; line-height: 1.1; } +.h1row { display: flex; align-items: flex-start; gap: 12px; } +.sub { font-size: 16px; font-weight: 600; margin-top: 9px; } +.desc { font-size: 13.5px; color: var(--grey); margin-top: 6px; max-width: 1030px; } +.spacer { flex: 1; } +.toolrow { display: flex; align-items: center; gap: 10px; margin: 18px 0 22px; } + +/* ---------- controls ---------- */ +.field { width: 240px; height: 34px; border: 1px solid #d8dbe0; border-radius: 7px; + background: #fff; display: flex; align-items: center; gap: 8px; padding: 0 11px; + font-size: 13px; color: #9aa1ab; } +.btn { height: 34px; padding: 0 15px; border-radius: 7px; border: 1px solid #d8dbe0; + background: #fff; color: #374151; font-size: 13.5px; font-weight: 500; + display: inline-flex; align-items: center; gap: 7px; } +.btn.pri { background: var(--blue); border-color: var(--blue); color: #fff; font-weight: 600; } +.btn.danger { border-color: #ffdad5; color: #b4433a; } +.btn.sm { height: 27px; padding: 0 10px; font-size: 12.5px; border-radius: 6px; } + +.mock { margin-left: auto; align-self: flex-start; background: #fff7e6; border: 1px solid #f0d18a; + color: #8a6212; border-radius: 6px; font-size: 11.5px; font-weight: 600; + padding: 5px 10px; letter-spacing: .2px; white-space: nowrap; } + +/* ---------- tabs ---------- */ +.tabs { display: flex; gap: 4px; margin-top: 20px; } +.tab { display: flex; align-items: center; gap: 8px; padding: 9px 16px; font-size: 14px; + color: var(--grey); border: 1px solid transparent; border-radius: 8px 8px 0 0; } +.tab.on { background: #fff; border-color: var(--line); border-bottom-color: #fff; + color: var(--ink); font-weight: 600; } + +/* ---------- card ---------- */ +.card { border: 1px solid var(--line); border-radius: 10px; background: #fff; overflow: hidden; } +.card + .card { margin-top: 18px; } +.chead { background: var(--band); border-bottom: 1px solid var(--line); + display: flex; align-items: center; gap: 11px; padding: 13px 18px; font-size: 14.5px; + font-weight: 600; } +.count { background: var(--chip); color: #4b5563; border-radius: 6px; font-size: 12px; + font-weight: 600; padding: 2px 9px; } +.ctab { font-weight: 400; color: var(--grey); font-size: 14px; margin-left: 14px; } +.filters { margin-left: auto; display: flex; gap: 18px; font-size: 13px; color: #4b5563; + font-weight: 400; } +.row { display: flex; align-items: center; gap: 14px; padding: 14px 18px; + border-bottom: 1px solid var(--line-soft); } +.row:last-child { border-bottom: 0; } +.row .grow { flex: 1; min-width: 0; } +.id { font-size: 15px; font-weight: 700; } +.badge { display: inline-block; border: 1px solid #e2e4e8; background: #f7f8fa; color: #4b5563; + border-radius: 4px; font-size: 11px; font-weight: 600; padding: 1px 6px; + vertical-align: 2px; } +.name { font-weight: 400; } +.line2 { font-size: 13px; color: var(--grey); margin-top: 5px; } +.tags { display: flex; gap: 6px; margin-top: 8px; } +.tag { background: #eef2f7; color: #44526a; border-radius: 4px; font-size: 11px; + padding: 2px 8px; } +.kebab { color: #9aa1ab; font-size: 17px; line-height: 1; } +.pill { border-radius: 999px; color: #fff; font-size: 12.5px; font-weight: 600; + padding: 6px 15px 6px 11px; display: inline-flex; align-items: center; gap: 7px; + white-space: nowrap; } +.pill.ok { background: var(--green); } +.pill.no { background: var(--red); } +.pill .dot { width: 15px; height: 15px; border-radius: 50%; background: rgba(255,255,255,.28); + display: grid; place-items: center; font-size: 10px; } +.mono { font: 12.5px/1.55 ui-monospace, SFMono-Regular, Menlo, monospace; } +.hint { font-size: 13px; color: var(--grey); margin-top: 14px; } +.hint code { font: 12.5px ui-monospace, Menlo, monospace; background: #f3f4f6; + border-radius: 4px; padding: 1px 5px; } + +/* ---------- footer ---------- */ +footer { display: flex; align-items: center; gap: 18px; padding: 20px 36px 22px; + font-size: 12.5px; color: #4b5563; } +footer .gen { background: var(--chip); color: #6b7280; border-radius: 5px; font-size: 11px; + padding: 3px 9px; } +footer img { width: 82px; height: 34px; } +footer .links { display: flex; gap: 15px; color: var(--blue); } + +/* ---------- detail tray ---------- */ +.scrim { position: fixed; inset: 0; background: rgba(0,0,0,.5); } +.tray { position: fixed; top: 0; right: 0; bottom: 0; width: 843px; background: #fff; + display: flex; flex-direction: column; } +.thead { padding: 30px 32px 20px; } +.trow1 { display: flex; align-items: center; gap: 16px; } +.trow1 h2 { font-size: 30px; font-weight: 700; letter-spacing: -.3px; } +.close { margin-left: auto; color: #6b7280; font-size: 22px; line-height: 1; } +.tmeta { display: flex; align-items: center; gap: 12px; margin-top: 13px; font-size: 13.5px; + color: #4b5563; } +.kv { margin: 0 32px; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; } +.kv > div { display: flex; border-bottom: 1px solid var(--line); } +.kv > div:last-child { border-bottom: 0; } +.kv dt { flex: 0 0 168px; background: var(--band); border-right: 1px solid var(--line); + padding: 13px 15px; font-size: 13.5px; color: var(--grey); } +.kv dd { flex: 1; min-width: 0; padding: 13px 15px; font-size: 13.5px; font-weight: 600; + word-break: break-all; } +.kv dd.plain { font-weight: 400; } +.kv dd ul { margin: 0; padding-left: 18px; font-weight: 400; } +.kv dd li + li { margin-top: 5px; } + +/* ---------- source view ---------- */ +.srcmeta { display: flex; gap: 26px; padding: 13px 18px; border-bottom: 1px solid var(--line-soft); + font-size: 13px; color: var(--grey); } +.srcmeta b { color: var(--ink); font-weight: 600; } +.src { display: flex; font: 12.5px/1.75 ui-monospace, SFMono-Regular, Menlo, monospace; } +.gutter { flex: 0 0 46px; text-align: right; padding: 14px 12px 16px 0; color: #b6bcc5; + background: #fcfcfd; border-right: 1px solid var(--line-soft); user-select: none; } +.code { flex: 1; padding: 14px 0 16px 16px; white-space: pre; overflow: hidden; color: var(--ink); } +.k { color: #8250df; } +.s { color: #0a6e5c; } +.vsel { display: inline-flex; align-items: center; gap: 7px; border: 1px solid #d8dbe0; + background: #fff; border-radius: 6px; padding: 3px 9px; font-size: 12.5px; + font-weight: 500; color: #374151; } + +/* ---------- bundle: file tree + viewer ---------- */ +.bundle { display: flex; align-items: stretch; } +.files { flex: 0 0 252px; border-right: 1px solid var(--line); background: #fcfcfd; + padding: 14px 0 16px; } +.files .cap { font-size: 11px; font-weight: 700; letter-spacing: .6px; color: #9aa1ab; + padding: 0 16px 10px; } +.fi { display: flex; align-items: center; gap: 7px; padding: 6px 16px; font-size: 13px; + color: #374151; border-left: 2px solid transparent; } +.fi.sel { background: #eef3fd; border-left-color: var(--blue); color: var(--blue); + font-weight: 600; } +.fi.nest { padding-left: 34px; } +.fi .flag { margin-left: auto; font-size: 10.5px; color: #9aa1ab; font-weight: 400; } +.dir { padding: 7px 16px 3px; font-size: 13px; color: #6b7280; } +.files .tot { border-top: 1px solid var(--line-soft); margin-top: 12px; padding: 11px 16px 0; + font-size: 12px; color: #9aa1ab; } +.viewer { flex: 1; min-width: 0; } +.vhead { display: flex; align-items: baseline; gap: 12px; padding: 13px 18px; + border-bottom: 1px solid var(--line-soft); font-size: 13.5px; } +.vhead .fn { font-weight: 700; font-size: 14px; } +.vhead .ep { background: #eef3fd; color: var(--blue); border-radius: 4px; font-size: 11px; + font-weight: 600; padding: 2px 7px; } +.vhead .meta { margin-left: auto; color: var(--grey); font-size: 12.5px; } +""" + +(HERE / "kosli-mock.css").write_text(CSS) +chrome = (HERE / "_chrome.html").read_text() +topbar = (HERE / "_topbar.html").read_text() +footer = (HERE / "_footer.html").read_text() + +ICON = ('' + '') +CHEV = ' ▾' +TICK = '' +CROSS = '' + + +def shell(crumb, body, title): + return f"""{title} + +{chrome} +
+{topbar.replace('CRUMB', crumb)} +{body} +{footer} +
""" + + +POLICIES = [ + ("pr-approved", "V1", "Pull request approved", + "Every pull request on the trail has at least one approver.", + ["team=platform", "framework=soc2"]), + ("no-hardcoded-secrets", "V3", "No hard-coded credentials", + "No attested source file matches a known credential pattern.", + ["team=platform", "framework=soc2"]), + ("vuln-scan-clean", "V2", "Vulnerability scan clean", + "The Snyk attestation reports no critical or high findings.", + ["team=security"]), + ("test-evidence-present", "V1", "Test evidence present", + "A JUnit attestation exists on the trail and reports zero failures.", + ["team=quality"]), +] + +DECISIONS = [ + ("14:02:11", "14:02:19", "release-99", "nginx:665d6dd", True), + ("14:00:03", "14:00:11", "release-98", "nginx:665d6dd", True), + ("13:41:55", "13:42:02", "release-97", "nginx:a6e433a", False), + ("13:20:47", "13:20:55", "release-96", "nginx:a6e433a", True), + ("12:58:12", "12:58:19", "release-95", "dashboard:26c6997", True), + ("12:31:09", "12:31:16", "release-94", "dashboard:26c6997", True), + ("11:57:44", "11:57:51", "release-93", "languages-start-points:ca386e0", False), + ("11:12:38", "11:12:46", "release-92", "languages-start-points:ca386e0", True), +] +ECR = "244531986313.dkr.ecr.eu-central-1.amazonaws.com" + + +def policy_rows(): + out = [] + for ident, ver, name, desc, tags in POLICIES: + pills = "".join(f'{t}' for t in tags) + out.append(f"""
+
+ {ident} {ver} +  {name} +
{desc}
+
{pills}
+
+ +
""") + return "\n".join(out) + + +def decision_rows(): + out = [] + for req, rec, trail, art, ok in DECISIONS: + pill = (f'{TICK}Compliant' if ok + else f'{CROSS}Non-compliant') + out.append(f"""
+
+ 2026-07-27 • {req} +  requested · recorded {rec} +   my-release-flow / {trail} +
{ECR}/{art}
+
+ V1 + {pill} +
""") + return "\n".join(out) + + +# ---------------------------------------------------------------- page 1: list +list_body = f"""
+

Policies

+ Mockup • proposed design
+
+
+ Search by name or identifier
+
+
+
{ICON}Evaluation
+
Environment
+
+
+
{ICON}Policies 4 + Archived + Tags{CHEV}Sort{CHEV} +
+{policy_rows()} +
+
Policies are created and updated with the CLI or API + (kosli create policy --type rego). In the beta the UI is read-only, + apart from tagging.
+
""" + +# ----------------------------------------------------------- page 2: decisions +detail_head = f"""

pr-approved

V1 + + + Mockup • proposed design +
+
Pull request approved
+
Every pull request on the trail has at least one approver.
+
+ team=platformframework=soc2 + +
+
+
{ICON}Policy
+
{ICON}Decisions
+
{ICON}Versions
+
""" + +head_decisions = detail_head.replace("TAB_POLICY", "").replace("TAB_DECISIONS", "on") +head_source = detail_head.replace("TAB_POLICY", "on").replace("TAB_DECISIONS", "") + +decisions_card = f"""
+
{ICON}Decisions 128 + Timestamp{CHEV}Compliance{CHEV} + Version{CHEV}Sort{CHEV} + +
+{decision_rows()} +
""" + +decisions_body = f'
\n{head_decisions}\n{decisions_card}\n
' + +# ------------------------------------------------------ page 3: decision detail +tray = f"""
+
+
+

pr-approval-decision

+ {CROSS}Non-compliant +
+
V1 + requested 2026-07-27 • 13:41:55 + ·recorded 13:42:02 + Mockup • proposed design
+
+
+
Evaluation context
evc_7f31a92c +  view
+
Control
RCTL-043 + — Source code review
+
Trail
my-release-flow / release-97
+
Artifact
{ECR}/nginx:a6e433a
+ sha256:a6e433a6fd3eb29c499b75310756420864b6c346
+
Policy
pr-approved V1
+ digest + sha256:9f2c1d84e0b7a35c6f1e8d2b47a90c3518fe64d7
+
Parameters
{{ "pr_attestation_name": "pull-request" }}
+
Evaluation engine
opa 1.18.2
+
Violations
    +
  • pull-request https://github.com/cyber-dojo/nginx/pull/42 has no approvers
  • +
  • pull-request https://github.com/cyber-dojo/nginx/pull/45 has no approvers
  • +
+
Evaluation report
+
+
""" + + +# ------------------------------------------------------- page 4: policy source +REGO = """package policy + +import rego.v1 +import data.lib.approvals + +# METADATA +# scope: document +# schemas: +# - input: schema["trail-v1"] +default allow = false + +allow if { + approvals.all_approved(input.trail, data.params.pr_attestation_name) +} + +violations contains msg if { + some pr in approvals.unapproved(input.trail, data.params.pr_attestation_name) + msg := sprintf("pull-request %v has no approvers", [pr.url]) +}""" + +KEYWORDS = ("package", "import", "default", "allow", "violations", "contains", + "every", "some", "if", "in") + + +def highlight(src): + out = [] + for line in src.split("\n"): + line = (line.replace("&", "&").replace("<", "<").replace(">", ">")) + line = re.sub(r'"([^"]*)"', lambda m: f'"{m.group(1)}"', line) + line = re.sub(r'\b(' + "|".join(KEYWORDS) + r')\b', + lambda m: f'{m.group(1)}', line) + out.append(line) + return "\n".join(out) + + +nlines = len(REGO.split("\n")) +gutter = "
".join(str(i) for i in range(1, nlines + 1)) + +FILES = """
+
FILES
+
policy.rego
+
lib/
+
approvals.rego
+
severity.rego
+
policy_test.regonot executed
+
4 files · 110 lines
+
""" + +source_card = f"""
+
{ICON}Policy bundle + V1 · current{CHEV} + + +
+
+ Bundle sha256:9f2c1d84… + roots policy + entrypoint policy.allow + schema trail + params read pr_attestation_name + published 2026-07-20 by alex@kosli.com +
+
+{FILES} +
+
policy.rego + entrypoint + sha256:9f2c1d84… · {nlines} lines
+
{gutter}
{highlight(REGO)}
+
+
+
+
The bundle is read-only here. Edit it in your repository and publish a new + version with kosli create policy — the version shown is the one that runs + when an evaluation names this policy. Tests are stored with the bundle for audit but are not + executed server-side.
""" + +source_body = f'
\n{head_source}\n{source_card}\n
' + +PAGES = { + "policy-list.html": ("cyber-dojo / policies", list_body, "Policies", ""), + "policy-decisions.html": ("cyber-dojo / policies / pr-approved", + decisions_body, "pr-approved", ""), + "policy-decision-detail.html": ("cyber-dojo / policies / pr-approved", + decisions_body, "pr-approved decision", tray), + "policy-source.html": ("cyber-dojo / policies / pr-approved", + source_body, "pr-approved policy", ""), +} + +for fn, (crumb, body, title, extra) in PAGES.items(): + html = shell(crumb, body, title) + if extra: + html = html.replace("", extra + "\n") + (HERE / fn).write_text(html) + print("wrote", fn) diff --git a/mockups/policies-ui/logo-footer.png b/mockups/policies-ui/logo-footer.png new file mode 100644 index 0000000..1709f8b Binary files /dev/null and b/mockups/policies-ui/logo-footer.png differ diff --git a/mockups/policies-ui/logo.png b/mockups/policies-ui/logo.png new file mode 100644 index 0000000..a290c35 Binary files /dev/null and b/mockups/policies-ui/logo.png differ diff --git a/tutorials/evaluate_policies_server_side.mdx b/tutorials/evaluate_policies_server_side.mdx new file mode 100644 index 0000000..9b885c9 --- /dev/null +++ b/tutorials/evaluate_policies_server_side.mdx @@ -0,0 +1,1036 @@ +--- +title: "Evaluate policies server-side" +description: "Proposed beta design: ship a versioned policy to Kosli, then evaluate it server-side to record a trustworthy, replicable decision." +--- + + +**Proposed design — not yet available.** This page describes planned beta behavior for +server-side policy evaluation and is published to gather feedback *before* the feature is +built. The commands, flags, and output shown are illustrative and will change as the design +firms up. Nothing here can be run against Kosli today. Share comments with the Kosli team. + + +Today, `kosli evaluate` runs **client-side**: the policy lives in your repository, the check +runs on your CI runner, and Kosli only learns the outcome if you **explicitly attest it back** — +a self-reported result Kosli takes on trust. Server-side policy evaluation moves the Policy +Decision Point (PDP) **into Kosli**. You ship a versioned policy to Kosli, Kosli executes it +against facts it already holds, and Kosli **records the decision itself** — one it can vouch for, +connected to the exact policy version and inputs used. + +## Start here + +### The whole loop in two commands + +Publish a policy, then evaluate it. That is the feature: + +```shell +# Publish a versioned policy into Kosli, once. +kosli create policy pr-approved pr-approved.rego \ + --type rego \ + --input-schema trail \ + --name "Pull request approved" + +# Evaluate it from your pipeline, as often as you like. +kosli evaluate policy \ + --flow my-release-flow \ + --trail my-release-trail \ + --policy pr-approved \ + --control RCTL-043 \ + --context trail=my-release-trail \ + --fingerprint "$ARTIFACT_FINGERPRINT" \ + --name pr-approval-decision \ + --params '{"pr_attestation_name": "pull-request"}' \ + --assert +``` + +Kosli evaluates the attestations on `my-release-trail` and records a **decision** against control +`RCTL-043`, citing the policy version, the parameters, and the exact facts it used. `--flow` and +`--trail` say where the decision is recorded, as on any other Kosli command, and `--context` says +which trail's facts to evaluate — the same trail here, a different one when you need it. `--assert` +fails the pipeline step if the policy denies. Everything else on this page is detail around those +two commands. + +### What is in the beta, and what is not + +**In:** publishing versioned policies, evaluating them against a trail or trails, recording decisions +against controls, validating and testing policies, and reviewing or exporting the decisions a policy +has produced. + +**Not in:** evaluating anything other than trails, evaluating the contents of attachments, composing +several policies into one, scheduled evaluations, pinning an evaluation to a policy version, and +advisory-only outcomes. [Full list, with reasons](#out-of-scope-for-the-beta) — worth reading +before you send feedback, so you don't spend it on something already deferred. + +### Where to go next + + + + +For **governance platform engineers**. The core loop, the Rego contract, testing, and managing +policies over time. + + + +For **auditors and control owners**. Review, filter, sample, and export decisions — and re-run one +yourself to check it. + + + +For **application teams**. Understand why a policy denied and what to do about it. + + + + + +This is distinct from the existing client-side +[Evaluate trails with OPA policies](/tutorials/evaluate_trails_with_opa) tutorial, where the +CLI runs the policy locally. Here, Kosli stores the policy and performs the evaluation. + + +## Why server-side + +Running evaluation inside Kosli changes what a decision means — and where the data is processed: + +| | Client-side `kosli evaluate trail` (today) | Server-side `kosli evaluate policy` (proposed) | +|---|---|---| +| **Where the policy lives** | Your repository | Stored and versioned in Kosli | +| **Where it runs** | Your CI runner | Kosli (isolated from the API) | +| **What Kosli stores** | Whatever you attest back (self-reported) | A decision Kosli computed and can vouch for | +| **Data movement** | Full trail evidence is fetched to the runner | Evaluated in place — nothing shipped to the client | +| **Replicability** | Depends on your pipeline | Decision cites its exact policy version and inputs | + +Because Kosli both stores the policy and runs it, a decision points at *exactly* what ran — the +policy version and its content digest, its parameters, and the trail facts that were in scope. The +record is complete enough to **reconstruct the evaluation**, which is what makes the decision +trustworthy for audit: you can take a decision, export what it was given, and run the policy again +yourself — see [Re-run a decision offline](#re-run-a-decision-offline). + +Running evaluation next to the data is also faster and scales further: Kosli doesn't have to +ship a trail's full evidence out to your runner before evaluating, so evaluations can cover much +larger volumes of facts in the cloud. + +## Prerequisites + + +These prerequisites describe the proposed beta experience — they are not runnable today. + + +- [Install Kosli CLI](/getting_started/install). +- [Get a Kosli API token](/getting_started/authenticating_to_kosli). +- Have at least one [Control](/tutorials/working_with_controls) defined, and a + [Flow](/getting_started/flows) with a [Trail](/getting_started/trails) carrying the + attestations you want to evaluate. + +```shell +export KOSLI_ORG= +export KOSLI_API_TOKEN= +``` + +## Author, ship, and evaluate a policy + +The core loop takes a policy from local Rego to a recorded decision. The later sections build on +it — [validating](#validate-and-test-a-policy) and [managing](#manage-policies) policies, and +[viewing](#view-the-decisions-for-a-policy) and [inspecting](#inspect-a-failing-evaluation) the +decisions they produce. + + + + + +Author your policy as code in your own repository. A policy is its **policy-as-code** (Rego), a +human-readable **name**, a short **description** of its purpose, and an **input schema** +declaring whether it evaluates a single trail or multiple trails. You give it a stable +**identifier** when you publish it to Kosli (next step). + +Your repository stays authoritative for **authoring** a policy — it is where the Rego is written, +reviewed, and tested. Kosli is authoritative for **what executed**: once a version is published, +that stored version is what runs and what a decision cites, so the audit record never depends on +your repository still being reachable or unchanged. + +The output schema requires an `allow` rule; you can return richer output alongside it. Follow +the same fail-safe design rules as client-side policies — see +[Evaluate trails with OPA policies](/tutorials/evaluate_trails_with_opa) for the +reasoning behind them. The full contract a policy is written against — entry point, `input`, +parameters, available built-ins, and output — is in +[The Rego authoring contract](#the-rego-authoring-contract). + +```rego pr-approved.rego +package policy + +import rego.v1 + +pr_attestation_name := data.params.pr_attestation_name + +default allow := false + +allow if { + every pr in input.trail.compliance_status.attestations_statuses[pr_attestation_name].pull_requests { + count(pr.approvers) > 0 + } +} + +violations contains msg if { + some pr in input.trail.compliance_status.attestations_statuses[pr_attestation_name].pull_requests + count(pr.approvers) == 0 + msg := sprintf("pull-request %v has no approvers", [pr.url]) +} + +violations contains msg if { + not input.trail.compliance_status.attestations_statuses[pr_attestation_name] + msg := sprintf("expected attestation %v is missing from the trail", [pr_attestation_name]) +} +``` + +Note the second `violations` rule. Without it, a trail that is missing the attestation entirely +leaves both `allow` and the first `violations` rule undefined — a denial with an empty violations +list and nothing for the team to act on. Cover the missing-fact case explicitly, so every way the +policy can deny produces something to fix. + +Before you ship it, you can [validate and test the policy](#validate-and-test-a-policy) against +real trail data. + + +In the beta, a policy evaluates the facts on the trail(s) you point it at — attestations on +`trail` / `trails`. Evaluating other targets (snapshots, artifacts, generic input) and +composing multiple policies into one are **not included** (see [What's out of scope](#out-of-scope-for-the-beta)). + + + + + + +Publish the policy into Kosli via the CLI (or API). The positional argument is the policy's +**stable identifier** (`pr-approved` in the example below) — this is what decisions and evaluations +use to refer to the policy, so it is **immutable once created**; choose one that matches how your +organization names its policies. `--name` and `--description` are the human-readable label and +purpose, and both can be changed later without changing the identifier. + +Pass a single `.rego` file, as in the example below, or a directory of them when a policy is split +into packages — Kosli stores the whole set as one versioned bundle with one digest, so a decision +still points at one unambiguous thing. Tests are stored with the bundle but never executed +server-side. + +Kosli records the first **version** on publish. Every later change creates a new version and +records who made it, so a decision can always point back to the exact policy that produced it — +see [Manage policies](#manage-policies) for updating, tagging, and archiving over time. + +**Only org admins can publish a policy.** Creating, updating, and archiving a policy all require +org admin, the same as [controls](/tutorials/working_with_controls#creating-a-control). Running an +evaluation and recording a decision does not — an ordinary API token or service account is enough, +so pipelines can invoke a policy without being able to change one. That split is what makes a +server-side decision worth more than a self-reported one: the team being audited can *run* the +control, but only an admin can decide what the control checks. + +```shell +kosli create policy pr-approved pr-approved.rego \ + --type rego \ + --input-schema trail \ + --name "Pull request approved" \ + --description "Every pull request on the trail has at least one approver." +``` + +```plaintext +Policy 'pr-approved' created + identifier: pr-approved + name: Pull request approved + version: 1 +``` + + +`kosli create policy` is the same command used for [environment policies](/getting_started/policies): +`--type rego` selects an evaluation policy, while the default `--type env` creates an environment +policy. Note that the positional argument differs between the two — for `--type env` it is the +policy **name**, while for `--type rego` it is the immutable **identifier** described above. In the +beta, authoring is **CLI/API only** — the Kosli UI is read/tag-only. + + + + + + +`kosli evaluate policy` is the core command. Kosli fetches the in-scope facts, runs the policy +**server-side**, and records a **decision** against a control. Evaluation is **asynchronous by +default** — the command returns once the evaluation is queued. Add `--sync` to wait for and +return the outcome, as the example below does. + +```shell +kosli evaluate policy \ + --flow "$FLOW_NAME" \ + --trail "$TRAIL_NAME" \ + --policy pr-approved \ + --control RCTL-043 \ + --context trail="$TRAIL_NAME" \ + --fingerprint "$ARTIFACT_FINGERPRINT" \ + --name pr-approval-decision \ + --params '{"pr_attestation_name": "pull-request"}' \ + --sync +``` + +```plaintext +RESULT: ALLOWED +DECISION: recorded against control RCTL-043 +POLICY: pr-approved @ version 1 +``` + +The four flags that carry the meaning: + +| Flag | Description | +|------|-------------| +| `--policy` | The policy to run — a Kosli policy **identifier** (uses its current version) or a local `.rego` file. | +| `--control` | The control identifier the resulting decision is recorded against. | +| `--context` | What it runs against — the **source**. Optional; defaults to the trail named by `--flow` / `--trail`. | +| `--flow` / `--trail` | Where the decision is recorded — the **destination**. Both always required. | + + +These flags pull in opposite directions, so it is worth reading them slowly. `--flow` and `--trail` +choose the trail the decision is **written** to, and are always required — as on every other Kosli +command that writes to a trail. `--context` chooses the trail whose facts are **read**. Most of the +time both name the same trail, as above; when they don't, that is a deliberate choice — see the +*Evaluating facts from another flow* example below. `--context` can be omitted, in which case it +falls back to the trail you are writing to, but the examples here pass it explicitly: a command that +names its source and its destination is easier to read back months later than one relying on a +default. + + +Use `--assert` in place of `--sync` when the step should also fail on a denial — that is how an +evaluation becomes a pipeline gate. Once recorded, a decision appears against the policy — see +[View the decisions for a policy](#view-the-decisions-for-a-policy). + + + +| Flag | Description | +|------|-------------| +| `--flow` | The flow the decision is recorded on (the **destination** flow). **Required.** | +| `--trail` | The trail the decision is recorded on. **Required** — there is no default, the same as every other Kosli command that writes to a trail. Set it once as `KOSLI_TRAIL` to keep it off each command. | +| `--policy` | The policy to evaluate — a Kosli policy **identifier** (uses its current version) or a **local** `.rego` file (see [Evaluate a local policy file](#evaluate-a-local-policy-file)). | +| `--control` | The control identifier the resulting decision is recorded against. | +| `--context` | One or more **typed references** to what should be evaluated (the **source**), repeatable. This is what Kosli resolves into the [evaluation context](#re-run-a-decision-offline) a decision cites — not a cluster or account selector. In the beta the only reference *type* is a trail: `trail=` (in `--flow`), or `trail=/` to name a trail in another flow. Repeat the flag to evaluate several trails — see [Evaluating multiple trails](#evaluating-multiple-trails). Optional: defaults to the trail named by `--flow` / `--trail`. The typed form leaves room for other context types — snapshots and reported workloads, for example — later, without new flags. | +| `--fingerprint` / `--artifact-type` | The artifact the decision applies to. Pass `--fingerprint` directly, or `--artifact-type` with the artifact name/path so Kosli calculates the fingerprint. | +| `--name` | The attestation slot name the decision is recorded under on the trail. A decision recorded here satisfies a `type: decision` attestation in the trail's [flow template](/template-reference/flow_template) when the names match — see [Requiring decisions in flow templates](/tutorials/working_with_controls#requiring-decisions-in-flow-templates). | +| `--params` | Parameters passed to the policy as `data.params`. Validated against the parameters the policy reads before the evaluation is queued — an unknown or missing name is an error. Every evaluation records the params it used. | +| `--description` | Optional human-readable context for the decision. | +| `--annotate` | Optional `key=value` annotations stored with the decision. | +| `--sync` | Wait for the evaluation to finish and return the outcome. Off by default — evaluation runs asynchronously. | +| `--assert` | Exit non-zero when the policy denies, for use as a pipeline gate. Implies `--sync`, since asserting requires the outcome. | +| `--dry-run` | Evaluate and return the outcome **without recording a decision** — for testing (see [Run an ad-hoc evaluation](#run-an-ad-hoc-evaluation)). | + + + +**Evaluating facts from another flow.** By default the decision is recorded on the same trail +whose facts you evaluate. Because each `--context` reference carries its own flow +(`trail=/`), you can evaluate a trail in a *different* flow — for example, +evaluating a build flow's trail but recording the decision on a release flow's trail: + +```shell +kosli evaluate policy \ + --policy pr-approved \ + --control RCTL-043 \ + --context trail=build-ci/build-42 \ + --flow release \ + --trail release-99 \ + --fingerprint "$ARTIFACT_FINGERPRINT" \ + --name pr-approval-decision \ + --sync +``` + +The decision records which flow and trail the facts came from, so a reviewer can always see that a +release decision was reached from a build flow's evidence rather than from the release trail itself. + + +**Be deliberate about which flows may satisfy a production control.** Any flow you can write to can +supply the facts for a decision on any other flow, and the beta does not check that a given policy +or source flow is acceptable for a given control. That makes it possible for evidence from a +low-assurance flow — a sandbox or an experiment — to close a control that gates production. Until +Kosli can constrain this, treat the set of flows that feed production controls as something to +decide and review, not something to leave open. + + + +**The server captures the evidence for you.** Because Kosli runs the evaluation, the decision +automatically records the policy (and its version), the evaluation report, and any violations — +you don't attach them by hand with `--attachments` / `--user-data` as you do today when you +attest a client-side result yourself. Likewise, `--compliant` is **derived** from the policy's +`allow` result rather than passed in. This is the core difference from +[recording a decision yourself](/tutorials/working_with_controls#recording-a-decision-against-a-control). + + + +The decision this records is the same kind of decision described in +[Working with controls](/tutorials/working_with_controls) — the difference is that Kosli +computed it server-side rather than you attesting the outcome yourself. **Enforcement is +unchanged:** require the control via `for_control` in an [environment policy](/getting_started/policies) +and gate promotion with [`kosli assert artifact --environment`](/getting_started/enforce_policies), +exactly as before. Recording the decision is the PDP; asserting it is the PEP. + + + + + + +As soon as the evaluation completes, the decision appears in Kosli against the control and the +policy that produced it. Open it in the [Kosli app](https://app.kosli.com) to see the outcome, and +the **evaluation context** it was reached from: + +| Recorded | Why it is there | +|---|---| +| The **evaluation context** the decision came from | The context holds what the evaluation was given. Naming it is what makes the decision traceable — and [re-runnable](#re-run-a-decision-offline). | +| The policy **version**, and a **digest** of the exact policy source that ran | A version number says which release; a digest proves the bytes. Together they mean nobody has to trust that version 3 is still what version 3 was. | +| The **parameters** used | Parameters are the only outside input, so a decision is not interpretable without them. | +| The trail **moments** in scope, and the **attestation IDs** actually used | A policy may ignore half a trail, so the context names the facts it actually relied on. | +| The **evaluation engine version** | The same policy on a different engine is not guaranteed to be the same evaluation. | +| The **requested** and **recorded** times | See below. | + +**A decision carries two timestamps.** The **requested** time is when the evaluation was asked +for; the **recorded** time is when the decision was written, once the evaluation came off the +queue. A decision is **valid as of the requested time**, even though it was recorded later — the +same distinction as a trade executed at one time and booked at another. Kosli records both and +never backdates the record. + +This matters when facts arrive mid-flight: an attestation added between the two timestamps is +*not* part of the decision, because the trail moment is anchored to the requested time. If that +new fact changes the answer, it takes a new evaluation to say so. + +Because the policy version, parameters, and trail moments are all pinned to that requested time, +the decision states exactly what was evaluated and when it applied — enough to +[re-run it offline](#re-run-a-decision-offline). To review and filter every decision a policy has +produced, see [View the decisions for a policy](#view-the-decisions-for-a-policy). + + + + + +### Evaluating multiple trails + +A policy published with `--input-schema trails` evaluates several trails at once and receives +them as `input.trails`. Repeat `--context` to name each one: + +```shell +kosli evaluate policy \ + --flow release \ + --trail release-99 \ + --policy pr-approved-across-builds \ + --control RCTL-043 \ + --context trail=build-ci/build-41 \ + --context trail=build-ci/build-42 \ + --fingerprint "$ARTIFACT_FINGERPRINT" \ + --name pr-approval-decision \ + --sync +``` + +The input schema and the number of references have to agree: passing several references to a +`trail`-schema policy is rejected before the evaluation is queued, as is passing one reference to a +`trails`-schema policy. Note that `--context` is doing real work here: several trails are read, but +the decision is still recorded on exactly one trail — the one named by `--flow` and `--trail`. + +## The Rego authoring contract + +Kosli evaluates your policy with OPA, so the tooling you already use locally — `opa fmt`, +`opa check`, `opa test` — works on these policies unchanged. What Kosli fixes is the contract +around the policy: the entry point it evaluates, the shape of `input`, the parameters and +built-ins a policy may use, and the output Kosli reads back. + + +Everything in this section is **proposed and open for comment**. If a constraint here would stop +you shipping a control you need, that is the most useful feedback you can give us. + + +### Entry point + +A policy declares `package policy`, and Kosli evaluates `data.policy.allow`. Keeping the package +fixed means the CLI, the server, and your own `opa test` cases all agree on where the entry point +is, and it is what lets your tests import the policy under test as `policy.allow`. + +### What the policy sees in `input` + +A policy evaluates the **attestations in the slots on the trail** — nothing else. Trail events +(`trail started`, `trail updated`) are not attestations and are not in `input`, and neither are +attestations from earlier moments of the same trail. Slots are matched **by name**, not by +attestation type. + +**Attachments are not opened.** A policy sees an attestation's own fields, not the bytes of the +files attached to it, so it cannot parse a SARIF file, an SBOM, or a PDF report to reason about +individual findings. To evaluate that content, summarize it into JSON when you make the attestation +and pass it as `--user-data` — extract the counts, severities, or package identifiers the policy +needs, and the policy evaluates the summary. + +Keep the original file on the same attestation with `--attachments`. The summary is what the policy +reads; the attachment is what makes the summary worth trusting, because an auditor can go back to +the file the summary was derived from and check that it is faithful. A summary with no attachment +behind it is just another self-reported number. + +The `--input-schema` you declare when you publish the policy selects the shape of `input`: + +| `--input-schema` | `input` contains | Reference schema | +|---|---|---| +| `trail` | `input.trail` — a single trail object | `https://docs.kosli.com/schemas/evaluation-input/trail/v1.json` | +| `trails` | `input.trails` — an array of trail objects | `https://docs.kosli.com/schemas/evaluation-input/trails/v1.json` | + +Those schemas are published as JSON Schema so you can type-check a policy against `input` before +you ship it. Associate one with your rules using an OPA +[metadata annotation](https://www.openpolicyagent.org/docs/policy-language#metadata): + +```rego pr-approved.rego +package policy + +import rego.v1 + +# METADATA +# scope: document +# schemas: +# - input: schema["trail-v1"] +default allow := false +``` + +Then check the policy against it. `opa check` resolves `schema["trail-v1"]` to `trail-v1.json` in +the directory you pass to `--schema`: + +```shell +mkdir -p schemas +curl -so schemas/trail-v1.json https://docs.kosli.com/schemas/evaluation-input/trail/v1.json + +opa check --strict --schema schemas pr-approved.rego +``` + +This turns a misspelled field into a compile error naming the fields that do exist, rather than an +undefined reference that silently leaves `allow` at its `false` default. `--strict` additionally +rejects unused imports and unused local assignments. + + +Schemas give you **type checking while authoring**; they are not how Kosli validates a policy on +publish. Kosli performs its own validation — see +[Validate and test a policy](#validate-and-test-a-policy). + + +### Parameters + +Parameters are the only input to a policy from outside the trail. They arrive as `data.params`, +and every evaluation records the parameters it used, so a decision always shows what the policy +was run with. + +```rego +pr_attestation_name := data.params.pr_attestation_name +``` + +Alias parameters at the top of the policy, as above, so a missing parameter leaves the alias +undefined and any rule referencing it fails to evaluate — rather than the policy proceeding with a +silently absent value. + +You don't declare the parameters a policy takes. Kosli **derives** them from the policy itself, by +reading which `data.params` paths the Rego depends on, and **validates the parameters you supply at +evaluation time** — an unknown parameter name or a missing one is rejected before the evaluation is +queued, by the CLI where it can be caught locally and by the server as the actual gate. + +This closes off a failure that is otherwise very hard to spot. Supplying `maxHigh` to a policy that +reads `max_high` leaves the alias undefined, so `allow` falls back to `false` *and* the `violations` +rules that reference the same alias don't fire either — the result is a recorded non-compliant +decision with no violations explaining it, and a failed pipeline with nothing to fix. Rejecting the +parameters up front turns that into an immediate, obvious error. + + +Derivation reads parameter paths statically, so a policy that computes parameter names at +evaluation time — `data.params[key]` for some derived `key` — cannot be checked this way, and Kosli +falls back to accepting whatever you supply. Prefer literal parameter names. + + + +Parameters are recorded on every decision and are visible to anyone who can read that decision. +Pass thresholds, names, and flags — never secrets or personal data. + + +### Output + +| Field | Required | Purpose | +|---|---|---| +| `allow` | **Yes** | Boolean. The decision outcome — this is what `--compliant` is derived from. | +| `violations` | No | A list of strings, each naming one thing that failed. Rendered on the decision in the Kosli UI and as markdown under `--assert`. | +| Any other field | No | Captured on the decision as-is, for richer human-readable context. | + +Give `allow` a `default allow := false` and drive it through positive assertions, so an +evaluation that goes wrong denies rather than allows. + +### Available built-ins + +Because Kosli executes the policy, evaluation runs with **no network access** and must produce the +same decision every time it is re-evaluated. Both constraints are expressed as an OPA +[capabilities](https://www.openpolicyagent.org/docs/cli#capabilities) file, which you can check a +policy against locally before publishing: + +```shell +curl -so kosli-capabilities.json https://docs.kosli.com/rego/capabilities/v1.json +opa check --capabilities kosli-capabilities.json pr-approved.rego +``` + +| Built-ins | Status | Reason | +|---|---|---| +| `http.send`, `net.lookup_ip_addr` | Not available | Evaluation has no network access. | +| `time.now_ns`, `rand.intn`, `opa.runtime` | Not available | Non-deterministic — see [Evaluation time](#evaluation-time) for time windows. | +| `io.jwt.verify_*`, `io.jwt.encode_sign*` | Not available | Require key material. `io.jwt.decode` is available. | +| `crypto.sha256`, `crypto.hmac.*`, and the other pure functions | Available | Deterministic, no host access. | +| Everything else | Available | The rest of the OPA built-in set. | + +### Evaluation time + +A policy cannot read the clock, because a decision that depends on when it happened to run is not +replicable. Instead, Kosli injects the evaluation context's request timestamp — the moment the +decision is **valid as of** — as `data.kosli.evaluation_time`, in nanoseconds since the Unix +epoch, so it can be compared directly against a parsed attestation timestamp: + +```rego +# 30 days before the moment this evaluation was requested. +cutoff := data.kosli.evaluation_time - time.parse_duration_ns("720h") + +approval_is_current if { + time.parse_rfc3339_ns(approval_timestamp) > cutoff +} +``` + +Here `approval_timestamp` is whichever timestamp the attestation you are evaluating carries — the +field depends on the attestation type. Time windows work as normal, they are anchored to the moment +the decision applies to, and re-evaluating the stored context produces the same answer. + +### Engine and Rego version + +Kosli pins the OPA version it evaluates with and records it on every decision alongside the policy +version, so a decision states exactly what ran. Author against that version locally and check with +`opa check --v1-compatible` to avoid a policy that compiles on your machine but not in Kosli. + +{/* TODO: state the pinned OPA version once the evaluation runtime is chosen. */} + +### When evaluation itself fails + +A policy that times out, exhausts its memory budget, or hits a runtime error is a *broken policy*, +not a non-compliant artifact, and Kosli reports the two differently — a failed evaluation records +a typed error rather than a `--compliant=false` decision, and `--assert` exits with a distinct +code so a pipeline can tell "this change is not compliant" from "this policy is broken". + +{/* TODO: confirm the exit codes and whether a failed evaluation records a decision at all. */} + +Open questions we would particularly like feedback on: whether a built-in error should abort the +evaluation or evaluate to undefined (and therefore deny), and whether `print` and `trace` output +should be discarded or captured on the evaluation report. + +## Validate and test a policy + +Before you ship a policy — or wire it into a pipeline gate — check that it holds up. + + +A policy is code, so treat it like code: keep **Rego tests alongside each policy** that assert it +allows and denies the cases you expect. Rego's built-in +[test framework](https://www.openpolicyagent.org/docs/policy-testing) runs these locally and in CI, +catching regressions before you publish a new version. + + +### Validate + +`kosli validate policy` checks that a policy is well-formed without recording anything. It +confirms that: + +- the Rego **compiles**; +- it matches its declared **input schema** (`trail` / `trails`); +- it satisfies the **output schema** — an `allow` rule is mandatory. + +```shell +kosli validate policy \ + --policy pr-approved.rego \ + --input-schema trail +``` + +```plaintext +RESULT: VALID +``` + +### Run an ad-hoc evaluation + +To see how a policy behaves against real trail data *without* recording a decision, add +`--dry-run` to `kosli evaluate policy`. Kosli runs the policy server-side and returns the outcome, +but no decision is stored — so `--control` and `--name` aren't needed: + +```shell +kosli evaluate policy \ + --flow "$FLOW_NAME" \ + --trail "$TRAIL_NAME" \ + --policy pr-approved.rego \ + --context trail="$TRAIL_NAME" \ + --params '{"pr_attestation_name": "pull-request"}' \ + --sync \ + --dry-run +``` + +```plaintext +RESULT: ALLOWED (dry-run — no decision recorded) +``` + +This is the fastest way to iterate: point a candidate policy at a trail you know should pass (or +fail) and confirm the outcome before publishing a version or gating a pipeline on it. + +### Evaluate a local policy file + +You don't have to publish a policy before it can record a decision. Point `--policy` at a local, +unpublished `.rego` file and Kosli evaluates it server-side and records the decision, just as it +would for a stored policy — useful while you're still iterating on a policy you haven't shipped: + +```shell +kosli evaluate policy \ + --flow "$FLOW_NAME" \ + --trail "$TRAIL_NAME" \ + --policy ./pr-approved.rego \ + --control RCTL-043 \ + --context trail="$TRAIL_NAME" \ + --fingerprint "$ARTIFACT_FINGERPRINT" \ + --name pr-approval-decision \ + --params '{"pr_attestation_name": "pull-request"}' +``` + +Because there is no stored policy version to cite, the decision records the exact policy source +that was evaluated instead. Unlike the ad-hoc evaluation above, this **records** a decision — add +`--dry-run` if you only want the outcome without recording one. + + +A decision made this way did **not** go through the org-admin gate on +[publishing a policy](#author-ship-and-evaluate-a-policy) — anyone who can record a decision can +supply the Rego that produced it. Treat these as decisions for iterating on a policy, not as +audit evidence, and publish a version before a control depends on the outcome. We would like +feedback on whether the beta should mark such decisions distinctly, or restrict them entirely. + + +## Manage policies + +### Update a policy + +Publishing a change is the same command as the initial publish: re-run `kosli create policy` with +the updated Rego, name, or description. Each publish creates a **new version** and records who made +it — add `--comment` to note what changed — so a decision always points back to the exact version +that produced it. The identifier never changes. + +```shell +kosli create policy pr-approved pr-approved.rego \ + --type rego \ + --input-schema trail \ + --name "Pull request approved by a code owner" \ + --description "Every pull request on the trail has an approver from a code owner." \ + --comment "Require approval from a code owner" +``` + +An evaluation that references a policy by identifier uses its **current version**. Decisions +recorded earlier keep pointing at the version that was current when they were made. + +**Publishing an unchanged policy is a no-op.** If what you publish is identical to the current +version, Kosli creates no new version, says so, and exits `0`: + +```plaintext +Policy 'pr-approved' unchanged — still at version 4 +``` + +This is what makes publishing safe to run on every merge, or from `terraform apply`: the command is +idempotent, so a pipeline that republishes an untouched policy leaves the history alone instead of +filling it with identical versions. + +What counts as a change: + +| Publish | Result | +|---|---| +| Same policy source, same name, description, and input schema | **No-op.** No version created. | +| Any change to the policy source | New version. The digest is over the bytes, so reformatting counts. | +| Same source, changed name, description, or input schema | New version — the description is part of what an auditor reads off a version. | +| Only `--comment` differs | **No-op.** A comment describes a change; with no change there is nothing to describe. | + +Comparison is against the **current** version, not the whole history. So republishing the bundle +from an earlier version is a real change and does create a new version — that is how you roll back, +and the history records that the rollback happened rather than hiding it. + +### Review a policy change + +A policy is the code that decides whether a control passes, so a change to it is a change to your +control environment. Reviewing that change is part of the lifecycle — author, test, version, +**review**, ship — and in the beta the review happens in your policy repository, not in Kosli. + +The practice we recommend, and the one Kosli's records are designed to corroborate: + + + + +Require pull requests, and require review from the people accountable for the control — a +`CODEOWNERS` entry per policy is the simplest way to make sure the right control owner sees a change +to *their* control. Restrict who can push directly. + + + +The question a control owner has to answer is not "is this valid Rego?" but "does this policy +actually meet the control objective?" Reviewing the change alongside its +[tests](#validate-and-test-a-policy) is what makes that answerable — the tests state, in cases, what +the policy is claiming to enforce. + + + +Because [only org admins can publish](#author-ship-and-evaluate-a-policy), publish from a CI job +running with an admin credential on merge, rather than by hand from a laptop. The reviewed commit +then becomes the published version, and the two cannot drift. + +You can publish every policy on every merge without thinking about which ones changed — +[publishing an unchanged policy is a no-op](#update-a-policy), so only the policies you actually +edited get a new version. + + + +Use `--comment` to reference the change record, so Kosli's version history and your review history +join up: + +```shell +kosli create policy pr-approved pr-approved.rego \ + --type rego \ + --input-schema trail \ + --comment "Require approval from a code owner (acme/policies#214)" +``` + + + + +Kosli records who published each version, when, what they said changed, and the digest of the source +that was published. Combined with the review in your repository, that is a complete change-control +trail for the policy itself — which is what an auditor asking "who decided this is what the control +means?" is looking for. + + +There is **no review or approval step inside Kosli** in the beta: a policy version is live as soon as +it is published, and there is no draft state or second-approver requirement. If your control +environment needs approval to be enforced by Kosli rather than by your repository, tell us — it +affects how much of this belongs in the product. + + +### Tag a policy + +Tags are versionless labels for organizing and filtering policies — grouping by team, framework, +or control area. Tagging does **not** create a new version. + +```shell +kosli tag policy pr-approved --set team=platform --set framework=soc2 +``` + +### Archive and unarchive + +When a policy is retired, archive it rather than deleting it. Archiving is light-touch: the policy +is removed from the main list in the UI but **remains evaluable**, and its versions and past +decisions are preserved. Archiving records who archived it and when, and does not create a new +version. Unarchive it if it comes back into use. + +```shell +kosli archive policy pr-approved +kosli unarchive policy pr-approved +``` + +### List and filter policies + +Browse your policies in the [Kosli app](https://app.kosli.com) — select **Policies** in the +sidebar to see the full catalog. Search by name or identifier, and filter by tag to narrow the +list. Each policy shows its identifier, current version, and tags. + + + Policies catalog listing policies with their identifier, current version, and tags, with a search box and a tag filter + + +You can also list policies from the CLI. Filter to evaluation policies with `--type rego`, and by +name or tag — each entry shows the identifier and current version: + +```shell +kosli list policies --type rego --tag team=platform +``` + +### View a policy + +Select a policy to open it. The **Policy** tab is the default view: it shows the policy-as-code +that Kosli actually runs, read-only, for the version you choose. This is what answers the question +a control owner has to answer — *does this policy meet the control objective?* — which a name and +description can only claim. + + + Policy tab for a policy showing the bundle digest, roots, entrypoint and input schema, a file list, and the read-only Rego source of the selected file with line numbers + + +A policy can be a single `.rego` file or several, so the tab lists the files in the version and +shows the one you select. Alongside them it shows what an auditor needs to check the record: the +**bundle digest** a decision cites, the **entrypoint** that Kosli evaluates, the declared +**input schema**, and the **parameters** the policy reads. Tests shipped with the policy are +listed and marked *not executed* — Kosli stores them as evidence the policy was tested, but only +the entrypoint runs. + + +When you open a policy from a decision, the tab shows the version **that decision used**, not the +current one — so the source in front of you is the source that produced the outcome you are looking +at. + + +Policies are read-only in the Kosli UI. Edit them in your repository and publish a new version with +`kosli create policy`. + +## View the decisions for a policy + +This is where **auditors and control owners** review what a policy has decided. Every decision a +policy produces is listed against that policy, across **all versions** of the policy. In the +[Kosli app](https://app.kosli.com), select **Policies**, choose a policy, and open its +**Decisions** tab. Each row shows the timestamp, the trail and artifact the decision applies +to, the policy version that produced it, and the outcome (compliant or non-compliant). Filter by +outcome, version, or time range, and page through the results to sample them. + + + Decisions tab for a policy showing decisions with timestamp, compliant or non-compliant outcome, trail, artifact fingerprint, and policy version, with outcome and version filters + + +Select a decision to open its detail, showing the trail and artifact it applies to, the policy +version and parameters that were used, the evaluation report, and any violations. + + + Decision detail showing the trail, artifact, policy version and parameters, the evaluation report, and the list of violations + + +For an audit or an offline review, export the filtered set as CSV — from the Decisions view in the +UI, or from the CLI: + +```shell +kosli list decisions \ + --policy pr-approved \ + --compliant=false \ + --since 30d \ + --output csv > decisions.csv +``` + +`--compliant=false` surfaces every non-compliant evaluation for follow-up; combine it with +`--since` or a version filter to scope the export. + +Each row shows the requested and recorded timestamps, so an export is enough to sample decisions +by when they applied rather than only by when they were written. + + +The same filtered sampling and CSV export are proposed for a **control's** Decisions tab, so an +auditor can sample by control as well as by policy. That is not part of the +[controls](/tutorials/working_with_controls) feature today. + + +### Re-run a decision offline + +Sampling a decision usually raises the question of whether it was *right*. Everything the +evaluation was given is held on its **evaluation context**, which the decision names — so you can +export that context and evaluate the policy again yourself, with stock OPA, on your own machine: + +```shell +kosli get evaluation-context CONTEXT-ID --output fixture --dir ./replay +``` + +Every decision shows the evaluation context it came from, in the UI and in +`kosli get decision`, so the context ID is one hop from the decision you are looking at. + +The export contains what the evaluation was given, as ordinary OPA inputs: + +| File | Contents | +|---|---| +| `input.json` | The trail moment that was evaluated — `input.trail` or `input.trails`. | +| `data.json` | `params` as supplied, and the `kosli.evaluation_time` that was injected. | +| `policy.rego` | The exact policy source that ran, matching the digest on the decision. | + + +A context exists from the moment an evaluation is queued, so this also works when there is **no +decision to look at** — an evaluation still in the queue, or one that failed. That is usually the +case you most want to inspect. + + +Evaluate it with OPA directly: + +```shell +opa eval --input replay/input.json \ + --data replay/data.json \ + --data replay/policy.rego \ + 'data.policy.allow' +``` + +Because the evaluation sandbox excludes anything non-deterministic — no clock, no network, no +randomness, with time supplied as data — this returns the outcome the decision recorded. If it +doesn't, that is a finding in itself. + + +This is the strongest form of the audit story, because it doesn't ask anyone to trust Kosli to check +Kosli. An auditor can reach the same conclusion independently, with a tool Kosli doesn't control. +The same export doubles as a **regression fixture**: commit `input.json` and `data.json` beside your +policy and assert against them with `opa test`, and a real past decision becomes a permanent test +case. + + +Re-running a stored decision through a *different* policy or version — to ask "what would the new +policy have said?" — is [out of scope for the beta](#out-of-scope-for-the-beta). Exporting the +fixture and running it locally is the way to answer that question today. + +## Inspect a failing evaluation + +When a policy denies, an **application team** needs to know why — and the decision records it +through its `violations`. Whether the policy's rules weren't met (for example, a pull request with +no approver) or an expected attestation was missing from the trail, both surface as violations on +the decision. + +A policy can return **more than `allow` and `violations`** — any additional fields in its output +are captured on the decision, so you can surface richer, human-readable context beyond a bare +pass/fail. + +When you gate a pipeline with `--assert`, Kosli prints the violations as markdown so failures are +readable directly in CI logs: + +```shell +kosli evaluate policy \ + --flow "$FLOW_NAME" \ + --trail "$TRAIL_NAME" \ + --policy pr-approved \ + --control RCTL-043 \ + --context trail="$TRAIL_NAME" \ + --fingerprint "$ARTIFACT_FINGERPRINT" \ + --name pr-approval-decision \ + --params '{"pr_attestation_name": "pull-request"}' \ + --assert +``` + +```plaintext +RESULT: DENIED +VIOLATIONS: pull-request https://github.com/acme/app/pull/42 has no approvers +``` + +In the Kosli UI, the violations are parsed out of the decision and rendered on the decision detail +(shown above under [View the decisions for a policy](#view-the-decisions-for-a-policy)), so an +application team can see exactly what to fix. + +## Out of scope for the beta + +The following are explicitly **not included** in the beta and may be addressed later. They are +listed so you can tell what has been deferred from what has not been considered — if something here +blocks you, that is worth telling us. + +- **Evidence-attachment content evaluation** — evaluating the contents of attached files. Summarize + the attachment into JSON `--user-data` at attestation time and a policy can evaluate that instead — + see [What the policy sees in `input`](#what-the-policy-sees-in-input). +- **Composable policies** — composing many authored policies into one versioned policy. +- **Non-trail evaluate targets** — snapshots, artifacts, and generic input. +- **Scheduled evaluations** and **risk measurement**. +- **Associating policies to controls** — a stored policy-to-control link that lets Kosli infer + which policy closes which control. In the beta you name the control per evaluation with + `--control`; there is no stored mapping. +- **Pinning an evaluation to a policy version** — an evaluation always uses the policy's current + version. There is no way to hold a pipeline on version 3 while version 4 is rolled out. +- **Re-running a past decision against a new policy** — you cannot replay a stored evaluation + context through a different policy or version to compare outcomes. +- **Advisory outcomes** — a decision is compliant or non-compliant. There is no advisory or + warn-only mode that records a concern without failing the control. +- **Verbose evaluation tracing** — beyond `violations` and any custom output the policy returns, + there is no rule-by-rule trace of why a policy reached its result. +- **Filtering decisions by branch or environment** — decisions can be filtered by outcome, policy + version, and time, but not by where the change came from or where it was heading. + +## Next steps + +- [Working with controls](/tutorials/working_with_controls) — define controls and enforce them with `for_control` and `kosli assert`. +- [Environment policies](/getting_started/policies) — require controls before an artifact runs in an environment. +- [Enforce policies](/getting_started/enforce_policies) — gate promotion on compliance in your pipeline. +- [Evaluate trails with OPA policies](/tutorials/evaluate_trails_with_opa) — the client-side `kosli evaluate` this builds on. + +## Feedback + +This is a design preview. If you are reviewing the proposed beta flow, the most useful feedback +is on the command surface (`kosli evaluate policy` and how policies are published), +[the Rego authoring contract](#the-rego-authoring-contract) — particularly any constraint there +that would stop you shipping a control you need — the decision and audit model, and anything in +[What's out of scope](#out-of-scope-for-the-beta) that you would expect in the beta. Share +comments with the Kosli team. diff --git a/tutorials/working_with_controls.mdx b/tutorials/working_with_controls.mdx index 7851571..d566afe 100644 --- a/tutorials/working_with_controls.mdx +++ b/tutorials/working_with_controls.mdx @@ -21,7 +21,7 @@ This tutorial covers how to: ## Prerequisites - [Install Kosli CLI](/getting_started/install). -- [Get a Kosli API token](/getting_started/service-accounts). +- [Get a Kosli API token](/getting_started/authenticating_to_kosli). - Have at least one [Flow](/getting_started/flows) and [Trail](/getting_started/trails) already created. ## Setup @@ -97,7 +97,7 @@ You can also list controls from the CLI with `kosli list controls`, fetch a sing When a control is retired, archive it rather than deleting it. Archiving moves the control out of the main catalog into its **Archived** view but preserves its full history — past decisions and every version of the definition remain intact for audit — and an archived control can be unarchived later if it comes back into use. -Archive or unarchive a control from its detail page (org admins only), from the CLI with `kosli archive control CONTROL-IDENTIFIER` and `kosli unarchive control CONTROL-IDENTIFIER`, or through the controls API (`POST /api/v2/controls/{org}/{identifier}/archive` and `.../unarchive`). Both actions appear as events in the control's **Versions** tab, alongside Created and Edited events, so the full lifecycle is auditable. +Archive or unarchive a control from its detail page (org admins only), from the CLI with `kosli archive control CONTROL-IDENTIFIER` and `kosli unarchive control CONTROL-IDENTIFIER`, or through the controls API (`POST /api/v2/controls/{org}/{identifier}/archive` and `.../unarchive`). Archiving and unarchiving are recorded as events with the actor and time, and do not create a new version of the definition. Both appear in the control's **Versions** tab alongside Created and Edited events, so the full lifecycle is auditable in one place. ## Recording a decision against a control