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..26a0046 --- /dev/null +++ b/policy-reference/policy_builder.mdx @@ -0,0 +1,24 @@ +--- +title: "Environment Policy Builder" +description: "Build a Kosli environment policy YAML interactively in the browser and copy the result into your repo." +mode: "wide" +--- + +import { PolicyBuilder } from "/snippets/policy-builder.jsx"; + +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. + + + +## 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..7cbf4df --- /dev/null +++ b/snippets/policy-builder.jsx @@ -0,0 +1,839 @@ +/* + * 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) 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 = () => { + const SCHEMA_URL = "https://docs.kosli.com/schemas/policy/v1"; + + const BUILTIN_TYPES = [ + "generic", + "jira", + "junit", + "pull_request", + "snyk", + "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 = [ + "# yaml-language-server: $schema=" + SCHEMA_URL + ".json", + "_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 + // ------------------------------------------------------------------------- + + // 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: "", + tagKey: "", + tagOp: "==", + tagValue: "", + artifactRegex: "", + customCtx: "flow.name", + customTagKey: "", + customOp: "==", + customValue: "", + raw: "", + }; + } + + function newExpr() { + return { id: uid(), combine: "and", terms: [newTerm()] }; + } + + function newAttestation() { + return { + id: uid(), + 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, 1.5fr)", + 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: "inline-flex", + flexDirection: "row-reverse", + 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 }, + fieldLabel: { fontWeight: 600, fontSize: "0.85rem", minWidth: "3rem" }, + fieldRow: { + display: "flex", + gap: "0.5rem", + alignItems: "center", + flexWrap: "wrap", + marginBottom: "0.2rem", + }, + }; + + // ------------------------------------------------------------------------- + // 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: term.id, + 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" && ( +
+ custom type + 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) => { + if (/^\s*#/.test(line)) { + return ( +
+ {line} +
+ ); + } + 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)}
+
+
+ ); +};