From 4eaadff488c0a9a86868b658f559d9f52fd772db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dan=20Gr=C3=B8ndahl?= Date: Tue, 16 Jun 2026 15:15:54 +0200 Subject: [PATCH 01/17] feat: add interactive policy builder to docs Add a browser-based environment-policy builder as a self-contained Mintlify React component (snippets/policy-builder.jsx) on a new policy-reference/policy_builder page. It mirrors the kosli create policy-file CLI wizard: provenance and trail-compliance toggles, repeatable attestation rules, exceptions, and an expression builder (comparison/matches/exists, and/or/not, flow-name and flow-tag conditions), with a live syntax-highlighted YAML preview and copy button. The YAML serializer and expression builders are reimplemented in plain JS (no third-party packages, per Mintlify sandbox constraints). All helpers, constants, styles, and sub-renderers live inside the exported component because the Mintlify MDX sandbox only exposes the exported component body plus the pre-injected hooks. The attestation 'name' field is omitted when it is the schema default ('*'). The page uses wide mode to drop the right-hand table of contents. Fully client-side, no API calls. Closes #269 --- config/navigation.json | 1 + policy-reference/environment_policy.mdx | 5 + policy-reference/policy_builder.mdx | 25 + snippets/policy-builder.jsx | 793 ++++++++++++++++++++++++ 4 files changed, 824 insertions(+) create mode 100644 policy-reference/policy_builder.mdx create mode 100644 snippets/policy-builder.jsx diff --git a/config/navigation.json b/config/navigation.json index f8e638d..ff94ea0 100644 --- a/config/navigation.json +++ b/config/navigation.json @@ -439,6 +439,7 @@ "group": "Policies", "pages": [ "policy-reference/environment_policy", + "policy-reference/policy_builder", "policy-reference/rego_policy" ] } diff --git a/policy-reference/environment_policy.mdx b/policy-reference/environment_policy.mdx index 655056b..3b2baed 100644 --- a/policy-reference/environment_policy.mdx +++ b/policy-reference/environment_policy.mdx @@ -5,6 +5,10 @@ description: "Reference for the YAML policy files used to define compliance requ An environment policy is a YAML file that declares compliance requirements for artifacts running in a Kosli environment. You pass the file to [`kosli create policy`](/client_reference/kosli_create_policy) to create or update a policy. For concepts, workflow, and enforcement, see [Environment Policies](/getting_started/policies). + + Prefer to build a policy interactively? Use the [Policy builder](/policy-reference/policy_builder) to assemble a valid policy file in your browser and copy the YAML. + + ## Specification @@ -190,6 +194,7 @@ _schema: https://docs.kosli.com/schemas/policy/v1 ## See also +- [Policy builder](/policy-reference/policy_builder) — build a policy file interactively in the browser - [Environment Policies](/getting_started/policies) — concepts, workflow, and enforcement - [`kosli create policy`](/client_reference/kosli_create_policy) — create or update a policy - [`kosli attach-policy`](/client_reference/kosli_attach-policy) — attach a policy to an environment diff --git a/policy-reference/policy_builder.mdx b/policy-reference/policy_builder.mdx new file mode 100644 index 0000000..90de7ba --- /dev/null +++ b/policy-reference/policy_builder.mdx @@ -0,0 +1,25 @@ +--- +title: "Policy builder" +description: "Build a Kosli environment policy YAML interactively in the browser and copy the result into your repo." +icon: "wand-magic-sparkles" +mode: "wide" +--- + +import { PolicyBuilder } from "/snippets/policy-builder.jsx"; + +Use this builder to assemble an [environment policy](/policy-reference/environment_policy) without installing the CLI. Toggle the requirements you need, add attestation rules and exceptions, then copy the generated YAML into a file in your repo. + +The output conforms to the [v1 policy schema](https://docs.kosli.com/schemas/policy/v1.json) and is equivalent to what the interactive `kosli create policy-file` wizard produces. It runs entirely in your browser — nothing is sent to Kosli. + + + +## Next steps + +- Save the YAML as a file (e.g. `policy.yaml`) in your repo. +- Create or update the policy with [`kosli create policy`](/client_reference/kosli_create_policy). +- Attach it to an environment with [`kosli attach-policy`](/client_reference/kosli_attach-policy). + +## See also + +- [Environment Policy](/policy-reference/environment_policy) — full schema reference and field descriptions +- [Environment Policies](/getting_started/policies) — concepts, workflow, and enforcement diff --git a/snippets/policy-builder.jsx b/snippets/policy-builder.jsx new file mode 100644 index 0000000..4faed08 --- /dev/null +++ b/snippets/policy-builder.jsx @@ -0,0 +1,793 @@ +/* + * Interactive Kosli environment-policy builder. + * + * Self-contained Mintlify React component (see + * https://www.mintlify.com/docs/customize/react-components): + * - named export only, no `export default` + * - no third-party imports (YAML is hand-rolled below) + * - only the pre-injected hooks are used (useState, useMemo) + * + * IMPORTANT — Mintlify sandbox scope: the sandbox evaluates only the exported + * component's body together with the pre-injected hooks. Module-level helpers, + * constants, and other components are NOT in scope at runtime, and capitalized + * resolve through Mintlify's MDX component registry rather than local + * scope. So EVERYTHING this component needs is defined inside the function + * below, and the UI sub-pieces are plain functions invoked directly + * (e.g. {termEditor(...)}) rather than JSX elements. + * + * The serialization and expression-building logic mirrors the CLI policy + * wizard (kosli-dev/cli: internal/policywizard, internal/policy). The YAML + * output is byte-compatible with the CLI's `policy.ToYAML()` (gopkg.in/yaml.v3, + * 4-space indent). Keep this file in sync with the v1 policy schema at + * https://docs.kosli.com/schemas/policy/v1 + */ + +export const PolicyBuilder = () => { + const SCHEMA_URL = "https://docs.kosli.com/schemas/policy/v1"; + + const BUILTIN_TYPES = [ + "generic", + "junit", + "snyk", + "pull_request", + "jira", + "sonar", + "*", + ]; + + const TERM_KINDS = [ + { value: "flow_name", label: "Flow name" }, + { value: "flow_tag", label: "Flow tag" }, + { value: "artifact_name", label: "Artifact name pattern" }, + { value: "custom", label: "Custom comparison" }, + { value: "raw", label: "Raw expression" }, + ]; + + const COMPARE_OPS = ["==", "!=", ">", "<", ">=", "<="]; + const CUSTOM_OPS = ["==", "!=", ">", "<", ">=", "<=", "matches", "exists"]; + const CUSTOM_CONTEXTS = [ + "flow.name", + "flow.tags.", + "artifact.name", + "artifact.fingerprint", + ]; + + // ------------------------------------------------------------------------- + // YAML serialization — mirrors gopkg.in/yaml.v3 default output. + // ------------------------------------------------------------------------- + + // yamlScalar quotes a scalar only when a plain (unquoted) form would be + // ambiguous, matching yaml.v3's preference for single quotes (`*` -> '*'). + function yamlScalar(s) { + if (s === "") return "''"; + const reserved = ["true", "false", "null", "yes", "no", "on", "off", "~"]; + const needsQuote = + /^\s|\s$/.test(s) || // leading/trailing whitespace + /^[-?:,\[\]{}#&*!|>'"%@`]/.test(s) || // indicator at start + /:(\s|$)/.test(s) || // colon followed by space or end-of-string + /\s#/.test(s) || // space then comment indicator + /[\n\t]/.test(s) || + reserved.includes(s.toLowerCase()) || + (/^[0-9.+-]/.test(s) && !Number.isNaN(Number(s))); // looks numeric + if (!needsQuote) return s; + return "'" + s.replace(/'/g, "''") + "'"; + } + + // serializePolicy renders the policy state to a YAML string. Blocks are only + // emitted when meaningful, exactly as the CLI wizard builds the policy object. + function serializePolicy(state) { + const lines = ["_schema: " + SCHEMA_URL]; + + const provOn = state.provReq; + const trailOn = state.trailReq; + const validAtts = state.atts.filter((a) => attestationIsValid(a)); + + if (!provOn && !trailOn && validAtts.length === 0) { + return lines.join("\n") + "\n"; + } + + lines.push("artifacts:"); + + const booleanBlock = (key, required, exceptions) => { + lines.push(" " + key + ":"); + lines.push(" required: " + (required ? "true" : "false")); + const exprs = exceptions + .map((e) => serializeExpr(e)) + .filter((s) => s !== ""); + if (exprs.length > 0) { + lines.push(" exceptions:"); + exprs.forEach((expr) => { + lines.push(" - if: " + yamlScalar(expr)); + }); + } + }; + + if (provOn) booleanBlock("provenance", true, state.provExc); + if (trailOn) booleanBlock("trail-compliance", true, state.trailExc); + + if (validAtts.length > 0) { + lines.push(" attestations:"); + validAtts.forEach((a) => { + const type = a.type === "custom" ? "custom:" + a.customType : a.type; + lines.push(" - type: " + yamlScalar(type)); + // `name` defaults to `*` in the v1 schema, so only emit it when the + // user requires a specific name. + const name = (a.name || "").trim(); + if (name !== "" && name !== "*") { + lines.push(" name: " + yamlScalar(name)); + } + if (a.condEnabled) { + const expr = serializeExpr(a.cond); + if (expr !== "") lines.push(" if: " + yamlScalar(expr)); + } + }); + } + + return lines.join("\n") + "\n"; + } + + // ------------------------------------------------------------------------- + // Expression building — mirrors internal/policy/expression.go. + // ------------------------------------------------------------------------- + + function unwrapExpr(expr) { + let s = (expr || "").trim(); + if (s.startsWith("${{")) s = s.slice(3); + if (s.endsWith("}}")) s = s.slice(0, -2); + return s.trim(); + } + + function termToInner(term) { + let inner = ""; + switch (term.kind) { + case "flow_name": + inner = `flow.name == "${term.flowName || ""}"`; + break; + case "flow_tag": + inner = `flow.tags.${term.tagKey || ""} ${term.tagOp} "${ + term.tagValue || "" + }"`; + break; + case "artifact_name": + inner = `matches(artifact.name, "${term.artifactRegex || ""}")`; + break; + case "custom": { + const ctx = + term.customCtx === "flow.tags." + ? "flow.tags." + (term.customTagKey || "") + : term.customCtx; + if (term.customOp === "exists") { + inner = `exists(${ctx})`; + } else if (term.customOp === "matches") { + inner = `matches(${ctx}, "${term.customValue || ""}")`; + } else { + inner = `${ctx} ${term.customOp} "${term.customValue || ""}"`; + } + break; + } + case "raw": + inner = unwrapExpr(term.raw); + break; + default: + inner = ""; + } + if (inner === "") return ""; + // NegateExpr wraps in parentheses regardless of precedence. + return term.negate ? `not (${inner})` : inner; + } + + // serializeExpr combines a term list with a logical operator, wrapping the + // result in ${{ }} — matching CombineExprs + WrapExpr in the Go wizard. + function serializeExpr(expr) { + if (!expr) return ""; + const inners = (expr.terms || []).map(termToInner).filter((s) => s !== ""); + if (inners.length === 0) return ""; + if (inners.length === 1) return "${{ " + inners[0] + " }}"; + return "${{ " + inners.join(" " + (expr.combine || "and") + " ") + " }}"; + } + + // ------------------------------------------------------------------------- + // State factories + // ------------------------------------------------------------------------- + + function newTerm() { + return { + kind: "flow_name", + negate: false, + flowName: "", + tagKey: "", + tagOp: "==", + tagValue: "", + artifactRegex: "", + customCtx: "flow.name", + customTagKey: "", + customOp: "==", + customValue: "", + raw: "", + }; + } + + function newExpr() { + return { combine: "and", terms: [newTerm()] }; + } + + function newAttestation() { + return { + type: "snyk", + customType: "", + name: "*", + condEnabled: false, + cond: newExpr(), + }; + } + + function attestationIsValid(a) { + const type = a.type === "custom" ? "custom:" + (a.customType || "") : a.type; + if (a.type === "custom" && !a.customType) return false; + if (type === "*" && (a.name || "*") === "*") return false; + return true; + } + + // ------------------------------------------------------------------------- + // Styles — theme-neutral inline styles so the component renders consistently + // regardless of the surrounding Mintlify CSS. + // ------------------------------------------------------------------------- + + const S = { + wrap: { + display: "grid", + gridTemplateColumns: "minmax(0, 1fr) minmax(0, 1fr)", + gap: "1.25rem", + alignItems: "start", + }, + panel: { + border: "1px solid var(--gray-200, #e5e7eb)", + borderRadius: "0.5rem", + padding: "1rem", + }, + section: { marginBottom: "1.25rem" }, + legend: { fontWeight: 600, marginBottom: "0.5rem", fontSize: "0.95rem" }, + row: { + display: "flex", + gap: "0.5rem", + alignItems: "center", + flexWrap: "wrap", + marginBottom: "0.5rem", + }, + label: { + display: "flex", + alignItems: "center", + gap: "0.4rem", + fontSize: "0.9rem", + }, + input: { + padding: "0.35rem 0.5rem", + border: "1px solid var(--gray-300, #d1d5db)", + borderRadius: "0.375rem", + fontSize: "0.85rem", + background: "var(--background, transparent)", + color: "inherit", + }, + select: { + padding: "0.35rem 0.5rem", + border: "1px solid var(--gray-300, #d1d5db)", + borderRadius: "0.375rem", + fontSize: "0.85rem", + background: "var(--background, transparent)", + color: "inherit", + }, + btn: { + padding: "0.3rem 0.7rem", + border: "1px solid var(--gray-300, #d1d5db)", + borderRadius: "0.375rem", + fontSize: "0.8rem", + cursor: "pointer", + background: "transparent", + color: "inherit", + }, + btnPrimary: { + padding: "0.3rem 0.7rem", + border: "none", + borderRadius: "0.375rem", + fontSize: "0.8rem", + cursor: "pointer", + background: "#2563eb", + color: "#fff", + }, + card: { + border: "1px solid var(--gray-200, #e5e7eb)", + borderRadius: "0.375rem", + padding: "0.75rem", + marginBottom: "0.75rem", + }, + pre: { + margin: 0, + padding: "1rem", + borderRadius: "0.5rem", + background: "#0d1117", + color: "#e6edf3", + fontFamily: + "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", + fontSize: "0.8rem", + lineHeight: 1.5, + overflowX: "auto", + whiteSpace: "pre", + }, + warn: { color: "#b45309", fontSize: "0.8rem", margin: "0.25rem 0 0.5rem" }, + muted: { fontSize: "0.8rem", opacity: 0.7 }, + }; + + // ------------------------------------------------------------------------- + // Render helpers — plain functions returning JSX (see header note). Each + // function used inside a list sets `key` on its returned root element. + // ------------------------------------------------------------------------- + + function termEditor({ term, onChange, onRemove, removable, keyId }) { + const set = (patch) => onChange({ ...term, ...patch }); + return ( +
+
+ + + {removable && ( + + )} +
+ + {term.kind === "flow_name" && ( +
+ flow.name == + set({ flowName: e.target.value })} + /> +
+ )} + + {term.kind === "flow_tag" && ( +
+ flow.tags. + set({ tagKey: e.target.value })} + /> + + set({ tagValue: e.target.value })} + /> +
+ )} + + {term.kind === "artifact_name" && ( +
+ matches(artifact.name, + set({ artifactRegex: e.target.value })} + /> + ) +
+ )} + + {term.kind === "custom" && ( +
+ + {term.customCtx === "flow.tags." && ( + set({ customTagKey: e.target.value })} + /> + )} + + {term.customOp !== "exists" && ( + set({ customValue: e.target.value })} + /> + )} +
+ )} + + {term.kind === "raw" && ( +
+ set({ raw: e.target.value })} + /> +
+ )} +
+ ); + } + + function expressionEditor({ expr, onChange, keyId }) { + const setTerm = (i, term) => { + const terms = expr.terms.slice(); + terms[i] = term; + onChange({ ...expr, terms }); + }; + const removeTerm = (i) => { + const terms = expr.terms.filter((_, idx) => idx !== i); + onChange({ ...expr, terms: terms.length ? terms : [newTerm()] }); + }; + const addTerm = () => + onChange({ ...expr, terms: [...expr.terms, newTerm()] }); + + return ( +
+ {expr.terms.length > 1 && ( +
+ Combine terms with + +
+ )} + {expr.terms.map((term, i) => + termEditor({ + term, + keyId: i, + onChange: (t) => setTerm(i, t), + onRemove: () => removeTerm(i), + removable: expr.terms.length > 1, + }) + )} + +
+ ); + } + + function exceptionList({ exceptions, onChange, label }) { + const add = () => onChange([...exceptions, newExpr()]); + const remove = (i) => onChange(exceptions.filter((_, idx) => idx !== i)); + const update = (i, expr) => { + const next = exceptions.slice(); + next[i] = expr; + onChange(next); + }; + return ( +
+ {exceptions.map((expr, i) => ( +
+
+ + {label} exception {i + 1} + + +
+ {expressionEditor({ expr, onChange: (e) => update(i, e) })} +
+ ))} + +
+ ); + } + + function attestationRow({ att, onChange, onRemove, index }) { + const set = (patch) => onChange({ ...att, ...patch }); + const valid = attestationIsValid(att); + const typeValue = att.type === "custom" ? "custom" : att.type; + return ( +
+
+ + Attestation {index + 1} + + +
+
+ type + + {att.type === "custom" && ( + set({ customType: e.target.value })} + /> + )} + name + set({ name: e.target.value })} + /> +
+ {!valid && ( +

+ {att.type === "custom" && !att.customType + ? "Enter a custom type name." + : "type and name cannot both be * — give the attestation a name."} +

+ )} + + {att.condEnabled && ( +
+ {expressionEditor({ + expr: att.cond, + onChange: (cond) => set({ cond }), + })} +
+ )} +
+ ); + } + + // ------------------------------------------------------------------------- + // YAML syntax highlighting — hand-rolled, no third-party packages. Tuned for + // the structured output this builder produces against the dark preview pane. + // ------------------------------------------------------------------------- + + const YAML = { + key: "#79c0ff", + string: "#a5d6ff", + bool: "#ff7b72", + expr: "#d2a8ff", + punct: "#8b949e", + }; + + function highlightValue(value) { + if (value === "true" || value === "false") { + return {value}; + } + if (value.includes("${{")) { + return {value}; + } + return {value}; + } + + // Each line is rendered as its own block element so line breaks survive + // Mintlify's MDX whitespace handling (newline text nodes get collapsed). + function highlightYaml(text) { + const rows = text.replace(/\n$/, "").split("\n"); + return rows.map((line, i) => { + const m = line.match(/^(\s*)(- )?([A-Za-z0-9_.-]+):( ?)(.*)$/); + let content; + if (!m) { + content = line === "" ? " " : line; + } else { + const indent = m[1]; + const dash = m[2]; + const key = m[3]; + const space = m[4]; + const value = m[5]; + content = ( + <> + {indent} + {dash ? {dash} : null} + {key} + : + {space} + {value !== "" ? highlightValue(value) : null} + + ); + } + return ( +
+ {content} +
+ ); + }); + } + + // ------------------------------------------------------------------------- + // State + render + // ------------------------------------------------------------------------- + + const [provReq, setProvReq] = useState(false); + const [provExc, setProvExc] = useState([]); + const [trailReq, setTrailReq] = useState(false); + const [trailExc, setTrailExc] = useState([]); + const [atts, setAtts] = useState([]); + const [copied, setCopied] = useState(false); + + const yaml = useMemo( + () => serializePolicy({ provReq, provExc, trailReq, trailExc, atts }), + [provReq, provExc, trailReq, trailExc, atts] + ); + + const invalidCount = atts.filter((a) => !attestationIsValid(a)).length; + + const copy = () => { + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(yaml).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }); + } + }; + + return ( +
+
+ {/* Provenance */} +
+
Provenance
+ + {provReq && ( +
+ {exceptionList({ + exceptions: provExc, + onChange: setProvExc, + label: "provenance", + })} +
+ )} +
+ + {/* Trail compliance */} +
+
Trail compliance
+ + {trailReq && ( +
+ {exceptionList({ + exceptions: trailExc, + onChange: setTrailExc, + label: "trail-compliance", + })} +
+ )} +
+ + {/* Attestations */} +
+
Required attestations
+ {atts.map((att, i) => + attestationRow({ + att, + index: i, + onChange: (next) => { + const arr = atts.slice(); + arr[i] = next; + setAtts(arr); + }, + onRemove: () => setAtts(atts.filter((_, idx) => idx !== i)), + }) + )} + +
+
+ +
+
+
+ policy.yaml +
+ +
+ {invalidCount > 0 && ( +

+ {invalidCount} attestation rule{invalidCount > 1 ? "s are" : " is"}{" "} + incomplete and excluded from the output. +

+ )} +
{highlightYaml(yaml)}
+
+
+ ); +}; From 5f1491bcc55095099754bf7dc76aa6eb33193696 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dan=20Gr=C3=B8ndahl?= Date: Tue, 16 Jun 2026 17:33:50 +0200 Subject: [PATCH 02/17] fix: address policy builder PR review comments - Drop reference to unreleased `kosli create policy-file` CLI command - Prepend `# yaml-language-server` schema directive to builder output for editor validation/autocomplete - Use stable ids instead of array indices as React keys on dynamic lists --- policy-reference/policy_builder.mdx | 2 +- snippets/policy-builder.jsx | 39 +++++++++++++++++++++++------ 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/policy-reference/policy_builder.mdx b/policy-reference/policy_builder.mdx index 90de7ba..bdbae22 100644 --- a/policy-reference/policy_builder.mdx +++ b/policy-reference/policy_builder.mdx @@ -9,7 +9,7 @@ import { PolicyBuilder } from "/snippets/policy-builder.jsx"; Use this builder to assemble an [environment policy](/policy-reference/environment_policy) without installing the CLI. Toggle the requirements you need, add attestation rules and exceptions, then copy the generated YAML into a file in your repo. -The output conforms to the [v1 policy schema](https://docs.kosli.com/schemas/policy/v1.json) and is equivalent to what the interactive `kosli create policy-file` wizard produces. It runs entirely in your browser — nothing is sent to Kosli. +The output conforms to the [v1 policy schema](https://docs.kosli.com/schemas/policy/v1.json) and runs entirely in your browser — nothing is sent to Kosli. diff --git a/snippets/policy-builder.jsx b/snippets/policy-builder.jsx index 4faed08..9cd25ea 100644 --- a/snippets/policy-builder.jsx +++ b/snippets/policy-builder.jsx @@ -17,8 +17,10 @@ * * The serialization and expression-building logic mirrors the CLI policy * wizard (kosli-dev/cli: internal/policywizard, internal/policy). The YAML - * output is byte-compatible with the CLI's `policy.ToYAML()` (gopkg.in/yaml.v3, - * 4-space indent). Keep this file in sync with the v1 policy schema at + * body mirrors the CLI's `policy.ToYAML()` (gopkg.in/yaml.v3, 4-space indent), + * with a leading `# yaml-language-server` schema directive prepended so the + * pasted file gets validation and autocomplete in schema-aware editors. Keep + * this file in sync with the v1 policy schema at * https://docs.kosli.com/schemas/policy/v1 */ @@ -76,7 +78,10 @@ export const PolicyBuilder = () => { // serializePolicy renders the policy state to a YAML string. Blocks are only // emitted when meaningful, exactly as the CLI wizard builds the policy object. function serializePolicy(state) { - const lines = ["_schema: " + SCHEMA_URL]; + const lines = [ + "# yaml-language-server: $schema=" + SCHEMA_URL + ".json", + "_schema: " + SCHEMA_URL, + ]; const provOn = state.provReq; const trailOn = state.trailReq; @@ -190,8 +195,20 @@ export const PolicyBuilder = () => { // State factories // ------------------------------------------------------------------------- + // uid hands out stable, monotonic ids for React keys on the dynamic term / + // exception / attestation lists. Index keys would let React reuse the wrong + // DOM node when an item is removed from the middle of a list. idBox lives in + // useState's lazy initializer (run once), so it persists across renders + // without a ref hook — only useState/useMemo exist in the Mintlify sandbox. + const idBox = useState(() => ({ n: 0 }))[0]; + function uid() { + idBox.n += 1; + return idBox.n; + } + function newTerm() { return { + id: uid(), kind: "flow_name", negate: false, flowName: "", @@ -208,11 +225,12 @@ export const PolicyBuilder = () => { } function newExpr() { - return { combine: "and", terms: [newTerm()] }; + return { id: uid(), combine: "and", terms: [newTerm()] }; } function newAttestation() { return { + id: uid(), type: "snyk", customType: "", name: "*", @@ -495,7 +513,7 @@ export const PolicyBuilder = () => { {expr.terms.map((term, i) => termEditor({ term, - keyId: i, + keyId: term.id, onChange: (t) => setTerm(i, t), onRemove: () => removeTerm(i), removable: expr.terms.length > 1, @@ -519,7 +537,7 @@ export const PolicyBuilder = () => { return (
{exceptions.map((expr, i) => ( -
+
{label} exception {i + 1} @@ -543,7 +561,7 @@ export const PolicyBuilder = () => { const valid = attestationIsValid(att); const typeValue = att.type === "custom" ? "custom" : att.type; return ( -
+
Attestation {index + 1} @@ -637,6 +655,13 @@ export const PolicyBuilder = () => { function highlightYaml(text) { const rows = text.replace(/\n$/, "").split("\n"); return rows.map((line, i) => { + if (/^\s*#/.test(line)) { + return ( +
+ {line} +
+ ); + } const m = line.match(/^(\s*)(- )?([A-Za-z0-9_.-]+):( ?)(.*)$/); let content; if (!m) { From b73c172f9cb4e53f372a7749c9ace10e8648a1b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dan=20Gr=C3=B8ndahl?= Date: Tue, 16 Jun 2026 17:35:20 +0200 Subject: [PATCH 03/17] style: put attestation type and name on separate lines with bold labels --- snippets/policy-builder.jsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/snippets/policy-builder.jsx b/snippets/policy-builder.jsx index 9cd25ea..3512800 100644 --- a/snippets/policy-builder.jsx +++ b/snippets/policy-builder.jsx @@ -333,6 +333,7 @@ export const PolicyBuilder = () => { }, warn: { color: "#b45309", fontSize: "0.8rem", margin: "0.25rem 0 0.5rem" }, muted: { fontSize: "0.8rem", opacity: 0.7 }, + fieldLabel: { fontWeight: 600, fontSize: "0.85rem", minWidth: "3rem" }, }; // ------------------------------------------------------------------------- @@ -571,7 +572,7 @@ export const PolicyBuilder = () => {
- type + type Date: Tue, 16 Jun 2026 17:36:57 +0200 Subject: [PATCH 04/17] style: widen policy preview to 2/3 of builder width --- snippets/policy-builder.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/snippets/policy-builder.jsx b/snippets/policy-builder.jsx index 3512800..91fd630 100644 --- a/snippets/policy-builder.jsx +++ b/snippets/policy-builder.jsx @@ -254,7 +254,7 @@ export const PolicyBuilder = () => { const S = { wrap: { display: "grid", - gridTemplateColumns: "minmax(0, 1fr) minmax(0, 1fr)", + gridTemplateColumns: "minmax(0, 1fr) minmax(0, 2fr)", gap: "1.25rem", alignItems: "start", }, From 2dfc33259fc9d94b2ff5ce174ead0d5892630faf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dan=20Gr=C3=B8ndahl?= Date: Tue, 16 Jun 2026 17:40:59 +0200 Subject: [PATCH 05/17] style: remove custom attestation type placeholder --- snippets/policy-builder.jsx | 1 - 1 file changed, 1 deletion(-) diff --git a/snippets/policy-builder.jsx b/snippets/policy-builder.jsx index 91fd630..869f5a5 100644 --- a/snippets/policy-builder.jsx +++ b/snippets/policy-builder.jsx @@ -588,7 +588,6 @@ export const PolicyBuilder = () => { {att.type === "custom" && ( set({ customType: e.target.value })} /> From ec9af0db770a377e03755f3fd1a108a802fa416b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dan=20Gr=C3=B8ndahl?= Date: Tue, 16 Jun 2026 17:46:03 +0200 Subject: [PATCH 06/17] style: add bold Term label and keep flow.name value inline --- snippets/policy-builder.jsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/snippets/policy-builder.jsx b/snippets/policy-builder.jsx index 869f5a5..aa7c9bb 100644 --- a/snippets/policy-builder.jsx +++ b/snippets/policy-builder.jsx @@ -346,6 +346,7 @@ export const PolicyBuilder = () => { return (
+ Term set({ flowName: e.target.value })} From ce26c9078961cd40e17625766cafd2e6ee6974cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dan=20Gr=C3=B8ndahl?= Date: Tue, 16 Jun 2026 17:48:02 +0200 Subject: [PATCH 07/17] style: keep flow tag, artifact, and custom term inputs inline --- snippets/policy-builder.jsx | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/snippets/policy-builder.jsx b/snippets/policy-builder.jsx index aa7c9bb..1695bc1 100644 --- a/snippets/policy-builder.jsx +++ b/snippets/policy-builder.jsx @@ -387,15 +387,15 @@ export const PolicyBuilder = () => { {term.kind === "flow_tag" && (
- flow.tags. + flow.tags. set({ tagKey: e.target.value })} /> set({ tagValue: e.target.value })} @@ -415,22 +415,24 @@ export const PolicyBuilder = () => { )} {term.kind === "artifact_name" && ( -
- matches(artifact.name, +
+ + matches(artifact.name, + set({ artifactRegex: e.target.value })} /> - ) + )
)} {term.kind === "custom" && (
{term.customCtx === "flow.tags." && ( set({ customTagKey: e.target.value })} /> )} {term.customOp !== "exists" && ( set({ customValue: e.target.value })} From ecee11d40f24bb792ae8d0d650dfdb0e8ebe1298 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dan=20Gr=C3=B8ndahl?= Date: Tue, 16 Jun 2026 17:57:47 +0200 Subject: [PATCH 08/17] style: 2-space YAML indent, shorter condition label, narrower preview --- snippets/policy-builder.jsx | 34 ++++++++++++++++++---------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/snippets/policy-builder.jsx b/snippets/policy-builder.jsx index 1695bc1..8c95139 100644 --- a/snippets/policy-builder.jsx +++ b/snippets/policy-builder.jsx @@ -16,12 +16,14 @@ * (e.g. {termEditor(...)}) rather than JSX elements. * * The serialization and expression-building logic mirrors the CLI policy - * wizard (kosli-dev/cli: internal/policywizard, internal/policy). The YAML - * body mirrors the CLI's `policy.ToYAML()` (gopkg.in/yaml.v3, 4-space indent), - * with a leading `# yaml-language-server` schema directive prepended so the - * pasted file gets validation and autocomplete in schema-aware editors. Keep - * this file in sync with the v1 policy schema at - * https://docs.kosli.com/schemas/policy/v1 + * wizard (kosli-dev/cli: internal/policywizard, internal/policy) and conforms + * to the v1 policy schema. For readability the YAML uses standard 2-space + * indentation (matching the reference-page examples) and prepends a + * `# yaml-language-server` schema directive so the pasted file gets validation + * and autocomplete in schema-aware editors. Both differ from the CLI's + * `policy.ToYAML()` output (gopkg.in/yaml.v3, 4-space, no directive); the + * documents are schema-equivalent, not byte-identical. Keep this file in sync + * with the v1 policy schema at https://docs.kosli.com/schemas/policy/v1 */ export const PolicyBuilder = () => { @@ -94,15 +96,15 @@ export const PolicyBuilder = () => { lines.push("artifacts:"); const booleanBlock = (key, required, exceptions) => { - lines.push(" " + key + ":"); - lines.push(" required: " + (required ? "true" : "false")); + lines.push(" " + key + ":"); + lines.push(" required: " + (required ? "true" : "false")); const exprs = exceptions .map((e) => serializeExpr(e)) .filter((s) => s !== ""); if (exprs.length > 0) { - lines.push(" exceptions:"); + lines.push(" exceptions:"); exprs.forEach((expr) => { - lines.push(" - if: " + yamlScalar(expr)); + lines.push(" - if: " + yamlScalar(expr)); }); } }; @@ -111,19 +113,19 @@ export const PolicyBuilder = () => { if (trailOn) booleanBlock("trail-compliance", true, state.trailExc); if (validAtts.length > 0) { - lines.push(" attestations:"); + lines.push(" attestations:"); validAtts.forEach((a) => { const type = a.type === "custom" ? "custom:" + a.customType : a.type; - lines.push(" - type: " + yamlScalar(type)); + lines.push(" - type: " + yamlScalar(type)); // `name` defaults to `*` in the v1 schema, so only emit it when the // user requires a specific name. const name = (a.name || "").trim(); if (name !== "" && name !== "*") { - lines.push(" name: " + yamlScalar(name)); + lines.push(" name: " + yamlScalar(name)); } if (a.condEnabled) { const expr = serializeExpr(a.cond); - if (expr !== "") lines.push(" if: " + yamlScalar(expr)); + if (expr !== "") lines.push(" if: " + yamlScalar(expr)); } }); } @@ -254,7 +256,7 @@ export const PolicyBuilder = () => { const S = { wrap: { display: "grid", - gridTemplateColumns: "minmax(0, 1fr) minmax(0, 2fr)", + gridTemplateColumns: "minmax(0, 1fr) minmax(0, 1.5fr)", gap: "1.25rem", alignItems: "start", }, @@ -618,7 +620,7 @@ export const PolicyBuilder = () => { checked={att.condEnabled} onChange={(e) => set({ condEnabled: e.target.checked })} /> - Only require when a condition is met + Require only if {att.condEnabled && (
From e36e49d8d4d6b394f0a8eb718e52c7aa7bfdf852 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dan=20Gr=C3=B8ndahl?= Date: Tue, 16 Jun 2026 18:00:41 +0200 Subject: [PATCH 09/17] style: move checkbox labels to the left of the checkbox --- snippets/policy-builder.jsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/snippets/policy-builder.jsx b/snippets/policy-builder.jsx index 8c95139..7deb452 100644 --- a/snippets/policy-builder.jsx +++ b/snippets/policy-builder.jsx @@ -275,7 +275,8 @@ export const PolicyBuilder = () => { marginBottom: "0.5rem", }, label: { - display: "flex", + display: "inline-flex", + flexDirection: "row-reverse", alignItems: "center", gap: "0.4rem", fontSize: "0.9rem", From 34ed0db0579724154ef9b2a9a8f96d63517921ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dan=20Gr=C3=B8ndahl?= Date: Tue, 16 Jun 2026 18:03:22 +0200 Subject: [PATCH 10/17] style: keep term kind, not, and remove controls on one line --- snippets/policy-builder.jsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/snippets/policy-builder.jsx b/snippets/policy-builder.jsx index 7deb452..a069e78 100644 --- a/snippets/policy-builder.jsx +++ b/snippets/policy-builder.jsx @@ -348,10 +348,10 @@ export const PolicyBuilder = () => { const set = (patch) => onChange({ ...term, ...patch }); return (
-
- Term +
+ Term - {provReq && (
@@ -770,7 +770,7 @@ export const PolicyBuilder = () => { if (!e.target.checked) setTrailExc([]); }} /> - Require trail compliance + Require {trailReq && (
From b71fcd0437a5e5837247893f6d766e8272ab8552 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dan=20Gr=C3=B8ndahl?= Date: Tue, 16 Jun 2026 18:38:04 +0200 Subject: [PATCH 12/17] style: add bold label to custom attestation type input --- snippets/policy-builder.jsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/snippets/policy-builder.jsx b/snippets/policy-builder.jsx index 3aafbb8..54c6c24 100644 --- a/snippets/policy-builder.jsx +++ b/snippets/policy-builder.jsx @@ -349,7 +349,6 @@ export const PolicyBuilder = () => { return (
- Term - {att.type === "custom" && ( +
+ {att.type === "custom" && ( +
+ custom type set({ customType: e.target.value })} /> - )} -
+
+ )}
name Date: Tue, 16 Jun 2026 18:40:57 +0200 Subject: [PATCH 13/17] style: tighten spacing between attestation type, custom type, and name rows --- snippets/policy-builder.jsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/snippets/policy-builder.jsx b/snippets/policy-builder.jsx index 54c6c24..735e55c 100644 --- a/snippets/policy-builder.jsx +++ b/snippets/policy-builder.jsx @@ -337,6 +337,13 @@ export const PolicyBuilder = () => { warn: { color: "#b45309", fontSize: "0.8rem", margin: "0.25rem 0 0.5rem" }, muted: { fontSize: "0.8rem", opacity: 0.7 }, fieldLabel: { fontWeight: 600, fontSize: "0.85rem", minWidth: "3rem" }, + fieldRow: { + display: "flex", + gap: "0.5rem", + alignItems: "center", + flexWrap: "wrap", + marginBottom: "0.2rem", + }, }; // ------------------------------------------------------------------------- @@ -580,7 +587,7 @@ export const PolicyBuilder = () => { Remove
-
+
type
{att.type === "custom" && ( -
+
custom type { />
)} -
+
name Date: Tue, 16 Jun 2026 18:44:34 +0200 Subject: [PATCH 14/17] style: sort attestation type options alphabetically, keeping * last --- snippets/policy-builder.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/snippets/policy-builder.jsx b/snippets/policy-builder.jsx index 735e55c..a586f69 100644 --- a/snippets/policy-builder.jsx +++ b/snippets/policy-builder.jsx @@ -31,10 +31,10 @@ export const PolicyBuilder = () => { const BUILTIN_TYPES = [ "generic", + "jira", "junit", - "snyk", "pull_request", - "jira", + "snyk", "sonar", "*", ]; From 741b6e5d6f66c8695e19b67a09124149090930aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dan=20Gr=C3=B8ndahl?= Date: Tue, 16 Jun 2026 18:47:07 +0200 Subject: [PATCH 15/17] style: move wildcard type option to the end of the dropdown --- snippets/policy-builder.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/snippets/policy-builder.jsx b/snippets/policy-builder.jsx index a586f69..7cbf4df 100644 --- a/snippets/policy-builder.jsx +++ b/snippets/policy-builder.jsx @@ -36,7 +36,6 @@ export const PolicyBuilder = () => { "pull_request", "snyk", "sonar", - "*", ]; const TERM_KINDS = [ @@ -600,6 +599,7 @@ export const PolicyBuilder = () => { ))} +
{att.type === "custom" && ( From 3580f92c456034f699b909bb40aae8a66dbc664a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dan=20Gr=C3=B8ndahl?= Date: Tue, 16 Jun 2026 18:56:53 +0200 Subject: [PATCH 16/17] docs: drop CLI mention from policy builder intro --- policy-reference/policy_builder.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/policy-reference/policy_builder.mdx b/policy-reference/policy_builder.mdx index bdbae22..d2b9fc6 100644 --- a/policy-reference/policy_builder.mdx +++ b/policy-reference/policy_builder.mdx @@ -7,7 +7,7 @@ mode: "wide" import { PolicyBuilder } from "/snippets/policy-builder.jsx"; -Use this builder to assemble an [environment policy](/policy-reference/environment_policy) without installing the CLI. Toggle the requirements you need, add attestation rules and exceptions, then copy the generated YAML into a file in your repo. +Use this builder to assemble an [environment policy](/policy-reference/environment_policy). Toggle the requirements you need, add attestation rules and exceptions, then copy the generated YAML into a file in your repo. The output conforms to the [v1 policy schema](https://docs.kosli.com/schemas/policy/v1.json) and runs entirely in your browser — nothing is sent to Kosli. From 42b1cc1c3afd3ec7805eb6dd76354543a3ad2bb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dan=20Gr=C3=B8ndahl?= Date: Tue, 16 Jun 2026 19:11:38 +0200 Subject: [PATCH 17/17] docs: rename to Environment Policy Builder and drop sidebar icon --- policy-reference/policy_builder.mdx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/policy-reference/policy_builder.mdx b/policy-reference/policy_builder.mdx index d2b9fc6..26a0046 100644 --- a/policy-reference/policy_builder.mdx +++ b/policy-reference/policy_builder.mdx @@ -1,7 +1,6 @@ --- -title: "Policy builder" +title: "Environment Policy Builder" description: "Build a Kosli environment policy YAML interactively in the browser and copy the result into your repo." -icon: "wand-magic-sparkles" mode: "wide" ---