diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9464fa0b9..17df71e29 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -400,6 +400,8 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5.0.1 - run: helm lint deploy/helm/kars + - name: Preserve task admission defaults with reused legacy values + run: python3 ci/helm-task-floor-compat.py - name: Render installation profiles run: | helm template kars deploy/helm/kars --namespace kars-system >/dev/null diff --git a/ci/helm-task-floor-compat.py b/ci/helm-task-floor-compat.py new file mode 100644 index 000000000..522b46b8d --- /dev/null +++ b/ci/helm-task-floor-compat.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Render task admission against reused values, without new-default coalescing.""" + +import io +import itertools +import os +from pathlib import Path +import subprocess +import tarfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +TEMPLATES = ROOT / "deploy/helm/kars/templates" +OLD_VALUES = (ROOT / "tests/compat/fixtures/task-floor-old-values.yaml").read_text() +ARCHIVE_IDS = itertools.count() +FLOOR = "admission-task-namespace-floor.yaml" + + +def render(values, legacy_templates=False): + # The saved release values are the chart defaults in this isolated archive. + # Passing -f to the current chart would merge in its new defaults and hide + # the --reuse-values nil-map regression. + files = { + "Chart.yaml": "apiVersion: v2\nname: task-floor-compat\nversion: 0.1.0\n", + "values.yaml": values, + f"templates/{FLOOR}": (TEMPLATES / FLOOR).read_text(), + } + if legacy_templates: + for name in ["admission-pod-exec-ban.yaml", "admission-sandbox-posture-lock.yaml"]: + files[f"templates/{name}"] = (TEMPLATES / name).read_text() + archive = Path(f".task-floor-compat-{os.getpid()}-{next(ARCHIVE_IDS)}.tgz") + output = archive.open("xb") + try: + with output, tarfile.open(fileobj=output, mode="w:gz") as package: + for name, content in files.items(): + data = content.encode() + info = tarfile.TarInfo(f"task-floor-compat/{name}") + info.size = len(data) + package.addfile(info, io.BytesIO(data)) + return subprocess.run( + ["helm", "template", "kars", str(archive), "--namespace", "kars-system"], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + finally: + archive.unlink() + + +class TaskFloorReuseValues(unittest.TestCase): + def assert_floor(self, result): + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("name: kars-task-namespace-floor\n", result.stdout) + self.assertIn("name: kars-task-namespace-floor-binding\n", result.stdout) + self.assertIn("failurePolicy: Fail", result.stdout) + self.assertIn("validationActions: [Deny, Audit]", result.stdout) + + def test_old_values_enable_new_floor_without_resetting_old_flags(self): + result = render(OLD_VALUES, legacy_templates=True) + self.assert_floor(result) + self.assertNotIn("name: kars-sandbox-exec-ban", result.stdout) + self.assertIn("name: kars-sandbox-posture-lock\n", result.stdout) + + def test_absent_parent_map_enables_floor(self): + self.assert_floor(render("{}\n")) + + def test_null_parent_map_enables_floor(self): + self.assert_floor(render("admission: null\n")) + + def test_absent_floor_map_enables_floor(self): + self.assert_floor(render("admission: {}\n")) + + def test_null_floor_map_enables_floor(self): + self.assert_floor(render("admission:\n taskNamespaceFloor: null\n")) + + def test_absent_enabled_flag_enables_floor(self): + self.assert_floor(render("admission:\n taskNamespaceFloor: {}\n")) + + def test_null_enabled_flag_enables_floor(self): + self.assert_floor(render("admission:\n taskNamespaceFloor:\n enabled: null\n")) + + def test_explicit_true_enables_floor(self): + self.assert_floor(render("admission:\n taskNamespaceFloor:\n enabled: true\n")) + + def test_existing_explicit_false_is_preserved(self): + result = render(OLD_VALUES + " taskNamespaceFloor:\n enabled: false\n", True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertNotIn("name: kars-task-namespace-floor", result.stdout) + self.assertNotIn("name: kars-sandbox-exec-ban", result.stdout) + self.assertIn("name: kars-sandbox-posture-lock\n", result.stdout) + + def test_non_boolean_flag_fails_instead_of_disabling_security(self): + for value in ['"false"', "0"]: + with self.subTest(value=value): + result = render(f"admission:\n taskNamespaceFloor:\n enabled: {value}\n") + self.assertNotEqual(result.returncode, 0) + self.assertIn("enabled must be a boolean", result.stderr) + + +if __name__ == "__main__": + if Path.cwd().resolve() != ROOT: + raise SystemExit("Run this test from the repository root.") + unittest.main() diff --git a/ci/no-custom-crypto.sh b/ci/no-custom-crypto.sh index d4ef2497e..8e282611c 100755 --- a/ci/no-custom-crypto.sh +++ b/ci/no-custom-crypto.sh @@ -18,6 +18,8 @@ cd "$REPO_ROOT" ALLOW_PATHS=( 'controller/src/providers/signing.rs' + 'controller/src/kars_receipt_log.rs' # receipt inclusion log — Sha256 Merkle-style hash chaining of receipt payload digests (transparency-log precursor); standard linkage, no bespoke crypto protocol. Tracked for the V2 external-witness upgrade. + 'controller/src/kars_task.rs' # KarsTask envelope digest — Sha256 content-hash over canonical JSON (authority-binding identifier), not a crypto protocol. The Governance Receipt (kars_receipt.rs) binds its subject to this digest; signing itself stays in providers/signing.rs. 'controller/src/providers/mesh.rs' 'controller/src/mesh_peer/' # in-tree controller-side mesh peer hashing/signing — uses ed25519-dalek::SigningKey + Sha256 only; tracked for SigningProvider extraction in plan §4.1 'inference-router/src/providers/signing.rs' diff --git a/cli/src/cli.ts b/cli/src/cli.ts index a018a62d9..63940a39f 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -30,6 +30,8 @@ import { pairCommand } from "./commands/pair.js"; import { convertCommand } from "./commands/convert.js"; import { a2aCommand, a2aAgentCommand } from "./commands/a2a.js"; import { attestCommand } from "./commands/attest.js"; +import { receiptCommand } from "./commands/receipt.js"; +import { approvalCommand } from "./commands/approval.js"; import { migrateCommand } from "./commands/migrate.js"; import { toolPolicyCommand } from "./commands/toolpolicy.js"; import { inferencePolicyCommand } from "./commands/inferencepolicy.js"; @@ -100,10 +102,14 @@ export function createCli(): Command { // Attestation program.addCommand(attestCommand()); + program.addCommand(receiptCommand()); // Self-management program.addCommand(updateCommand()); + // Steering + program.addCommand(approvalCommand()); + program.addHelpText("after", ` Command groups: Lifecycle up, dev, add, push, destroy @@ -113,8 +119,9 @@ Command groups: Agent mobility handoff, mesh, pair Interop convert, a2a, a2a-agent, migrate Governance toolpolicy, inferencepolicy, mcp, memory - Attestation attest + Attestation attest, receipt Self update + Steering approval Quick start: kars up # Provision Azure + deploy controller + first sandbox diff --git a/cli/src/commands/approval.test.ts b/cli/src/commands/approval.test.ts new file mode 100644 index 000000000..9acd7f24f --- /dev/null +++ b/cli/src/commands/approval.test.ts @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect } from "vitest"; +import { __test } from "./approval.js"; + +const { defaultDecider, formatList } = __test; + +describe("approval — defaultDecider", () => { + it("uses the explicit --by when given", () => { + expect(defaultDecider("alice@example.com")).toBe("alice@example.com"); + }); + + it("trims whitespace and falls back to the OS user when blank", () => { + expect(defaultDecider(" bob ")).toBe("bob"); + // Blank → some non-empty username (OS-dependent, just assert non-empty). + expect(defaultDecider(" ").length).toBeGreaterThan(0); + expect(defaultDecider(undefined).length).toBeGreaterThan(0); + }); +}); + +describe("approval — formatList", () => { + it("renders an empty state", () => { + expect(formatList([])).toContain("No approvals"); + }); + + it("renders task, action, and decision metadata", () => { + const out = formatList([ + { + metadata: { name: "raise-tier", namespace: "kars-system" }, + spec: { + taskRef: { name: "migrate" }, + action: { kind: "tierRaise", summary: "raise to tier 4" }, + }, + status: { phase: "Approved", decider: "alice", decidedAt: "2026-06-26T10:00:00Z" }, + }, + ]); + expect(out).toContain("raise-tier"); + expect(out).toContain("migrate"); + expect(out).toContain("tierRaise"); + expect(out).toContain("alice"); + }); +}); diff --git a/cli/src/commands/approval.ts b/cli/src/commands/approval.ts new file mode 100644 index 000000000..99321141d --- /dev/null +++ b/cli/src/commands/approval.ts @@ -0,0 +1,207 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// kars Bridge Inc 4 — `kars approval` CLI subcommand. +// +// The steering primitive on the command line: list the human decisions a task +// fleet is waiting on, and approve / deny them. Works on a plain kars cluster +// (no Bridge) — a KarsApproval is a first-class kars CRD. +// +// kars approval list [-n ns] [--task ] [--pending] +// kars approval approve [-n ns] [--by ] [--reason ] +// kars approval deny [-n ns] [--by ] [--reason ] +// kars approval show [-n ns] +// +// approve/deny patch spec.decision; the controller is the sole writer of +// status and drives the terminal transition + records the decision immutably. + +import { Command } from "commander"; +import chalk from "chalk"; +import { userInfo } from "node:os"; + +interface ApprovalCr { + metadata?: { name?: string; namespace?: string }; + spec?: { + taskRef?: { name?: string }; + action?: { kind?: string; summary?: string; detail?: string; requestedTier?: number }; + ttl?: string; + decision?: { verdict?: string; decider?: string; reason?: string }; + }; + status?: { + phase?: string; + decider?: string; + decidedAt?: string; + expiresAt?: string; + boundEnvelopeDigest?: string; + }; +} + +async function kubectlJson(args: string[]): Promise { + const { execa } = await import("execa"); + try { + const { stdout } = await execa("kubectl", [...args, "-o", "json"], { stdio: "pipe" }); + return JSON.parse(stdout); + } catch { + return null; + } +} + +function phaseBadge(phase: string | undefined): string { + switch (phase) { + case "Approved": + return chalk.green("Approved"); + case "Denied": + return chalk.red("Denied"); + case "Pending": + return chalk.yellow("Pending"); + case "Expired": + return chalk.gray("Expired"); + case "Stale": + return chalk.magenta("Stale"); + default: + return phase ?? "—"; + } +} + +function defaultDecider(by?: string): string { + if (by && by.trim()) return by.trim(); + try { + return userInfo().username || "unknown"; + } catch { + return "unknown"; + } +} + +/** Patch spec.decision via a strategic-merge patch. */ +async function decide( + name: string, + namespace: string, + verdict: "approve" | "deny", + decider: string, + reason: string | undefined, +): Promise { + const { execa } = await import("execa"); + const decision: Record = { verdict, decider }; + if (reason && reason.trim()) decision.reason = reason.trim(); + const patch = JSON.stringify({ spec: { decision } }); + try { + await execa( + "kubectl", + ["patch", "karsapproval", name, "-n", namespace, "--type", "merge", "-p", patch], + { stdio: "pipe" }, + ); + return true; + } catch (e) { + process.stderr.write(chalk.red(`✗ failed to ${verdict} '${name}': ${(e as Error).message}\n`)); + return false; + } +} + +function formatList(items: ApprovalCr[]): string { + if (items.length === 0) return chalk.dim(" No approvals.\n"); + const lines: string[] = [""]; + for (const a of items) { + const name = a.metadata?.name ?? "?"; + const task = a.spec?.taskRef?.name ?? "?"; + const kind = a.spec?.action?.kind ?? "custom"; + const summary = a.spec?.action?.summary ?? ""; + lines.push(` ${phaseBadge(a.status?.phase).padEnd(18)} ${chalk.bold(name)}`); + lines.push(` ${chalk.dim("task")} ${task} ${chalk.dim("action")} ${kind}`); + if (summary) lines.push(` ${summary}`); + if (a.status?.decider) { + lines.push(` ${chalk.dim("decided by")} ${a.status.decider}${a.status.decidedAt ? ` ${chalk.dim("at")} ${a.status.decidedAt}` : ""}`); + } else if (a.status?.expiresAt) { + lines.push(` ${chalk.dim("expires")} ${a.status.expiresAt}`); + } + lines.push(""); + } + return lines.join("\n"); +} + +export function approvalCommand(): Command { + const cmd = new Command("approval"); + cmd.description( + "Steer the fleet: list, approve, and deny the human decisions (HITL " + + "approvals) a KarsTask is waiting on.", + ); + + cmd + .command("list") + .description("List approvals in a namespace.") + .option("-n, --namespace ", "Namespace", "kars-system") + .option("--task ", "Only approvals gating this task") + .option("--pending", "Only undecided (Pending) approvals") + .option("--format ", "Output format: 'human' (default) or 'json'", "human") + .action( + async (options: { namespace: string; task?: string; pending?: boolean; format: string }) => { + const list = (await kubectlJson([ + "get", + "karsapproval", + "-n", + options.namespace, + ])) as { items?: ApprovalCr[] } | null; + let items = list?.items ?? []; + if (options.task) items = items.filter((a) => a.spec?.taskRef?.name === options.task); + if (options.pending) items = items.filter((a) => a.status?.phase === "Pending"); + if (options.format === "json") { + console.log(JSON.stringify(items, null, 2)); + } else { + console.log(formatList(items)); + } + }, + ); + + cmd + .command("approve") + .description("Approve an approval the fleet is waiting on.") + .argument("", "KarsApproval name") + .option("-n, --namespace ", "Namespace", "kars-system") + .option("--by ", "Decider identity (defaults to your OS username)") + .option("--reason ", "Justification recorded in the receipt") + .action(async (name: string, options: { namespace: string; by?: string; reason?: string }) => { + const decider = defaultDecider(options.by); + const ok = await decide(name, options.namespace, "approve", decider, options.reason); + if (!ok) process.exit(1); + console.log(chalk.green(`✓ approved ${name} (as ${decider})`)); + console.log(chalk.dim(" The controller will record the decision and update the receipt.")); + }); + + cmd + .command("deny") + .description("Deny an approval the fleet is waiting on.") + .argument("", "KarsApproval name") + .option("-n, --namespace ", "Namespace", "kars-system") + .option("--by ", "Decider identity (defaults to your OS username)") + .option("--reason ", "Justification recorded in the receipt") + .action(async (name: string, options: { namespace: string; by?: string; reason?: string }) => { + const decider = defaultDecider(options.by); + const ok = await decide(name, options.namespace, "deny", decider, options.reason); + if (!ok) process.exit(1); + console.log(chalk.yellow(`✓ denied ${name} (as ${decider})`)); + }); + + cmd + .command("show") + .description("Print the raw KarsApproval CR.") + .argument("", "KarsApproval name") + .option("-n, --namespace ", "Namespace", "kars-system") + .action(async (name: string, options: { namespace: string }) => { + const cr = (await kubectlJson([ + "get", + "karsapproval", + name, + "-n", + options.namespace, + ])) as ApprovalCr | null; + if (!cr) { + process.stderr.write(chalk.red(`✗ approval '${name}' not found in '${options.namespace}'.\n`)); + process.exit(4); + return; + } + console.log(JSON.stringify(cr, null, 2)); + }); + + return cmd; +} + +export const __test = { defaultDecider, formatList }; diff --git a/cli/src/commands/receipt.test.ts b/cli/src/commands/receipt.test.ts new file mode 100644 index 000000000..def39f9d0 --- /dev/null +++ b/cli/src/commands/receipt.test.ts @@ -0,0 +1,216 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect } from "vitest"; +import { generateKeyPairSync, sign as cryptoSign, createHash } from "node:crypto"; +import { __test } from "./receipt.js"; + +const { pae, verifyReceipt } = __test; + +const DSSE_PAYLOAD_TYPE = "application/vnd.in-toto+json"; + +/** Build a self-signed receipt + matching anchor, mirroring the controller. */ +function makeSignedReceipt(overrides?: { tamperPayload?: boolean; wrongKey?: boolean }) { + const { publicKey, privateKey } = generateKeyPairSync("ed25519"); + const rawPub = publicKey.export({ type: "spki", format: "der" }).subarray(-32); + const keyId = createHash("sha256").update(rawPub).digest("hex"); + + const envelopeDigest = "sha256:deadbeefdeadbeefdeadbeefdeadbeef"; + const statement = { + _type: "https://in-toto.io/Statement/v1", + subject: [ + { + name: "kars-system/demo", + digest: { sha256: "deadbeefdeadbeefdeadbeefdeadbeef" }, + }, + ], + predicateType: "https://kars.azure.com/attestations/GovernanceReceipt/v0", + predicate: { + task: { name: "demo", namespace: "kars-system" }, + envelope: { digest: envelopeDigest }, + issuer: { keyId, scheme: "DSSEv1+ed25519" }, + claims: [ + { class: "integrity", status: "PASS", detail: "signed" }, + { class: "completeness", status: "PARTIAL", detail: "governance only" }, + ], + }, + }; + const payloadBody = Buffer.from(JSON.stringify(statement), "utf8"); + const message = pae(DSSE_PAYLOAD_TYPE, payloadBody); + const signature = cryptoSign(null, message, privateKey); + + const payloadB64 = overrides?.tamperPayload + ? Buffer.from(JSON.stringify({ ...statement, subject: [{ name: "evil" }] }), "utf8").toString("base64") + : payloadBody.toString("base64"); + + const receipt = { + metadata: { name: "demo", namespace: "kars-system" }, + spec: { + taskRef: { name: "demo" }, + envelopeDigest, + predicateType: statement.predicateType, + scheme: "DSSEv1+ed25519", + keyId, + dsse: { + payload: payloadB64, + payloadType: DSSE_PAYLOAD_TYPE, + signatures: [{ keyid: keyId, sig: signature.toString("base64") }], + }, + claims: statement.predicate.claims, + }, + }; + + const anchorKeyId = overrides?.wrongKey + ? createHash("sha256").update(Buffer.alloc(32, 7)).digest("hex") + : keyId; + const anchorPub = overrides?.wrongKey + ? generateKeyPairSync("ed25519").publicKey.export({ type: "spki", format: "der" }).subarray(-32) + : rawPub; + + const anchor = { + keyId: anchorKeyId, + publicKey: Buffer.from(anchorPub).toString("base64"), + scheme: "DSSEv1+ed25519", + payloadType: DSSE_PAYLOAD_TYPE, + }; + + return { receipt, anchor }; +} + +describe("receipt verify — PAE", () => { + it("matches the DSSE framing byte-for-byte", () => { + const got = pae("application/vnd.in-toto+json", Buffer.from("{}")); + expect(got.toString("latin1")).toBe("DSSEv1 28 application/vnd.in-toto+json 2 {}"); + }); +}); + +describe("receipt verify — verifyReceipt", () => { + it("verifies a well-formed, correctly-signed receipt", () => { + const { receipt, anchor } = makeSignedReceipt(); + const res = verifyReceipt(receipt, anchor); + expect(res.ok).toBe(true); + expect(res.checks.find((c) => c.name === "signature")?.ok).toBe(true); + expect(res.checks.find((c) => c.name === "envelopeBinding")?.ok).toBe(true); + expect(res.checks.find((c) => c.name === "keyBinding")?.ok).toBe(true); + expect(res.claims).toHaveLength(2); + }); + + it("fails when the payload was tampered after signing", () => { + const { receipt, anchor } = makeSignedReceipt({ tamperPayload: true }); + const res = verifyReceipt(receipt, anchor); + expect(res.ok).toBe(false); + expect(res.checks.find((c) => c.name === "signature")?.ok).toBe(false); + }); + + it("fails when signed by a key the anchor does not trust", () => { + const { receipt, anchor } = makeSignedReceipt({ wrongKey: true }); + const res = verifyReceipt(receipt, anchor); + expect(res.ok).toBe(false); + // Either key binding or signature fails — both are unacceptable. + const keyBinding = res.checks.find((c) => c.name === "keyBinding")?.ok; + const sig = res.checks.find((c) => c.name === "signature")?.ok; + expect(keyBinding && sig).toBeFalsy(); + }); + + it("fails when the receipt carries no DSSE envelope", () => { + const res = verifyReceipt( + { metadata: { name: "x", namespace: "kars-system" }, spec: {} }, + { keyId: "k", publicKey: "", scheme: "DSSEv1+ed25519", payloadType: DSSE_PAYLOAD_TYPE }, + ); + expect(res.ok).toBe(false); + }); + + it("rejects forged PASS echoes while returning only signed PARTIAL claims", () => { + const { receipt, anchor } = makeSignedReceipt(); + receipt.spec.claims = receipt.spec.claims.map((claim) => ({ ...claim, status: "PASS" })); + const result = verifyReceipt(receipt, anchor); + expect(result.checks.find((c) => c.name === "signature")?.ok).toBe(true); + expect(result.checks.find((c) => c.name === "claimsBinding")?.ok).toBe(false); + expect(result.ok).toBe(false); + expect(result.claims.find((c) => c.class === "completeness")?.status).toBe("PARTIAL"); + }); + + it("derives claims from the payload when the unsigned echo is absent", () => { + const { receipt, anchor } = makeSignedReceipt(); + const { claims: _, ...spec } = receipt.spec; + const result = verifyReceipt({ ...receipt, spec }, anchor); + expect(result.ok).toBe(true); + expect(result.claims).toHaveLength(2); + }); + + it("does not expose claims from an invalidly signed payload", () => { + const { receipt, anchor } = makeSignedReceipt({ tamperPayload: true }); + expect(verifyReceipt(receipt, anchor).claims).toEqual([]); + }); + + it.each(["taskRef", "metadata", "namespace", "predicateType", "scheme", "envelopeDigest"])( + "rejects a mismatched %s echo without needing a forged signature", + (field) => { + const { receipt, anchor } = makeSignedReceipt(); + if (field === "taskRef") receipt.spec.taskRef.name = "victim"; + else if (field === "metadata") receipt.metadata.name = "victim"; + else if (field === "namespace") receipt.metadata.namespace = "victim"; + else if (field === "predicateType") receipt.spec.predicateType = "evil"; + else if (field === "scheme") receipt.spec.scheme = "unsigned"; + else receipt.spec.envelopeDigest = ""; + const result = verifyReceipt(receipt, anchor); + expect(result.checks.find((c) => c.name === "signature")?.ok).toBe(true); + expect(result.ok).toBe(false); + }, + ); +}); + +describe("receipt verify — inclusion chain", () => { + function buildChain(receipts: Array<{ receipt: string; payloadSha256: string }>) { + let prev = "genesis"; + return receipts.map((r, i) => { + const entryHash = __test.inclusionEntryHash(i, r.receipt, r.payloadSha256, prev); + const e = { seq: i, receipt: r.receipt, payloadSha256: r.payloadSha256, prevHash: prev, entryHash }; + prev = entryHash; + return e; + }); + } + + it("accepts an intact chain", () => { + const chain = buildChain([ + { receipt: "ns/a", payloadSha256: "sha-a" }, + { receipt: "ns/b", payloadSha256: "sha-b" }, + ]); + expect(__test.verifyInclusionChain(chain)).toBeNull(); + expect(__test.verifyInclusionChain([])).toBeNull(); + }); + + it("detects a tampered payload digest", () => { + const chain = buildChain([ + { receipt: "ns/a", payloadSha256: "sha-a" }, + { receipt: "ns/b", payloadSha256: "sha-b" }, + ]); + chain[0].payloadSha256 = "evil"; + expect(__test.verifyInclusionChain(chain)).toBe(0); + }); + + it("detects a deleted entry", () => { + const chain = buildChain([ + { receipt: "ns/a", payloadSha256: "sha-a" }, + { receipt: "ns/b", payloadSha256: "sha-b" }, + { receipt: "ns/c", payloadSha256: "sha-c" }, + ]); + chain.splice(1, 1); + expect(__test.verifyInclusionChain(chain)).not.toBeNull(); + }); +}); + +describe("receipt checkpoint — note + root", () => { + it("builds the canonical signed-note body", () => { + expect(__test.checkpointNote(5, "abc")).toBe("kars-receipt-log\n5\nabc\n"); + }); + + it("chainRoot is the head entry hash or genesis", () => { + expect(__test.chainRoot([])).toBe("genesis"); + const chain = [ + { seq: 0, receipt: "ns/a", payloadSha256: "s0", prevHash: "genesis", entryHash: "h0" }, + { seq: 1, receipt: "ns/b", payloadSha256: "s1", prevHash: "h0", entryHash: "h1" }, + ]; + expect(__test.chainRoot(chain)).toBe("h1"); + }); +}); diff --git a/cli/src/commands/receipt.ts b/cli/src/commands/receipt.ts new file mode 100644 index 000000000..7ef544f35 --- /dev/null +++ b/cli/src/commands/receipt.ts @@ -0,0 +1,711 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// kars Bridge Inc 3 — `kars receipt` CLI subcommand. +// +// The Governance Receipt is a signed, independently-verifiable record that a +// `KarsTask` was governed under a specific trust envelope. This command is the +// **auditor's tool**: it verifies the cryptographic signature against an +// out-of-band trust anchor — it does not trust the Bridge UI, the receipt's +// own embedded fields, or anything but the controller's published public key. +// +// `kars receipt verify `: +// 1. Reads the `KarsReceipt` CR for the task. +// 2. Reads the trust anchor (`kars-receipt-pubkey` ConfigMap in +// `kars-system`) — the controller's public key, published out of band. +// 3. Reconstructs the DSSE Pre-Authentication Encoding over the signed +// in-toto Statement and verifies the Ed25519 signature. +// 4. Cross-checks that the signed subject digest matches the receipt's +// claimed `envelopeDigest`, and that the signing `keyid` matches the +// anchor — defeating a forged receipt that swaps in its own key. +// 5. Prints the claim matrix (integrity / conformance / completeness / +// regulatory) verbatim and an overall verdict. Exits non-zero on any +// signature, anchor, or binding failure. +// +// This works on a **plain kars cluster with no Bridge installed** — the +// receipt is a kars primitive. + +import { Command } from "commander"; +import chalk from "chalk"; +import { createHash, createPublicKey, verify as cryptoVerify } from "node:crypto"; +import { isDeepStrictEqual } from "node:util"; + +function anchorNamespace(): string { + return process.env.KARS_NAMESPACE?.trim() || process.env.POD_NAMESPACE?.trim() || "kars-system"; +} +const ANCHOR_CONFIGMAP = "kars-receipt-pubkey"; +const LOG_CONFIGMAP = "kars-receipt-log"; +const CHECKPOINT_CONFIGMAP = "kars-receipt-checkpoint"; +const CHECKPOINT_ORIGIN = "kars-receipt-log"; +const DSSE_PAYLOAD_TYPE = "application/vnd.in-toto+json"; +const PREDICATE_TYPE = "https://kars.azure.com/attestations/GovernanceReceipt/v0"; +const SIGNING_SCHEME = "DSSEv1+ed25519"; +// Fixed ASN.1/DER SubjectPublicKeyInfo prefix for an Ed25519 public key +// (RFC 8410). Prepending it to the 32 raw key bytes yields a SPKI DER that +// Node's crypto can import. +const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex"); + +interface DsseSignature { + keyid: string; + sig: string; +} + +interface DsseEnvelope { + payload: string; + payloadType: string; + signatures: DsseSignature[]; +} + +interface Claim { + class: string; + status: string; + detail: string; +} + +interface ReceiptSpec { + taskRef?: { name?: string }; + envelopeDigest?: string; + predicateType?: string; + scheme?: string; + keyId?: string; + dsse?: DsseEnvelope; + claims?: Claim[]; +} + +interface ReceiptCr { + metadata?: { name?: string; namespace?: string; creationTimestamp?: string }; + spec?: ReceiptSpec; + status?: { issuedAt?: string; observedTaskGeneration?: number }; +} + +interface TrustAnchor { + keyId: string; + publicKey: string; // base64, 32 raw Ed25519 bytes + scheme: string; + payloadType: string; +} + +export interface VerifyResult { + ok: boolean; + task: string; + namespace: string; + keyId: string; + envelopeDigest: string | null; + /** Per-check pass/fail with human reasons. */ + checks: Array<{ name: string; ok: boolean; detail: string }>; + claims: Claim[]; + /** The decoded in-toto Statement, for `--format json` consumers. */ + statement: unknown; +} + +/** + * DSSE Pre-Authentication Encoding, byte-identical to the Rust emitter: + * `"DSSEv1 " len(type) " " type " " len(body) " " body`. Lengths are byte + * lengths. + */ +export function pae(payloadType: string, body: Buffer): Buffer { + const typeBytes = Buffer.from(payloadType, "utf8"); + return Buffer.concat([ + Buffer.from("DSSEv1 ", "utf8"), + Buffer.from(String(typeBytes.length), "utf8"), + Buffer.from(" ", "utf8"), + typeBytes, + Buffer.from(" ", "utf8"), + Buffer.from(String(body.length), "utf8"), + Buffer.from(" ", "utf8"), + body, + ]); +} + +/** Import 32 raw Ed25519 public-key bytes as a verifiable KeyObject. */ +function importEd25519PublicKey(raw: Buffer) { + const der = Buffer.concat([ED25519_SPKI_PREFIX, raw]); + return createPublicKey({ key: der, format: "der", type: "spki" }); +} + +/** + * Verify a receipt against a trust anchor. Pure (no I/O) so it is unit + * testable; the command wires it to `kubectl`. + */ +export function verifyReceipt(receipt: ReceiptCr, anchor: TrustAnchor): VerifyResult { + const spec = receipt.spec ?? {}; + const task = receipt.metadata?.name ?? spec.taskRef?.name ?? "(unknown)"; + const namespace = receipt.metadata?.namespace ?? "(unknown)"; + const checks: VerifyResult["checks"] = []; + + const dsse = spec.dsse; + let statement: unknown = null; + let payloadBody: Buffer | null = null; + let signatureVerified = false; + + if (!dsse || !Array.isArray(dsse.signatures) || dsse.signatures.length === 0) { + checks.push({ name: "envelope", ok: false, detail: "receipt has no DSSE envelope or signatures" }); + } else { + payloadBody = Buffer.from(dsse.payload ?? "", "base64"); + try { + statement = JSON.parse(payloadBody.toString("utf8")); + checks.push({ name: "payload", ok: true, detail: "in-toto Statement decoded" }); + } catch { + checks.push({ name: "payload", ok: false, detail: "DSSE payload is not valid JSON" }); + } + + // Payload type must match what we sign over. + const ptOk = dsse.payloadType === DSSE_PAYLOAD_TYPE; + checks.push({ + name: "payloadType", + ok: ptOk, + detail: ptOk ? DSSE_PAYLOAD_TYPE : `unexpected payloadType '${dsse.payloadType}'`, + }); + + // Key binding: the signature keyid and the anchor must agree, and match + // the receipt's declared keyId. This is what stops a forged receipt from + // shipping its own key. + const sig = dsse.signatures[0]; + const keyMatchesAnchor = sig.keyid === anchor.keyId; + const declaredMatches = !spec.keyId || spec.keyId === anchor.keyId; + checks.push({ + name: "keyBinding", + ok: keyMatchesAnchor && declaredMatches, + detail: + keyMatchesAnchor && declaredMatches + ? `signed by trusted anchor ${anchor.keyId.slice(0, 16)}…` + : `keyid mismatch (sig=${sig.keyid.slice(0, 16)}… anchor=${anchor.keyId.slice(0, 16)}…)`, + }); + + // The cryptographic core: verify Ed25519 over the PAE. + if (payloadBody) { + let sigOk = false; + let sigDetail = ""; + try { + const raw = Buffer.from(anchor.publicKey, "base64"); + const key = importEd25519PublicKey(raw); + const message = pae(DSSE_PAYLOAD_TYPE, payloadBody); + const signature = Buffer.from(sig.sig ?? "", "base64"); + sigOk = cryptoVerify(null, message, key, signature); + sigDetail = sigOk + ? "DSSE/Ed25519 signature valid" + : "DSSE/Ed25519 signature INVALID"; + } catch (e) { + sigDetail = `signature verification error: ${(e as Error).message}`; + } + checks.push({ name: "signature", ok: sigOk, detail: sigDetail }); + signatureVerified = sigOk && keyMatchesAnchor && declaredMatches && ptOk; + } + } + + const signed = statement as { + _type?: string; + predicateType?: string; + subject?: Array<{ name?: string; digest?: { sha256?: string } }>; + predicate?: { + task?: { name?: string; namespace?: string }; + envelope?: { digest?: string }; + issuer?: { keyId?: string; scheme?: string }; + claims?: Claim[]; + }; + } | null; + const predicate = signed?.predicate; + const signedDigest = predicate?.envelope?.digest; + const signedTask = predicate?.task; + const signedClaims = predicate?.claims; + checks.push({ + name: "statementType", + ok: signed?._type === "https://in-toto.io/Statement/v1" + && signed.predicateType === PREDICATE_TYPE + && (spec.predicateType === undefined || spec.predicateType === signed.predicateType), + detail: "signed statement and predicate types must match the supported governance contract", + }); + checks.push({ + name: "envelopeBinding", + ok: typeof signedDigest === "string" && signedDigest.startsWith("sha256:") + && spec.envelopeDigest === signedDigest + && Array.isArray(signed?.subject) && signed.subject.length === 1 + && signed.subject[0]?.digest?.sha256 === signedDigest.slice(7), + detail: "signed subject, predicate envelope and digest echo must agree", + }); + checks.push({ + name: "taskBinding", + ok: typeof signedTask?.name === "string" && typeof signedTask.namespace === "string" + && signedTask.name === task && signedTask.namespace === namespace + && spec.taskRef?.name === signedTask.name + && signed?.subject?.[0]?.name === `${signedTask.namespace}/${signedTask.name}`, + detail: "signed task and subject must match receipt metadata and taskRef", + }); + checks.push({ + name: "issuerBinding", + ok: predicate?.issuer?.keyId === anchor.keyId + && predicate.issuer.scheme === SIGNING_SCHEME + && anchor.scheme === SIGNING_SCHEME && anchor.payloadType === DSSE_PAYLOAD_TYPE + && (spec.scheme === undefined || spec.scheme === SIGNING_SCHEME), + detail: "signed issuer and unsigned scheme echoes must match the trusted signing contract", + }); + const claimsValid = Array.isArray(signedClaims) && signedClaims.length > 0 + && signedClaims.every((claim) => claim && typeof claim.class === "string" + && typeof claim.status === "string" && typeof claim.detail === "string"); + checks.push({ + name: "claimsBinding", + ok: claimsValid && (spec.claims === undefined || isDeepStrictEqual(spec.claims, signedClaims)), + detail: "claim echoes must match the verified signed predicate; unsigned claims are never trusted", + }); + + const ok = checks.length > 0 && checks.every((c) => c.ok); + return { + ok, + task, + namespace, + keyId: anchor.keyId, + envelopeDigest: signatureVerified && typeof signedDigest === "string" ? signedDigest : null, + checks, + claims: signatureVerified && claimsValid ? signedClaims : [], + statement, + }; +} + +async function kubectlGetJson(args: string[]): Promise { + const { execa } = await import("execa"); + try { + const { stdout } = await execa("kubectl", [...args, "-o", "json"], { stdio: "pipe" }); + return JSON.parse(stdout); + } catch { + return null; + } +} + +async function fetchAnchor(): Promise { + const cm = (await kubectlGetJson([ + "get", + "configmap", + ANCHOR_CONFIGMAP, + "-n", + anchorNamespace(), + ])) as { data?: Record } | null; + const data = cm?.data; + if (!data?.keyId || !data?.publicKey) return null; + return { + keyId: data.keyId, + publicKey: data.publicKey, + scheme: data.scheme ?? "DSSEv1+ed25519", + payloadType: data.payloadType ?? DSSE_PAYLOAD_TYPE, + }; +} + +interface InclusionEntry { + seq: number; + receipt: string; + payloadSha256: string; + prevHash: string; + entryHash: string; +} + +/** Entry-hash recipe, byte-identical to the controller (kars_receipt_log.rs). */ +export function inclusionEntryHash( + seq: number, + receipt: string, + payloadSha256: string, + prevHash: string, +): string { + return createHash("sha256") + .update(`${seq}|${receipt}|${payloadSha256}|${prevHash}`) + .digest("hex"); +} + +/** Verify chain integrity; returns the broken seq, or null if intact. */ +export function verifyInclusionChain(chain: InclusionEntry[]): number | null { + let prev = "genesis"; + for (let i = 0; i < chain.length; i++) { + const e = chain[i]; + if (e.seq !== i) return i; + if (e.prevHash !== prev) return e.seq; + if (inclusionEntryHash(e.seq, e.receipt, e.payloadSha256, e.prevHash) !== e.entryHash) { + return e.seq; + } + prev = e.entryHash; + } + return null; +} + +async function fetchInclusionChain(): Promise { + const cm = (await kubectlGetJson([ + "get", + "configmap", + LOG_CONFIGMAP, + "-n", + anchorNamespace(), + ])) as { data?: Record } | null; + const raw = cm?.data?.["chain.json"]; + if (!raw) return null; + try { + return JSON.parse(raw) as InclusionEntry[]; + } catch { + return null; + } +} + +interface CheckpointData { + treeSize: number; + rootHash: string; + keyId: string; + signature: string; + note: string; + publishedAt?: string; +} + +/** The signed-note body the controller signs — byte-identical recipe. */ +export function checkpointNote(treeSize: number, rootHash: string): string { + return `${CHECKPOINT_ORIGIN}\n${treeSize}\n${rootHash}\n`; +} + +/** Head hash of a chain (commits to the whole prefix); 'genesis' if empty. */ +export function chainRoot(chain: InclusionEntry[]): string { + return chain.length > 0 ? chain[chain.length - 1].entryHash : "genesis"; +} + +async function fetchCheckpoint(): Promise { + const cm = (await kubectlGetJson([ + "get", + "configmap", + CHECKPOINT_CONFIGMAP, + "-n", + anchorNamespace(), + ])) as { data?: Record } | null; + const d = cm?.data; + if (!d?.signature || !d?.rootHash || d?.treeSize === undefined) return null; + return { + treeSize: Number(d.treeSize), + rootHash: d.rootHash, + keyId: d.keyId ?? "", + signature: d.signature, + note: d.note ?? checkpointNote(Number(d.treeSize), d.rootHash), + publishedAt: d.publishedAt, + }; +} + +/** + * Verify a signed checkpoint: the Ed25519 signature over the note must validate + * against the trust anchor, and (when a chain is supplied) the checkpoint must + * commit to the chain's current size + head — proving the operator has not + * silently diverged from the log they published. Returns a check row. + */ +function checkCheckpoint( + checkpoint: CheckpointData, + anchor: TrustAnchor, + chain: InclusionEntry[] | null, +): { name: string; ok: boolean; detail: string } { + // 1. Signature over the canonical note. + let sigOk = false; + try { + const raw = Buffer.from(anchor.publicKey, "base64"); + const key = importEd25519PublicKey(raw); + const note = checkpointNote(checkpoint.treeSize, checkpoint.rootHash); + sigOk = cryptoVerify(null, Buffer.from(note, "utf8"), key, Buffer.from(checkpoint.signature, "base64")); + } catch { + sigOk = false; + } + if (!sigOk) { + return { name: "checkpoint", ok: false, detail: "signed checkpoint signature INVALID" }; + } + // 2. Key binding to the anchor. + if (checkpoint.keyId && checkpoint.keyId !== anchor.keyId) { + return { + name: "checkpoint", + ok: false, + detail: `checkpoint signed by an untrusted key (${checkpoint.keyId.slice(0, 16)}…)`, + }; + } + // 3. Consistency with the live chain. + if (chain) { + if (checkpoint.treeSize !== chain.length || checkpoint.rootHash !== chainRoot(chain)) { + return { + name: "checkpoint", + ok: false, + detail: `checkpoint (size ${checkpoint.treeSize}) diverges from the live log (size ${chain.length}) — history may have been rewritten`, + }; + } + } + return { + name: "checkpoint", + ok: true, + detail: `signed checkpoint valid over ${checkpoint.treeSize} entries (pin this to detect later rewrites; external witness is V2)`, + }; +} + +/** + * Check the receipt is included in the intact hash-chained log. Returns a + * check row; `ok=false` if the chain is broken or the receipt is absent. + */ +function checkInclusion( + receipt: ReceiptCr, + chain: InclusionEntry[], +): { name: string; ok: boolean; detail: string } { + const broken = verifyInclusionChain(chain); + if (broken !== null) { + return { + name: "inclusion", + ok: false, + detail: `inclusion log chain is BROKEN at seq ${broken} (a receipt was deleted, altered, or reordered)`, + }; + } + const ns = receipt.metadata?.namespace ?? ""; + const name = receipt.metadata?.name ?? ""; + const ref = `${ns}/${name}`; + const payload = receipt.spec?.dsse?.payload ?? ""; + const payloadSha = createHash("sha256") + .update(Buffer.from(payload, "base64")) + .digest("hex"); + const entry = chain.find((e) => e.receipt === ref && e.payloadSha256 === payloadSha); + if (!entry) { + return { + name: "inclusion", + ok: false, + detail: `receipt not found in the inclusion log (chain intact, ${chain.length} entries) — this exact receipt was not logged`, + }; + } + return { + name: "inclusion", + ok: true, + detail: `included at seq ${entry.seq} in the intact ${chain.length}-entry log (cross-receipt tamper-evidence; external witness is V2)`, + }; +} + +function statusBadge(status: string): string { + switch (status) { + case "PASS": + return chalk.green("PASS"); + case "PARTIAL": + return chalk.yellow("PARTIAL"); + case "OMITTED": + return chalk.gray("OMITTED"); + case "FAIL": + return chalk.red("FAIL"); + default: + return status; + } +} + +function formatHuman(result: VerifyResult): string { + const lines: string[] = []; + const verdict = result.ok + ? chalk.green.bold("✓ VERIFIED") + : chalk.red.bold("✗ NOT VERIFIED"); + lines.push(""); + lines.push(` ${chalk.bold("Governance Receipt")} ${result.namespace}/${result.task}`); + lines.push(` ${chalk.bold("Verdict:")} ${verdict}`); + if (result.envelopeDigest) { + lines.push(` ${chalk.bold("Envelope:")} ${result.envelopeDigest}`); + } + lines.push(` ${chalk.bold("Signed by:")} ${result.keyId.slice(0, 24)}…`); + lines.push(""); + lines.push(` ${chalk.bold.underline("Cryptographic checks")}`); + for (const c of result.checks) { + const mark = c.ok ? chalk.green("✓") : chalk.red("✗"); + lines.push(` ${mark} ${c.name.padEnd(16)} ${chalk.dim(c.detail)}`); + } + lines.push(""); + lines.push(` ${chalk.bold.underline("Claim matrix")}`); + for (const claim of result.claims) { + lines.push(` ${statusBadge(claim.status).padEnd(18)} ${chalk.bold(claim.class)}`); + lines.push(` ${chalk.dim(claim.detail)}`); + } + lines.push(""); + return lines.join("\n"); +} + +export function receiptCommand(): Command { + const cmd = new Command("receipt"); + cmd.description( + "Inspect and verify Governance Receipts — signed, independently-" + + "verifiable records that a KarsTask was governed under a trust envelope.", + ); + + cmd + .command("verify") + .description( + "Cryptographically verify a task's Governance Receipt against the " + + "controller's published trust anchor. Exits non-zero if the signature, " + + "key binding, or envelope binding fails.", + ) + .argument("", "KarsTask name") + .option("-n, --namespace ", "Namespace where the KarsReceipt lives", "kars-system") + .option("--format ", "Output format: 'human' (default) or 'json'", "human") + .action(async (task: string, options: { namespace: string; format: string }) => { + const receipt = (await kubectlGetJson([ + "get", + "karsreceipt", + task, + "-n", + options.namespace, + ])) as ReceiptCr | null; + if (!receipt) { + process.stderr.write( + chalk.red( + `✗ no Governance Receipt found for '${task}' in namespace '${options.namespace}'.\n` + + ` A receipt is emitted only for a governance-Ready task.\n`, + ), + ); + process.exit(4); + return; + } + + const anchor = await fetchAnchor(); + if (!anchor) { + process.stderr.write( + chalk.red( + `✗ trust anchor '${ANCHOR_CONFIGMAP}' not found in '${anchorNamespace()}'.\n` + + ` Cannot verify a receipt without the controller's published public key.\n`, + ), + ); + process.exit(5); + return; + } + + const result = verifyReceipt(receipt, anchor); + + // Inclusion check: cross-receipt tamper-evidence via the hash-chained log. + const chain = await fetchInclusionChain(); + if (chain) { + result.checks.push(checkInclusion(receipt, chain)); + } + + // Checkpoint check: the signed tree head must validate and agree with the + // live log (detects a silent history rewrite). + const checkpoint = await fetchCheckpoint(); + if (checkpoint) { + result.checks.push(checkCheckpoint(checkpoint, anchor, chain)); + } + + result.ok = result.checks.length > 0 && result.checks.every((c) => c.ok); + + if (options.format === "json") { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log(formatHuman(result)); + } + if (!result.ok) { + process.exit(2); + } + }); + + cmd + .command("show") + .description("Print the raw Governance Receipt (DSSE envelope + claims) for a task.") + .argument("", "KarsTask name") + .option("-n, --namespace ", "Namespace where the KarsReceipt lives", "kars-system") + .action(async (task: string, options: { namespace: string }) => { + const receipt = (await kubectlGetJson([ + "get", + "karsreceipt", + task, + "-n", + options.namespace, + ])) as ReceiptCr | null; + if (!receipt) { + process.stderr.write( + chalk.red(`✗ no Governance Receipt found for '${task}' in '${options.namespace}'.\n`), + ); + process.exit(4); + return; + } + console.log(JSON.stringify(receipt, null, 2)); + }); + + cmd + .command("log") + .description( + "Show the hash-chained receipt inclusion log and verify its integrity " + + "(cross-receipt tamper-evidence). Exits non-zero if the chain is broken.", + ) + .option("--format ", "Output format: 'human' (default) or 'json'", "human") + .action(async (options: { format: string }) => { + const chain = await fetchInclusionChain(); + if (!chain) { + process.stderr.write( + chalk.yellow( + `No inclusion log found (${LOG_CONFIGMAP} in ${anchorNamespace()}). ` + + `It is created when the first Governance Receipt is emitted.\n`, + ), + ); + return; + } + const broken = verifyInclusionChain(chain); + if (options.format === "json") { + console.log(JSON.stringify({ entries: chain, intact: broken === null, brokenAt: broken }, null, 2)); + } else { + console.log(""); + console.log(` ${chalk.bold("Receipt inclusion log")} ${chain.length} entries`); + const verdict = + broken === null + ? chalk.green.bold("✓ chain intact") + : chalk.red.bold(`✗ chain BROKEN at seq ${broken}`); + console.log(` ${chalk.bold("Integrity:")} ${verdict}`); + console.log(chalk.dim(" Operator-controlled tamper-evidence; external witness is V2.")); + console.log(""); + for (const e of chain) { + console.log(` ${String(e.seq).padStart(4)} ${chalk.bold(e.receipt)}`); + console.log(` ${chalk.dim(`payload ${e.payloadSha256.slice(0, 16)}… · entry ${e.entryHash.slice(0, 16)}…`)}`); + } + console.log(""); + } + if (broken !== null) process.exit(2); + }); + + cmd + .command("checkpoint") + .description( + "Verify the inclusion log's signed checkpoint (signed tree head) against " + + "the trust anchor and the live log. Pin the printed root to detect later " + + "history rewrites. Exits non-zero if invalid or divergent.", + ) + .option("--format ", "Output format: 'human' (default) or 'json'", "human") + .action(async (options: { format: string }) => { + const checkpoint = await fetchCheckpoint(); + if (!checkpoint) { + process.stderr.write( + chalk.yellow( + `No signed checkpoint found (${CHECKPOINT_CONFIGMAP} in ${anchorNamespace()}). ` + + `It is published when the first Governance Receipt is emitted.\n`, + ), + ); + return; + } + const anchor = await fetchAnchor(); + if (!anchor) { + process.stderr.write( + chalk.red(`✗ trust anchor '${ANCHOR_CONFIGMAP}' not found in '${anchorNamespace()}'.\n`), + ); + process.exit(5); + return; + } + const chain = await fetchInclusionChain(); + const check = checkCheckpoint(checkpoint, anchor, chain); + if (options.format === "json") { + console.log(JSON.stringify({ checkpoint, check }, null, 2)); + } else { + console.log(""); + console.log(` ${chalk.bold("Receipt log signed checkpoint")}`); + console.log(` ${chalk.bold("Tree size:")} ${checkpoint.treeSize}`); + console.log(` ${chalk.bold("Root hash:")} ${chalk.dim(checkpoint.rootHash)}`); + console.log(` ${chalk.bold("Signed by:")} ${checkpoint.keyId.slice(0, 24)}…`); + if (checkpoint.publishedAt) { + console.log(` ${chalk.bold("Published:")} ${chalk.dim(checkpoint.publishedAt)}`); + } + const verdict = check.ok + ? chalk.green.bold("✓ valid") + : chalk.red.bold("✗ invalid"); + console.log(` ${chalk.bold("Verdict:")} ${verdict} ${chalk.dim(`— ${check.detail}`)}`); + console.log(""); + } + if (!check.ok) process.exit(2); + }); + + return cmd; +} + +export const __test = { + pae, + verifyReceipt, + importEd25519PublicKey, + inclusionEntryHash, + verifyInclusionChain, + checkpointNote, + chainRoot, +}; \ No newline at end of file diff --git a/cli/src/testing/helm-installation.test.ts b/cli/src/testing/helm-installation.test.ts index a8711b6e3..1e4cedbc0 100644 --- a/cli/src/testing/helm-installation.test.ts +++ b/cli/src/testing/helm-installation.test.ts @@ -79,6 +79,8 @@ afterEach(() => { }); describe("existing Helm installation compatibility", () => { + // Archive copying and a bounded Helm subprocess are integration work, not + // a five-second unit test, especially with the expanded governance CRDs. it("preserves saved customer values and the legacy selector when new maps are absent", () => { const manifests = render(reusedValuesChart()); expect(manifests.some((item) => item.metadata?.name === "agentmesh-registry")).toBe(false); @@ -90,7 +92,7 @@ describe("existing Helm installation compatibility", () => { expect(env).toContainEqual({ name: "SANDBOX_IMAGE", value: "registry.customer.example/existing-agent:latest", }); - }); + }, 45_000); it("can enable the new mesh using reused values without missing nested defaults", () => { const manifests = render(reusedValuesChart(true)); @@ -100,7 +102,7 @@ describe("existing Helm installation compatibility", () => { expect(deployment.spec?.template?.spec?.containers?.[0]?.image) .toBe(`ghcr.io/azure/kars-agentmesh-${component}:latest`); } - }); + }, 45_000); it("treats removed/null new configuration as the compatible disabled/default state", () => { const manifests = render(chart, ["--set", "agentMesh=null,sandbox.nodeSelector=null"]); diff --git a/controller/src/crd_validations.rs b/controller/src/crd_validations.rs index 7ffa74beb..983e9ef42 100644 --- a/controller/src/crd_validations.rs +++ b/controller/src/crd_validations.rs @@ -51,9 +51,12 @@ use kube::CustomResourceExt; use crate::a2a_agent::A2AAgent; use crate::egress_approval::EgressApproval; use crate::inference_policy::InferencePolicy; +use crate::kars_approval::KarsApproval; use crate::kars_eval::KarsEval; use crate::kars_memory::KarsMemory; +use crate::kars_receipt::KarsReceipt; use crate::kars_sre_action::KarsSREAction; +use crate::kars_task::KarsTask; use crate::mcp_server::McpServer; use crate::tool_policy::ToolPolicy; @@ -507,6 +510,238 @@ pub fn kars_eval_crd() -> CustomResourceDefinition { .expect("kube-rs derive must produce a spec property on KarsEval") } +/// `KarsTask.spec` CEL rules — enforce the trust-envelope invariants at +/// admission time, before the reconciler ever sees the CR. These are the +/// substrate guarantees that capability-attenuating delegation builds on. +#[must_use] +pub fn kars_task_validations() -> Vec { + vec![ + ValidationRule { + rule: "size(self.objective) > 0 && size(self.objective) <= 4096".into(), + message: Some("spec.objective must be 1-4096 characters".into()), + reason: Some("FieldValueInvalid".into()), + ..ValidationRule::default() + }, + ValidationRule { + rule: "self.envelope.tier >= 1 && self.envelope.tier <= 5".into(), + message: Some("spec.envelope.tier must be in 1..5".into()), + reason: Some("FieldValueInvalid".into()), + ..ValidationRule::default() + }, + ValidationRule { + rule: "self.envelope.authorityCeiling >= 1 && self.envelope.authorityCeiling <= 5".into(), + message: Some("spec.envelope.authorityCeiling must be in 1..5".into()), + reason: Some("FieldValueInvalid".into()), + ..ValidationRule::default() + }, + ValidationRule { + // A task can never authorize a descendant to act with more + // authority than it holds itself. This is the load-bearing + // anti-amplification rule. + rule: "self.envelope.authorityCeiling <= self.envelope.tier".into(), + message: Some( + "spec.envelope.authorityCeiling must be <= spec.envelope.tier (a task cannot grant a child more authority than it holds)".into(), + ), + reason: Some("FieldValueInvalid".into()), + ..ValidationRule::default() + }, + ValidationRule { + rule: "self.envelope.delegationDepth >= 0 && self.envelope.delegationDepth <= 16".into(), + message: Some("spec.envelope.delegationDepth must be in 0..16".into()), + reason: Some("FieldValueInvalid".into()), + ..ValidationRule::default() + }, + ValidationRule { + rule: "!has(self.envelope.budget) || !has(self.envelope.budget.tokens) || self.envelope.budget.tokens >= 0".into(), + message: Some("spec.envelope.budget.tokens, when set, must be >= 0".into()), + reason: Some("FieldValueInvalid".into()), + ..ValidationRule::default() + }, + ValidationRule { + rule: "!has(self.envelope.budget) || !has(self.envelope.budget.usdMicros) || self.envelope.budget.usdMicros >= 0".into(), + message: Some("spec.envelope.budget.usdMicros, when set, must be >= 0".into()), + reason: Some("FieldValueInvalid".into()), + ..ValidationRule::default() + }, + ValidationRule { + rule: "!has(self.displayName) || (size(self.displayName) > 0 && size(self.displayName) <= 253)".into(), + message: Some("spec.displayName, when set, must be 1-253 characters".into()), + reason: Some("FieldValueInvalid".into()), + ..ValidationRule::default() + }, + ValidationRule { + rule: "!has(self.blueprint) || !has(self.blueprint.runtime) || self.blueprint.runtime in ['OpenClaw','OpenAIAgents','MAF','MicrosoftAgentFramework','Hermes']".into(), + message: Some("spec.blueprint.runtime must be OpenClaw, OpenAIAgents, MAF, MicrosoftAgentFramework or Hermes; BYO task configuration is unsupported".into()), + reason: Some("FieldValueInvalid".into()), + ..ValidationRule::default() + }, + ValidationRule { + rule: "!has(self.execution) || !has(self.execution.runtime) || self.execution.runtime in ['OpenClaw','OpenAIAgents','MAF','MicrosoftAgentFramework','Hermes']".into(), + message: Some("spec.execution.runtime must name a supported task runtime; BYO task configuration is unsupported".into()), + reason: Some("FieldValueInvalid".into()), + ..ValidationRule::default() + }, + ValidationRule { + rule: "!has(self.envelope.toolPolicyRef) || !has(self.blueprint) || !has(self.blueprint.toolPolicy) || self.blueprint.toolPolicy == self.envelope.toolPolicyRef.name".into(), + message: Some("spec.blueprint.toolPolicy must match spec.envelope.toolPolicyRef".into()), + reason: Some("FieldValueInvalid".into()), + ..ValidationRule::default() + }, + ValidationRule { + rule: "!has(self.envelope.egressAllowlistRef)".into(), + message: Some("envelope.egressAllowlistRef is unsupported by this foundation; use blueprint.egress for enforced Strict destinations".into()), + reason: Some("FieldValueInvalid".into()), + ..ValidationRule::default() + }, + ValidationRule { + rule: "!has(self.execution) || !self.execution.launch || !has(self.envelope.budget) || ((!has(self.envelope.budget.tokens) || self.envelope.budget.tokens == 0) && (!has(self.envelope.budget.usdMicros) || self.envelope.budget.usdMicros == 0))".into(), + message: Some("UnsupportedLaunchBudget: total/subtree token and usdMicros ceilings are not enforced; bounded tasks may be planned but cannot launch".into()), + reason: Some("FieldValueForbidden".into()), + ..ValidationRule::default() + }, + ValidationRule { + rule: "!has(self.blueprint) || !has(self.blueprint.isolation) || self.blueprint.isolation in ['standard','enhanced','confidential']".into(), + message: Some("spec.blueprint.isolation must be one of standard, enhanced, confidential".into()), + reason: Some("FieldValueInvalid".into()), + ..ValidationRule::default() + }, + ValidationRule { + rule: "!has(self.blueprint) || !has(self.blueprint.instructions) || size(self.blueprint.instructions) <= 8192".into(), + message: Some("spec.blueprint.instructions, when set, must be <= 8192 characters".into()), + reason: Some("FieldValueInvalid".into()), + ..ValidationRule::default() + }, + ValidationRule { + rule: "!has(self.blueprint) || !has(self.blueprint.mcpServers) || size(self.blueprint.mcpServers) <= 8".into(), + message: Some("spec.blueprint.mcpServers may list at most 8 connected services".into()), + reason: Some("FieldValueInvalid".into()), + ..ValidationRule::default() + }, + ValidationRule { + rule: "!has(self.blueprint) || !has(self.blueprint.mcpServers) || size(self.blueprint.mcpServers) == 0 || has(self.blueprint.toolPolicy)".into(), + message: Some("spec.blueprint.mcpServers requires spec.blueprint.toolPolicy — governed MCP access must be bounded by a tool policy".into()), + reason: Some("FieldValueInvalid".into()), + ..ValidationRule::default() + }, + ValidationRule { + rule: "!has(self.blueprint) || !has(self.blueprint.egress) || size(self.blueprint.egress) <= 32".into(), + message: Some("spec.blueprint.egress may list at most 32 destinations".into()), + reason: Some("FieldValueInvalid".into()), + ..ValidationRule::default() + }, + ] +} + +/// `KarsTask` CRD with [`kars_task_validations`] injected. +/// +/// Panics only if kube-rs ever produces a CRD whose `spec` is missing. +#[must_use] +pub fn kars_task_crd() -> CustomResourceDefinition { + inject_spec_validations(KarsTask::crd(), kars_task_validations()) + .expect("kube-rs derive must produce a spec property on KarsTask") +} + +#[must_use] +pub fn kars_receipt_validations() -> Vec { + vec![ + ValidationRule { + rule: "size(self.claims) > 0".into(), + message: Some("spec.claims must be non-empty".into()), + reason: Some("FieldValueInvalid".into()), + ..ValidationRule::default() + }, + ValidationRule { + rule: "size(self.envelopeDigest) > 0".into(), + message: Some("spec.envelopeDigest must be non-empty".into()), + reason: Some("FieldValueInvalid".into()), + ..ValidationRule::default() + }, + ] +} + +/// `KarsReceipt` CRD with basic malformed-object rejection. +#[must_use] +pub fn kars_receipt_crd() -> CustomResourceDefinition { + inject_spec_validations(KarsReceipt::crd(), kars_receipt_validations()) + .expect("kube-rs derive must produce a spec property on KarsReceipt") +} + +#[must_use] +pub fn kars_approval_validations() -> Vec { + vec![ + ValidationRule { + rule: "size(self.action.kind) > 0".into(), + message: Some("spec.action.kind must be non-empty".into()), + reason: Some("FieldValueInvalid".into()), + ..ValidationRule::default() + }, + ValidationRule { + rule: "size(self.taskRef.name) > 0".into(), + message: Some("spec.taskRef.name must be non-empty".into()), + reason: Some("FieldValueInvalid".into()), + ..ValidationRule::default() + }, + ValidationRule { + rule: "self.taskRef == oldSelf.taskRef && self.action == oldSelf.action".into(), + message: Some("spec.taskRef and spec.action are immutable".into()), + reason: Some("FieldValueForbidden".into()), + ..ValidationRule::default() + }, + ValidationRule { + rule: "(!has(self.ttl) && !has(oldSelf.ttl)) || (has(self.ttl) && has(oldSelf.ttl) && self.ttl == oldSelf.ttl)".into(), + message: Some("spec.ttl is immutable".into()), + reason: Some("FieldValueForbidden".into()), + ..ValidationRule::default() + }, + ValidationRule { + rule: "!has(oldSelf.decision) || (has(self.decision) && self.decision == oldSelf.decision)".into(), + message: Some("spec.decision is immutable once recorded".into()), + reason: Some("FieldValueForbidden".into()), + ..ValidationRule::default() + }, + ValidationRule { + rule: "!has(self.decision) || (self.decision.verdict in ['approve','deny'] && size(self.decision.decider) > 0)".into(), + message: Some("spec.decision requires approve/deny and a non-empty decider".into()), + reason: Some("FieldValueInvalid".into()), + ..ValidationRule::default() + }, + ] +} + +/// `KarsApproval` CRD with immutable request-shape validation. +#[must_use] +pub fn kars_approval_crd() -> CustomResourceDefinition { + let mut crd = inject_spec_validations(KarsApproval::crd(), kars_approval_validations()) + .expect("kube-rs derive must produce a spec property on KarsApproval"); + // Bound the shared reference locally, not for unrelated CRDs. Along with + // the action/decision string bounds this keeps CEL equality cost bounded. + let schema = crd.spec.versions[0] + .schema + .as_mut() + .unwrap() + .open_api_v3_schema + .as_mut() + .unwrap(); + schema + .properties + .as_mut() + .unwrap() + .get_mut("spec") + .unwrap() + .properties + .as_mut() + .unwrap() + .get_mut("taskRef") + .unwrap() + .properties + .as_mut() + .unwrap() + .get_mut("name") + .unwrap() + .max_length = Some(253); + crd +} + /// `TrustGraph.spec` CEL rules. Phase F1. /// /// 1. `vertices` must be non-empty (an empty graph yields a useless diff --git a/controller/src/field_managers.rs b/controller/src/field_managers.rs index d5f24f981..2f33aa20b 100644 --- a/controller/src/field_managers.rs +++ b/controller/src/field_managers.rs @@ -53,6 +53,10 @@ pub const CLAW_MEMORY: &str = "kars-controller/karsmemory"; /// `KarsEval` reconciler — eval bundle ConfigMap + Job emission. pub const CLAW_EVAL: &str = "kars-controller/karseval"; +/// `KarsTask` reconciler — validates the trust envelope and stamps the +/// envelope digest + lifecycle phase on status. +pub const CLAW_TASK: &str = "kars-controller/karstask"; + /// `TrustGraph` reconciler (Phase F1) — verifies signed trust edges /// and publishes a `ConfigMap` projection to `kars-system`. pub const TRUST_GRAPH: &str = "kars-controller/trustgraph"; @@ -102,6 +106,7 @@ pub const ALL_FIELD_MANAGERS: &[&str] = &[ INFERENCE_POLICY, CLAW_MEMORY, CLAW_EVAL, + CLAW_TASK, TRUST_GRAPH, TRUSTGRAPH_MOUNT, ROUTER_RECONCILER, diff --git a/controller/src/helm_drift.rs b/controller/src/helm_drift.rs index 7d37ab7b4..385990087 100644 --- a/controller/src/helm_drift.rs +++ b/controller/src/helm_drift.rs @@ -32,8 +32,9 @@ #[cfg(test)] use crate::crd_validations::{ - a2a_agent_crd, egress_approval_crd, inference_policy_crd, kars_eval_crd, kars_memory_crd, - kars_sre_action_crd, mcp_server_crd, tool_policy_crd, trust_graph_crd, + a2a_agent_crd, egress_approval_crd, inference_policy_crd, kars_approval_crd, kars_eval_crd, + kars_memory_crd, kars_receipt_crd, kars_sre_action_crd, kars_task_crd, mcp_server_crd, + tool_policy_crd, trust_graph_crd, }; const MCP_HELM_CRD_PATH: &str = concat!( @@ -66,6 +67,21 @@ const CLAWEVAL_HELM_CRD_PATH: &str = concat!( "/../deploy/helm/kars/templates/crd-karseval.yaml" ); +const KARSTASK_HELM_CRD_PATH: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../deploy/helm/kars/templates/crd-karstask.yaml" +); + +const KARSRECEIPT_HELM_CRD_PATH: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../deploy/helm/kars/templates/crd-karsreceipt.yaml" +); + +const KARSAPPROVAL_HELM_CRD_PATH: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../deploy/helm/kars/templates/crd-karsapproval.yaml" +); + const TRUSTGRAPH_HELM_CRD_PATH: &str = concat!( env!("CARGO_MANIFEST_DIR"), "/../deploy/helm/kars/templates/crd-trustgraph.yaml" @@ -262,6 +278,69 @@ mod tests { assert_helm_matches_rust(CLAWEVAL_HELM_CRD_PATH, rust_crd_value, "karseval"); } + /// One-shot dumper for the karstask CRD. Run via: + /// + /// DUMP_KARSTASK_CRD_YAML=1 cargo test --bin kars-controller \ + /// helm_drift::tests::dump_karstask_crd_yaml -- --nocapture + #[test] + fn dump_karstask_crd_yaml() { + if std::env::var("DUMP_KARSTASK_CRD_YAML").is_err() { + return; + } + let crd = kars_task_crd(); + let yaml = serde_yaml::to_string(&crd).expect("serialize crd to YAML"); + println!("---\n{yaml}"); + } + + #[test] + fn helm_karstask_crd_matches_rust_schema() { + let rust_crd_value = + serde_json::to_value(kars_task_crd()).expect("rust crd serializes to JSON"); + assert_helm_matches_rust(KARSTASK_HELM_CRD_PATH, rust_crd_value, "karstask"); + } + + /// One-shot dumper for the karsreceipt CRD. Run via: + /// + /// DUMP_KARSRECEIPT_CRD_YAML=1 cargo test --bin kars-controller \ + /// helm_drift::tests::dump_karsreceipt_crd_yaml -- --nocapture + #[test] + fn dump_karsreceipt_crd_yaml() { + if std::env::var("DUMP_KARSRECEIPT_CRD_YAML").is_err() { + return; + } + let crd = kars_receipt_crd(); + let yaml = serde_yaml::to_string(&crd).expect("serialize crd to YAML"); + println!("---\n{yaml}"); + } + + #[test] + fn helm_karsreceipt_crd_matches_rust_schema() { + let rust_crd_value = + serde_json::to_value(kars_receipt_crd()).expect("rust crd serializes to JSON"); + assert_helm_matches_rust(KARSRECEIPT_HELM_CRD_PATH, rust_crd_value, "karsreceipt"); + } + + /// One-shot dumper for the karsapproval CRD. Run via: + /// + /// DUMP_KARSAPPROVAL_CRD_YAML=1 cargo test --bin kars-controller \ + /// helm_drift::tests::dump_karsapproval_crd_yaml -- --nocapture + #[test] + fn dump_karsapproval_crd_yaml() { + if std::env::var("DUMP_KARSAPPROVAL_CRD_YAML").is_err() { + return; + } + let crd = kars_approval_crd(); + let yaml = serde_yaml::to_string(&crd).expect("serialize crd to YAML"); + println!("---\n{yaml}"); + } + + #[test] + fn helm_karsapproval_crd_matches_rust_schema() { + let rust_crd_value = + serde_json::to_value(kars_approval_crd()).expect("rust crd serializes to JSON"); + assert_helm_matches_rust(KARSAPPROVAL_HELM_CRD_PATH, rust_crd_value, "karsapproval"); + } + /// One-shot dumper for the trustgraph CRD. Run via: /// /// DUMP_TRUSTGRAPH_CRD_YAML=1 cargo test --bin kars-controller \ diff --git a/controller/src/kars_approval.rs b/controller/src/kars_approval.rs new file mode 100644 index 000000000..cba5f3ece --- /dev/null +++ b/controller/src/kars_approval.rs @@ -0,0 +1,506 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! `KarsApproval` CRD — the tiered, envelope-aware HITL approval primitive +//! (kars Bridge V0, Inc 4). +//! +//! A `KarsApproval` is a single human decision a `KarsTask` is waiting on: a +//! priced/external/irreversible action, a checkpoint sign-off, or a request to +//! raise a branch's autonomy tier. It is the substrate under the Bridge's +//! **steering inbox** — "you steer the mission, approve / deny / redirect, +//! *without* attaching to any agent" — and the thing that makes the autonomy +//! tiers *mean* something: at tiers 1–3 a human gates the action, and the +//! decision is itself recorded in the task's Governance Receipt. +//! +//! Like `KarsTask`, it is **independently useful on a plain kars cluster with +//! no Bridge installed**: `kubectl apply` an approval, patch `spec.decision`, +//! and the controller drives the lifecycle and stamps a verifiable record. +//! +//! ## Authority binding (controller-owned) +//! +//! An approval is bound to the **exact authority** the task held when the +//! approval became bindable: the controller copies the task's +//! `status.envelopeDigest` into `status.boundEnvelopeDigest` on first +//! observation and never changes it. If the task's envelope later drifts, the +//! pending approval goes `Stale` — you cannot grant authority against a +//! moved target. The controller is the **sole writer** of the binding, so a +//! requester cannot forge what they are asking permission for. +//! +//! ## Lifecycle +//! +//! `Pending` (awaiting bind or decision) → +//! - `Approved` / `Denied` — a human set `spec.decision`; terminal, the +//! decision and decider are recorded immutably. +//! - `Expired` — undecided past `requestedAt + ttl`. +//! - `Stale` — the bound task envelope drifted (or the task vanished) before +//! a decision; the request no longer applies to current authority. +//! +//! A first decision requires a current, unexpired binding. Already recorded +//! terminal decisions remain stable even if the task later changes. + +use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition; +use kube::CustomResource; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::mcp_server::LocalObjectRef; + +/// `.status.phase` — a human approved the request. Terminal. +pub const PHASE_APPROVED: &str = "Approved"; +/// `.status.phase` — a human denied the request. Terminal. +pub const PHASE_DENIED: &str = "Denied"; +/// `.status.phase` — undecided past its TTL. Terminal. +pub const PHASE_EXPIRED: &str = "Expired"; +/// `.status.phase` — the bound task authority drifted before a decision. +pub const PHASE_STALE: &str = "Stale"; + +/// The kinds of action a `KarsApproval` can gate. Free-form `Custom` is +/// allowed so the primitive is not a closed taxonomy, but the named kinds let +/// the Bridge group and prioritise the steering inbox. +#[allow(dead_code)] +pub const ACTION_KINDS: &[&str] = &[ + "toolCall", + "egress", + "checkpoint", + "tierRaise", + "irreversible", + "custom", +]; + +/// `KarsApproval.spec` — a human decision a task is waiting on. +#[derive(CustomResource, Debug, Serialize, Deserialize, Default, Clone, JsonSchema)] +#[kube( + group = "kars.azure.com", + version = "v1alpha1", + kind = "KarsApproval", + namespaced, + status = "KarsApprovalStatus", + shortname = "cappr", + printcolumn = r#"{"name":"Task","type":"string","jsonPath":".spec.taskRef.name"}"#, + printcolumn = r#"{"name":"Action","type":"string","jsonPath":".spec.action.kind"}"#, + printcolumn = r#"{"name":"Phase","type":"string","jsonPath":".status.phase"}"#, + printcolumn = r#"{"name":"Decider","type":"string","jsonPath":".status.decider"}"#, + printcolumn = r#"{"name":"Expires","type":"string","jsonPath":".status.expiresAt"}"#, + printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"# +)] +#[serde(rename_all = "camelCase")] +pub struct KarsApprovalSpec { + /// The `KarsTask` this approval gates, in the **same namespace**. The + /// controller binds the approval to this task's envelope digest. + pub task_ref: LocalObjectRef, + + /// What needs a human decision. + pub action: ApprovalAction, + + /// Time-to-live as an ISO-8601 duration (`PT15M`, `PT4H`, `P1D`). An + /// undecided approval past `requestedAt + ttl` becomes `Expired`. Defaults + /// to `PT1H` when omitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(length(max = 64))] + pub ttl: Option, + + /// The human decision. Absent while the approval is pending; a person (or + /// the Bridge acting for them) patches this to drive the terminal + /// transition. The controller is the sole writer of `status`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub decision: Option, +} + +/// The action a `KarsApproval` gates. +#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct ApprovalAction { + /// One of [`ACTION_KINDS`]. Not enum-constrained on the wire so the + /// primitive stays open; the Bridge treats unknown kinds as `custom`. + #[schemars(length(max = 64))] + pub kind: String, + + /// One-line, human-readable statement of what the agent wants to do. + #[schemars(length(max = 4096))] + pub summary: String, + + /// Optional longer detail (e.g. the exact tool args or egress host). + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(length(max = 8192))] + pub detail: Option, + + /// For a `tierRaise`, the autonomy tier (1..5) being requested. Surfaced + /// so an approver sees exactly how much authority they are granting. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub requested_tier: Option, +} + +impl Default for ApprovalAction { + fn default() -> Self { + Self { + kind: "custom".to_string(), + summary: String::new(), + detail: None, + requested_tier: None, + } + } +} + +/// A human's decision on an approval. +#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct ApprovalDecision { + /// `approve` or `deny`. + #[schemars(length(max = 7))] + pub verdict: String, + + /// Identity of the human (or delegated principal) who decided. Recorded + /// verbatim into status and, for granted approvals, into the receipt. + #[schemars(length(max = 320))] + pub decider: String, + + /// Optional justification, surfaced to auditors. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(length(max = 8192))] + pub reason: Option, +} + +/// Verdict values. +pub const VERDICT_APPROVE: &str = "approve"; +pub const VERDICT_DENY: &str = "deny"; + +/// `KarsApproval.status` — the controller is the sole writer. +#[derive(Debug, Serialize, Deserialize, Default, Clone, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct KarsApprovalStatus { + /// `Pending` | `Approved` | `Denied` | `Expired` | `Stale`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub phase: Option, + + /// `metadata.generation` last reconciled. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub observed_generation: Option, + + /// RFC-3339 request creation time (first observation when unavailable). + /// The TTL is measured from here; re-reconciles never bump it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub requested_at: Option, + + /// RFC-3339 time the human decision was first recorded. Immutable once + /// set — re-reconciles preserve it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub decided_at: Option, + + /// RFC-3339 expiry (`requestedAt + ttl`). Stable across re-reconciles. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expires_at: Option, + + /// The task authorization digest (envelope plus effective blueprint). + /// Copied once from `status.envelopeDigest`; never changes after binding. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bound_envelope_digest: Option, + + /// Immutable Kubernetes identity of the task whose authority was bound. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bound_task_uid: Option, + + /// Controller snapshot of taskRef/action/ttl, excluding the later decision. + /// Prevents request mutation even on clusters with outdated admission rules. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bound_request: Option, + + /// Echo of `spec.decision.decider` once decided, for the printer column. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub decider: Option, + + /// Standard K8s conditions; the `Decided` condition message surfaces + /// *why* (e.g. the staleness reason). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub conditions: Option>, +} + +/// The pure outcome of evaluating an approval — no I/O, fully unit-testable. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ApprovalOutcome { + /// Not yet decided. Carries a human-readable reason (awaiting bind vs + /// awaiting decision) for the condition message. + Pending(&'static str), + /// A human approved. Terminal. + Approved { decider: String }, + /// A human denied. Terminal. + Denied { decider: String }, + /// Undecided past TTL. Terminal. + Expired, + /// Bound authority drifted (or task vanished) before a decision. + Stale(String), +} + +impl ApprovalOutcome { + /// The `.status.phase` string for this outcome. + pub fn phase(&self) -> &'static str { + match self { + ApprovalOutcome::Pending(_) => crate::status::phase::PHASE_PENDING, + ApprovalOutcome::Approved { .. } => PHASE_APPROVED, + ApprovalOutcome::Denied { .. } => PHASE_DENIED, + ApprovalOutcome::Expired => PHASE_EXPIRED, + ApprovalOutcome::Stale(_) => PHASE_STALE, + } + } + + /// Whether this outcome is terminal (no further transition expected). + pub fn is_terminal(&self) -> bool { + !matches!(self, ApprovalOutcome::Pending(_)) + } +} + +/// Evaluate an approval. Pure: the reconciler resolves the live task digest +/// and the bound digest (binding the latter on first observation) and supplies +/// them here, so all decision logic is testable without a cluster. +/// +/// Evaluates only a first decision; the reconciler preserves terminal status. +/// Binding and expiry are checked before accepting any supplied verdict. +pub fn evaluate( + decision: Option<&ApprovalDecision>, + bound_digest: Option<&str>, + live_task_digest: Option<&str>, + expired: bool, +) -> ApprovalOutcome { + let pending = undecided_outcome(bound_digest, live_task_digest, expired); + if pending.is_terminal() || bound_digest.is_none() { + return pending; + } + if let Some(d) = decision { + if d.decider.trim().is_empty() { + return ApprovalOutcome::Pending("decision requires a non-empty decider"); + } + return match d.verdict.as_str() { + VERDICT_APPROVE => ApprovalOutcome::Approved { + decider: d.decider.clone(), + }, + VERDICT_DENY => ApprovalOutcome::Denied { + decider: d.decider.clone(), + }, + // An unknown verdict is treated as no decision rather than a + // silent approval — fail closed. + _ => undecided_outcome(bound_digest, live_task_digest, expired), + }; + } + undecided_outcome(bound_digest, live_task_digest, expired) +} + +fn undecided_outcome( + bound_digest: Option<&str>, + live_task_digest: Option<&str>, + expired: bool, +) -> ApprovalOutcome { + if expired { + return ApprovalOutcome::Expired; + } + let Some(bound) = bound_digest else { + return ApprovalOutcome::Pending("awaiting task envelope (not yet bindable)"); + }; + match live_task_digest { + None => ApprovalOutcome::Stale( + "bound task is missing or no longer Ready; request no longer applies".to_string(), + ), + Some(live) if live != bound => ApprovalOutcome::Stale(format!( + "task envelope drifted since the request (bound {bound}, current {live})" + )), + Some(_) => ApprovalOutcome::Pending("awaiting a human decision"), + } +} + +/// Stable request snapshot for controller-side immutability checks and receipts. +pub fn request_snapshot(spec: &KarsApprovalSpec) -> String { + serde_json::json!({ + "taskRef": spec.task_ref, + "action": spec.action, + "ttl": spec.ttl, + }) + .to_string() +} + +/// Match immutable request identity and the task's current effective authority. +/// This does not assert task readiness or a verdict; receipts may record denials. +pub fn approval_binding_matches_task( + approval: &KarsApproval, + task: &crate::kars_task::KarsTask, +) -> bool { + let Some(status) = &approval.status else { + return false; + }; + let Some(uid) = task.metadata.uid.as_deref().filter(|uid| !uid.is_empty()) else { + return false; + }; + task.metadata.name.as_deref() == Some(approval.spec.task_ref.name.as_str()) + && task.metadata.namespace.as_deref().unwrap_or("default") + == approval.metadata.namespace.as_deref().unwrap_or("default") + && status.bound_task_uid.as_deref() == Some(uid) + && status.bound_envelope_digest.as_deref() == Some(task.envelope_digest().as_str()) + && status.bound_request.as_deref() == Some(request_snapshot(&approval.spec).as_str()) +} + +/// Consumer guard for a terminal approval. A historical Approved phase alone +/// never authorizes a replacement task or changed blueprint. Consumers must +/// additionally validate their action kind, target, owner and one-shot semantics. +pub fn approval_authorizes_task( + approval: &KarsApproval, + task: &crate::kars_task::KarsTask, +) -> bool { + let Some(status) = &approval.status else { + return false; + }; + let Some(decision) = &approval.spec.decision else { + return false; + }; + approval.metadata.deletion_timestamp.is_none() + && status.phase.as_deref() == Some(PHASE_APPROVED) + && decision.verdict == VERDICT_APPROVE + && !decision.decider.trim().is_empty() + && status.decider.as_deref() == Some(decision.decider.as_str()) + && status + .decided_at + .as_ref() + .is_some_and(|time| !time.is_empty()) + && status.observed_generation == approval.metadata.generation + && approval_binding_matches_task(approval, task) + && crate::kars_task_reconciler::task_is_ready(task) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn decision(verdict: &str) -> ApprovalDecision { + ApprovalDecision { + verdict: verdict.to_string(), + decider: "alice@example.com".to_string(), + reason: None, + } + } + + #[test] + fn snapshot_freezes_request_but_allows_the_first_decision() { + let mut spec = KarsApprovalSpec::default(); + let original = request_snapshot(&spec); + spec.decision = Some(decision("approve")); + assert_eq!(request_snapshot(&spec), original); + spec.action.summary = "different request".into(); + assert_ne!(request_snapshot(&spec), original); + spec.action.summary.clear(); + spec.task_ref.name = "replacement-task".into(); + assert_ne!(request_snapshot(&spec), original); + } + + #[test] + fn admission_freezes_request_and_write_once_decision() { + let crd = serde_json::to_value(crate::crd_validations::kars_approval_crd()).unwrap(); + let spec = &crd["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]; + let rules = spec["x-kubernetes-validations"].as_array().unwrap(); + for fragment in [ + "self.taskRef == oldSelf.taskRef", + "self.action == oldSelf.action", + "self.ttl == oldSelf.ttl", + "self.decision == oldSelf.decision", + ] { + assert!( + rules + .iter() + .any(|r| r["rule"].as_str().unwrap().contains(fragment)) + ); + } + assert_eq!( + spec["properties"]["taskRef"]["properties"]["name"]["maxLength"], + 253 + ); + assert_eq!( + spec["properties"]["action"]["properties"]["detail"]["maxLength"], + 8192 + ); + } + + #[test] + fn approve_is_terminal_and_records_decider() { + let out = evaluate( + Some(&decision("approve")), + Some("sha256:aa"), + Some("sha256:aa"), + false, + ); + assert_eq!(out.phase(), PHASE_APPROVED); + assert!(out.is_terminal()); + assert!( + matches!(out, ApprovalOutcome::Approved { decider } if decider == "alice@example.com") + ); + } + + #[test] + fn deny_is_terminal() { + let out = evaluate( + Some(&decision("deny")), + Some("sha256:aa"), + Some("sha256:aa"), + false, + ); + assert_eq!(out.phase(), PHASE_DENIED); + assert!(out.is_terminal()); + } + + #[test] + fn first_decision_cannot_override_expiry_or_staleness() { + let out = evaluate( + Some(&decision("approve")), + Some("sha256:aa"), + Some("sha256:bb"), + true, + ); + assert_eq!(out.phase(), PHASE_EXPIRED); + assert_eq!( + evaluate(Some(&decision("approve")), Some("aa"), Some("bb"), false).phase(), + PHASE_STALE + ); + assert!(matches!( + evaluate(Some(&decision("approve")), None, Some("aa"), false), + ApprovalOutcome::Pending(_) + )); + assert_eq!(evaluate(None, None, None, true), ApprovalOutcome::Expired); + } + + #[test] + fn unknown_verdict_fails_closed_to_pending() { + let out = evaluate( + Some(&decision("maybe")), + Some("sha256:aa"), + Some("sha256:aa"), + false, + ); + assert_eq!(out.phase(), crate::status::phase::PHASE_PENDING); + } + + #[test] + fn unbound_is_pending_awaiting_task() { + let out = evaluate(None, None, Some("sha256:aa"), false); + assert!(matches!(out, ApprovalOutcome::Pending(_))); + } + + #[test] + fn drifted_envelope_is_stale() { + let out = evaluate(None, Some("sha256:aa"), Some("sha256:bb"), false); + assert_eq!(out.phase(), PHASE_STALE); + assert!(out.is_terminal()); + } + + #[test] + fn missing_task_is_stale() { + let out = evaluate(None, Some("sha256:aa"), None, false); + assert_eq!(out.phase(), PHASE_STALE); + } + + #[test] + fn bound_current_past_ttl_is_expired() { + let out = evaluate(None, Some("sha256:aa"), Some("sha256:aa"), true); + assert_eq!(out.phase(), PHASE_EXPIRED); + } + + #[test] + fn bound_current_within_ttl_is_pending_decision() { + let out = evaluate(None, Some("sha256:aa"), Some("sha256:aa"), false); + assert!(matches!(out, ApprovalOutcome::Pending(m) if m.contains("decision"))); + assert!(!out.is_terminal()); + } +} diff --git a/controller/src/kars_approval_reconciler.rs b/controller/src/kars_approval_reconciler.rs new file mode 100644 index 000000000..c65e0e530 --- /dev/null +++ b/controller/src/kars_approval_reconciler.rs @@ -0,0 +1,623 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! `KarsApproval` reconciler — the HITL approval lifecycle (kars Bridge Inc 4). +//! +//! For each `KarsApproval` the controller: +//! +//! 1. Ensures a cleanup finalizer. +//! 2. **Binds** the approval to the gated task's authority: on first +//! observation where the task is governance-`Ready`, it copies the task's +//! `status.envelopeDigest` into `status.boundEnvelopeDigest` and never +//! changes it. The controller is the sole writer of this binding. +//! 3. Evaluates the pure decision function ([`crate::kars_approval::evaluate`]) +//! over the recorded human decision, the bound digest, the live task digest, +//! and TTL expiry, and stamps the resulting `phase` + `Decided` condition. +//! 4. Preserves `requestedAt`, `expiresAt`, and `decidedAt` immutably across +//! re-reconciles, so the timeline a Governance Receipt records cannot be +//! rewritten. +//! +//! The reconciler never executes the approved action — it records the human +//! decision. Acting on it (a tier raise, an egress widen) is the consuming +//! reconciler's job; this primitive is the verifiable decision record. + +use anyhow::Result; +use chrono::{DateTime, Duration as ChronoDuration, Utc}; +use futures::StreamExt; +use kube::{ + Client, ResourceExt, + api::{Api, ListParams, Patch, PatchParams}, + runtime::controller::{Action, Controller}, +}; +use serde_json::json; +use std::sync::Arc; +use std::time::Duration; + +use crate::egress_approval_reconciler::parse_iso8601_duration_secs; +use crate::kars_approval::{ + ApprovalOutcome, KarsApproval, KarsApprovalStatus, evaluate, request_snapshot, +}; +use crate::kars_task::KarsTask; +use crate::status::conditions::{self, reason as cond_reason, status as cond_status}; + +const FIELD_MANAGER: &str = "kars-controller/karsapproval"; +const FINALIZER: &str = "kars.azure.com/karsapproval-cleanup"; + +/// The `Decided` condition type — `True` when terminal, `False` while pending. +const TYPE_DECIDED: &str = "Decided"; + +/// Default TTL when `spec.ttl` is omitted. +const DEFAULT_TTL: &str = "PT1H"; +/// Hard ceiling on an approval TTL (7 days) — a pending decision should not +/// linger indefinitely. +const MAX_TTL_SECS: u64 = 7 * 24 * 3600; + +/// Re-reconcile a still-pending approval periodically so TTL expiry is +/// observed even without an external event. +const REQUEUE_PENDING: Duration = Duration::from_secs(30); +/// Terminal approvals rarely change; re-check infrequently. +const REQUEUE_TERMINAL: Duration = Duration::from_secs(300); + +#[derive(Debug, thiserror::Error)] +enum ReconcileError { + #[error("Kubernetes API error: {0}")] + Kube(#[from] kube::Error), + #[error("JSON serialization error: {0}")] + SerdeJson(#[from] serde_json::Error), +} + +impl ReconcileError { + fn class(&self) -> &'static str { + match self { + ReconcileError::Kube(_) => "kube_api", + ReconcileError::SerdeJson(_) => "serde", + } + } +} + +struct Ctx { + client: Client, +} + +async fn reconcile(approval: Arc, ctx: Arc) -> Result { + let name = approval.name_any(); + let ns = approval.namespace().unwrap_or_else(|| "default".into()); + let approvals: Api = Api::namespaced(ctx.client.clone(), &ns); + + // Deletion: drop the finalizer; nothing cluster-side to clean up. + if approval.metadata.deletion_timestamp.is_some() { + if has_finalizer(&approval) { + let patch = json!({ + "apiVersion": "kars.azure.com/v1alpha1", + "kind": "KarsApproval", + "metadata": { + "uid": approval.uid(), "resourceVersion": approval.resource_version(), + "finalizers": drop_finalizer(&approval), + }, + }); + approvals + .patch(&name, &PatchParams::default(), &Patch::Merge(patch)) + .await?; + } + return Ok(Action::await_change()); + } + + if !has_finalizer(&approval) { + let mut finalizers = approval.metadata.finalizers.clone().unwrap_or_default(); + finalizers.push(FINALIZER.to_string()); + let patch = json!({ + "apiVersion": "kars.azure.com/v1alpha1", + "kind": "KarsApproval", + "metadata": { + "name": name, "namespace": ns, + "uid": approval.uid(), "resourceVersion": approval.resource_version(), + "finalizers": finalizers, + }, + }); + approvals + .patch( + &name, + &PatchParams::apply(FIELD_MANAGER).force(), + &Patch::Apply(patch), + ) + .await?; + return Ok(Action::requeue(Duration::from_secs(1))); + } + + let generation = approval.metadata.generation; + let prior = approval.status.clone().unwrap_or_default(); + if prior + .phase + .as_deref() + .is_some_and(is_terminal_approval_phase) + { + return Ok(Action::await_change()); + } + + // Resolve the gated task's live envelope digest (None unless it is + // governance-Ready and has a digest). + let tasks: Api = Api::namespaced(ctx.client.clone(), &ns); + let live_task = tasks.get_opt(&approval.spec.task_ref.name).await?; + let live_task_digest = live_task + .as_ref() + .filter(|task| crate::kars_task_reconciler::task_is_ready(task)) + .and_then(|task| task.status.as_ref()?.envelope_digest.clone()); + + // Bind on first observation where the task is Ready. The controller owns + // this; once set it is immutable. + let bound_digest = prior + .bound_envelope_digest + .clone() + .or_else(|| live_task_digest.clone()); + let bound_task_uid = prior + .bound_task_uid + .clone() + .or_else(|| live_task.as_ref()?.uid()); + let snapshot = request_snapshot(&approval.spec); + + let now = Utc::now(); + let requested_at = prior + .requested_at + .as_ref() + .and_then(|s| DateTime::parse_from_rfc3339(s).ok()) + .map(|dt| dt.with_timezone(&Utc)) + .or_else(|| { + approval + .metadata + .creation_timestamp + .as_ref() + .and_then(|time| DateTime::parse_from_rfc3339(&time.0.to_string()).ok()) + .map(|time| time.with_timezone(&Utc)) + }) + .unwrap_or(now); + + let ttl_secs = resolve_ttl_secs(approval.spec.ttl.as_deref()); + let expires_at = requested_at + ChronoDuration::seconds(ttl_secs as i64); + let expired = now >= expires_at; + + let outcome = match binding_violation(&prior, &snapshot, live_task.as_ref()) { + Some(why) => ApprovalOutcome::Stale(why.into()), + None => evaluate( + approval.spec.decision.as_ref(), + bound_digest.as_deref().filter(|_| bound_task_uid.is_some()), + live_task_digest.as_deref(), + expired, + ), + }; + + let mut new_status = build_status( + &prior, + generation, + &outcome, + requested_at, + expires_at, + bound_digest, + now, + ); + new_status.bound_task_uid = bound_task_uid; + new_status.bound_request = prior.bound_request.clone().or(Some(snapshot)); + + let terminal = outcome.is_terminal(); + + let status_patch = json!({ + "apiVersion": "kars.azure.com/v1alpha1", + "kind": "KarsApproval", + "metadata": { + "uid": approval.uid(), "resourceVersion": approval.resource_version(), + }, + "status": new_status, + }); + approvals + .patch_status(&name, &PatchParams::default(), &Patch::Merge(status_patch)) + .await?; + + tracing::debug!(karsapproval = %name, ns = %ns, phase = outcome.phase(), "KarsApproval reconciled"); + + Ok(Action::requeue(if terminal { + REQUEUE_TERMINAL + } else { + REQUEUE_PENDING + })) +} + +fn is_terminal_approval_phase(phase: &str) -> bool { + matches!(phase, "Approved" | "Denied" | "Expired" | "Stale") +} + +fn binding_violation( + prior: &KarsApprovalStatus, + snapshot: &str, + live_task: Option<&KarsTask>, +) -> Option<&'static str> { + if prior + .bound_request + .as_deref() + .is_some_and(|bound| bound != snapshot) + { + return Some("approval request changed after first observation"); + } + if prior.bound_envelope_digest.is_some() + && (prior.bound_task_uid.is_none() || prior.bound_request.is_none()) + { + return Some( + "legacy approval lacks an immutable task/request binding; create a new request", + ); + } + if let Some(uid) = &prior.bound_task_uid + && live_task.and_then(|task| task.metadata.uid.as_ref()) != Some(uid) + { + return Some("bound task UID changed or task was deleted"); + } + None +} + +/// Resolve the effective TTL in seconds, clamped to [`MAX_TTL_SECS`], falling +/// back to [`DEFAULT_TTL`] on absence or a parse failure. +fn resolve_ttl_secs(ttl: Option<&str>) -> u64 { + let raw = ttl.unwrap_or(DEFAULT_TTL); + let secs = parse_iso8601_duration_secs(raw) + .or_else(|_| parse_iso8601_duration_secs(DEFAULT_TTL)) + .unwrap_or(3600); + secs.min(MAX_TTL_SECS) +} + +/// Build the new status, preserving immutable timestamps across re-reconciles. +fn build_status( + prior: &KarsApprovalStatus, + generation: Option, + outcome: &ApprovalOutcome, + requested_at: DateTime, + expires_at: DateTime, + bound_digest: Option, + now: DateTime, +) -> KarsApprovalStatus { + let terminal = outcome.is_terminal(); + let decided = matches!( + outcome, + ApprovalOutcome::Approved { .. } | ApprovalOutcome::Denied { .. } + ); + + let (cond_status_value, message) = match outcome { + ApprovalOutcome::Pending(why) => (cond_status::FALSE, why.to_string()), + ApprovalOutcome::Approved { decider } => { + (cond_status::TRUE, format!("approved by {decider}")) + } + ApprovalOutcome::Denied { decider } => (cond_status::TRUE, format!("denied by {decider}")), + ApprovalOutcome::Expired => (cond_status::TRUE, "expired before a decision".to_string()), + ApprovalOutcome::Stale(why) => (cond_status::TRUE, why.clone()), + }; + + let reason_value = match outcome { + ApprovalOutcome::Pending(_) => cond_reason::RECONCILING, + ApprovalOutcome::Approved { .. } | ApprovalOutcome::Denied { .. } => { + cond_reason::RECONCILED + } + ApprovalOutcome::Expired => cond_reason::TIMED_OUT, + ApprovalOutcome::Stale(_) => cond_reason::DEPENDENCY_MISSING, + }; + + let prior_decided = prior + .conditions + .as_ref() + .and_then(|cs| conditions::find(cs, TYPE_DECIDED)); + let condition = conditions::preserve_transition_time( + prior_decided, + TYPE_DECIDED, + cond_status_value, + reason_value, + &message, + generation, + ); + + // decidedAt + decider are immutable once first recorded. + let decider = match outcome { + ApprovalOutcome::Approved { decider } | ApprovalOutcome::Denied { decider } => { + Some(decider.clone()) + } + _ => prior.decider.clone(), + }; + let decided_at = if decided { + prior.decided_at.clone().or_else(|| Some(now.to_rfc3339())) + } else { + prior.decided_at.clone() + }; + + KarsApprovalStatus { + phase: Some(outcome.phase().to_string()), + observed_generation: generation, + requested_at: Some( + prior + .requested_at + .clone() + .unwrap_or_else(|| requested_at.to_rfc3339()), + ), + decided_at, + // Once terminal, freeze expiresAt as last computed; while pending it + // tracks the (stable) requested_at + ttl. + expires_at: Some( + prior + .expires_at + .clone() + .filter(|_| terminal) + .unwrap_or_else(|| expires_at.to_rfc3339()), + ), + bound_envelope_digest: bound_digest.or_else(|| prior.bound_envelope_digest.clone()), + bound_task_uid: prior.bound_task_uid.clone(), + bound_request: prior.bound_request.clone(), + decider, + conditions: Some(vec![condition]), + } +} + +fn has_finalizer(a: &KarsApproval) -> bool { + a.metadata + .finalizers + .as_ref() + .is_some_and(|f| f.iter().any(|s| s == FINALIZER)) +} + +fn drop_finalizer(a: &KarsApproval) -> Vec { + a.metadata + .finalizers + .clone() + .unwrap_or_default() + .into_iter() + .filter(|s| s != FINALIZER) + .collect() +} + +fn error_policy(approval: Arc, error: &ReconcileError, _ctx: Arc) -> Action { + crate::metrics::record_reconcile_error("KarsApproval", error.class()); + tracing::warn!( + karsapproval = %approval.name_any(), + error_class = error.class(), + error = %error, + "KarsApproval reconcile error — requeuing in ~30s (±20% jitter)" + ); + Action::requeue(crate::backoff::requeue_secs_with_jitter(30)) +} + +pub async fn run(client: Client) -> Result<()> { + let approvals: Api = Api::all(client.clone()); + match approvals.list(&ListParams::default().limit(1)).await { + Ok(_) => tracing::info!("KarsApproval CRD found — starting controller"), + Err(e) => { + tracing::warn!("KarsApproval CRD not installed — reconciler disabled: {e}"); + std::future::pending::<()>().await; + #[allow(unreachable_code)] + return Ok(()); + } + } + let ctx = Arc::new(Ctx { client }); + Controller::new(approvals, crate::watch_config::bounded()) + .run( + |x, ctx| async move { + crate::metrics::observe_reconcile("KarsApproval", reconcile(x, ctx)).await + }, + error_policy, + ctx, + ) + .for_each(|res| async move { + match res { + Ok(o) => tracing::debug!("KarsApproval reconciled {:?}", o), + Err(e) => tracing::warn!("KarsApproval reconcile failed: {e:?}"), + } + }) + .await; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::kars_approval::ApprovalDecision; + + fn approved(decider: &str) -> ApprovalOutcome { + ApprovalOutcome::Approved { + decider: decider.to_string(), + } + } + + #[test] + fn resolve_ttl_defaults_and_clamps() { + assert_eq!(resolve_ttl_secs(None), 3600); + assert_eq!(resolve_ttl_secs(Some("PT15M")), 900); + assert_eq!(resolve_ttl_secs(Some("garbage")), 3600); + // 30d clamps to the 7d ceiling. + assert_eq!(resolve_ttl_secs(Some("P30D")), MAX_TTL_SECS); + } + + #[test] + fn bindings_reject_request_mutation_and_task_replacement() { + let mut task = KarsTask::new("task", Default::default()); + task.metadata.uid = Some("original-task".into()); + let prior = KarsApprovalStatus { + bound_task_uid: task.uid(), + bound_request: Some("original-request".into()), + bound_envelope_digest: Some("digest".into()), + ..Default::default() + }; + assert!(binding_violation(&prior, "original-request", Some(&task)).is_none()); + assert!(binding_violation(&prior, "changed-action", Some(&task)).is_some()); + task.metadata.uid = Some("replacement-task".into()); + assert!( + binding_violation(&prior, "original-request", Some(&task)) + .unwrap() + .contains("UID") + ); + assert!(binding_violation(&prior, "original-request", None).is_some()); + } + + #[test] + fn pending_legacy_bindings_cannot_be_replayed_without_task_uid() { + let prior = KarsApprovalStatus { + bound_envelope_digest: Some("digest".into()), + ..Default::default() + }; + assert!( + binding_violation(&prior, "request", None) + .unwrap() + .contains("legacy") + ); + } + + #[tokio::test] + async fn terminal_decisions_are_stable_without_reading_a_replaced_task() { + let server = wiremock::MockServer::start().await; + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + let client = Client::try_from(kube::Config::new(server.uri().parse().unwrap())).unwrap(); + for phase in ["Approved", "Denied", "Expired", "Stale"] { + let mut approval = KarsApproval::new("approval", Default::default()); + approval.metadata.finalizers = Some(vec![FINALIZER.into()]); + approval.status = Some(KarsApprovalStatus { + phase: Some(phase.into()), + decider: Some("original-human".into()), + ..Default::default() + }); + approval.spec.decision = Some(ApprovalDecision { + verdict: "deny".into(), + decider: "different-human".into(), + reason: None, + }); + reconcile( + Arc::new(approval), + Arc::new(Ctx { + client: client.clone(), + }), + ) + .await + .unwrap(); + } + assert!(server.received_requests().await.unwrap().is_empty()); + } + + #[tokio::test] + async fn status_write_cannot_cross_approval_entity_replacement() { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + let server = MockServer::start().await; + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + let client = Client::try_from(kube::Config::new(server.uri().parse().unwrap())).unwrap(); + Mock::given(method("GET")) + .and(path( + "/apis/kars.azure.com/v1alpha1/namespaces/default/karstasks/task", + )) + .respond_with(ResponseTemplate::new(404).set_body_json(json!({ + "status": "Failure", "message": "not found", "reason": "NotFound", "code": 404, + }))) + .mount(&server) + .await; + Mock::given(method("PATCH")) + .and(path( + "/apis/kars.azure.com/v1alpha1/namespaces/default/karsapprovals/approval/status", + )) + .respond_with(ResponseTemplate::new(409).set_body_json(json!({ + "status": "Failure", "message": "changed UID", "reason": "Conflict", "code": 409, + }))) + .mount(&server) + .await; + let mut approval = KarsApproval::new("approval", Default::default()); + approval.spec.task_ref.name = "task".into(); + approval.metadata.uid = Some("original-approval-uid".into()); + approval.metadata.resource_version = Some("42".into()); + approval.metadata.creation_timestamp = + Some(k8s_openapi::apimachinery::pkg::apis::meta::v1::Time( + "2020-01-01T00:00:00Z".parse().unwrap(), + )); + approval.metadata.finalizers = Some(vec![FINALIZER.into()]); + assert!( + reconcile(Arc::new(approval), Arc::new(Ctx { client })) + .await + .is_err() + ); + let requests = server.received_requests().await.unwrap(); + let patch: serde_json::Value = requests.last().unwrap().body_json().unwrap(); + assert_eq!(patch["metadata"]["uid"], "original-approval-uid"); + assert_eq!(patch["metadata"]["resourceVersion"], "42"); + assert_eq!(patch["status"]["phase"], "Expired"); + assert_eq!(patch["status"]["requestedAt"], "2020-01-01T00:00:00+00:00"); + } + + #[test] + fn decided_at_is_set_once_and_preserved() { + let now = Utc::now(); + let req = now - ChronoDuration::minutes(5); + let exp = req + ChronoDuration::hours(1); + + // First terminal write stamps decidedAt. + let s1 = build_status( + &KarsApprovalStatus::default(), + Some(1), + &approved("alice"), + req, + exp, + Some("sha256:aa".to_string()), + now, + ); + assert_eq!(s1.phase.as_deref(), Some("Approved")); + let first_decided = s1.decided_at.clone().unwrap(); + assert_eq!(s1.decider.as_deref(), Some("alice")); + + // A later re-reconcile preserves the original decidedAt. + let later = now + ChronoDuration::minutes(10); + let s2 = build_status( + &s1, + Some(1), + &approved("alice"), + req, + exp, + Some("sha256:aa".to_string()), + later, + ); + assert_eq!(s2.decided_at, Some(first_decided)); + } + + #[test] + fn pending_has_no_decided_at() { + let now = Utc::now(); + let s = build_status( + &KarsApprovalStatus::default(), + Some(1), + &ApprovalOutcome::Pending("awaiting a human decision"), + now, + now + ChronoDuration::hours(1), + Some("sha256:aa".to_string()), + now, + ); + assert_eq!(s.phase.as_deref(), Some("Pending")); + assert!(s.decided_at.is_none()); + // The Decided condition is False while pending. + let c = &s.conditions.unwrap()[0]; + assert_eq!(c.status, "False"); + } + + #[test] + fn requested_at_is_immutable() { + let now = Utc::now(); + let prior = KarsApprovalStatus { + requested_at: Some("2020-01-01T00:00:00+00:00".to_string()), + ..Default::default() + }; + let s = build_status( + &prior, + Some(1), + &ApprovalOutcome::Pending("awaiting a human decision"), + now, + now + ChronoDuration::hours(1), + Some("sha256:aa".to_string()), + now, + ); + assert_eq!(s.requested_at.as_deref(), Some("2020-01-01T00:00:00+00:00")); + } + + #[test] + fn decision_records_decider_in_status() { + let d = ApprovalDecision { + verdict: "approve".to_string(), + decider: "bob".to_string(), + reason: Some("looks good".to_string()), + }; + let out = evaluate(Some(&d), Some("sha256:aa"), Some("sha256:aa"), false); + assert!(matches!(out, ApprovalOutcome::Approved { decider } if decider == "bob")); + } +} diff --git a/controller/src/kars_receipt.rs b/controller/src/kars_receipt.rs new file mode 100644 index 000000000..958483dee --- /dev/null +++ b/controller/src/kars_receipt.rs @@ -0,0 +1,789 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! `KarsReceipt` CRD + Governance Receipt model (kars Bridge V0, Inc 3). +//! +//! A **Governance Receipt** is a signed, independently-verifiable record that +//! a `KarsTask` was governed under a specific trust envelope. It is the +//! "auditor's moment": a third party can take the receipt, the public-key +//! anchor, and `kars receipt verify`, and confirm — without trusting the +//! Bridge UI — what authority a task ran under and that the governance +//! invariants held. +//! +//! ## What V0 proves (and what it honestly does not) +//! +//! The receipt is an [in-toto Statement] wrapped in a [DSSE] envelope and +//! signed by the controller (see [`crate::providers::signing`]). Its claim +//! matrix is deliberately explicit so the receipt never overstates assurance: +//! +//! | class | V0 status | meaning | +//! |--------------|-----------|---------| +//! | `integrity` | `PASS` | DSSE/Ed25519 signature binds the payload to the envelope digest. | +//! | `conformance`| `PASS` | Envelope validated; any delegation strictly attenuated its parent. | +//! | `completeness`| `PARTIAL`| Covers *governance* facts (envelope, lineage, launch decision). The runtime token/cost audit chain is emitted by the inference router and is **not yet** bound in — that is the V1 upgrade. | +//! | `regulatory` | `OMITTED` | No external transparency-log / KMS anchor in V0 local signing. | +//! +//! These statuses are written verbatim into the receipt predicate *and* +//! surfaced at `spec.claims` for `kubectl`/Bridge, so the honesty travels +//! with the artifact. +//! +//! ## Determinism +//! +//! The signed Statement carries **no timestamp** and is built only from the +//! task spec + governed status. Combined with Ed25519's deterministic +//! signatures, this makes emission idempotent and lets a verifier re-derive +//! the exact Statement from the live `KarsTask` and confirm it matches +//! byte-for-byte before checking the signature. Issuance time lives in +//! `status.issuedAt` (unsigned, informational). +//! +//! [in-toto Statement]: https://github.com/in-toto/attestation/blob/main/spec/v1/statement.md +//! [DSSE]: https://github.com/secure-systems-lab/dsse + +use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition; +use kube::CustomResource; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::kars_task::{KarsTask, KarsTaskStatus}; +use crate::mcp_server::LocalObjectRef; +use crate::providers::signing::{DsseEnvelope, SIGNING_SCHEME}; + +/// in-toto Statement type URI. +pub const STATEMENT_TYPE: &str = "https://in-toto.io/Statement/v1"; +/// kars Governance Receipt predicate type URI (V0). +pub const PREDICATE_TYPE: &str = "https://kars.azure.com/attestations/GovernanceReceipt/v0"; + +/// `KarsReceipt.spec` — the persisted, signed Governance Receipt for one +/// `KarsTask`. The controller is the sole writer; it owns the object via an +/// owner reference to the task, so the receipt is garbage-collected with it. +#[derive(CustomResource, Debug, Serialize, Deserialize, Default, Clone, JsonSchema)] +#[kube( + group = "kars.azure.com", + version = "v1alpha1", + kind = "KarsReceipt", + namespaced, + status = "KarsReceiptStatus", + shortname = "crcpt", + printcolumn = r#"{"name":"Task","type":"string","jsonPath":".spec.taskRef.name"}"#, + printcolumn = r#"{"name":"EnvelopeDigest","type":"string","jsonPath":".spec.envelopeDigest"}"#, + printcolumn = r#"{"name":"KeyId","type":"string","jsonPath":".spec.keyId"}"#, + printcolumn = r#"{"name":"State","type":"string","jsonPath":".status.conditions[-1:].type"}"#, + printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"# +)] +#[serde(rename_all = "camelCase")] +pub struct KarsReceiptSpec { + /// The `KarsTask` this receipt attests, in the same namespace. + pub task_ref: LocalObjectRef, + + /// `sha256:` authorization digest of the envelope and effective blueprint. + /// Mirrors `status.envelopeDigest` and is bound into the signed subject. + pub envelope_digest: String, + + /// in-toto predicate type URI — always [`PREDICATE_TYPE`] for V0. + pub predicate_type: String, + + /// Signing scheme, e.g. `DSSEv1+ed25519`. + pub scheme: String, + + /// Hex SHA-256 fingerprint of the signing public key. A verifier matches + /// this against the out-of-band trust anchor, never the reverse. + pub key_id: String, + + /// The DSSE envelope: base64 in-toto Statement + Ed25519 signature(s). + pub dsse: DsseEnvelope, + + /// The claim matrix, surfaced for `kubectl`/Bridge without base64-decoding + /// the payload. This is a copy of `predicate.claims`; the signed source of + /// truth is inside `dsse.payload`. + pub claims: Vec, +} + +/// One claim-class assertion in the receipt. `class`/`status` are constrained +/// to the small vocabularies below; kept as strings for forward-compatible +/// wire stability. +#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Claim { + /// One of: `integrity`, `conformance`, `completeness`, `regulatory`. + pub class: String, + /// One of: `PASS`, `PARTIAL`, `FAIL`, `OMITTED`. + pub status: String, + /// Human-readable justification, surfaced verbatim to the auditor. + pub detail: String, +} + +impl Claim { + fn new(class: &str, status: &str, detail: impl Into) -> Self { + Self { + class: class.to_string(), + status: status.to_string(), + detail: detail.into(), + } + } +} + +/// `KarsReceipt.status` — informational echo. The receipt's authority comes +/// from its signature, not from this block. +#[derive(Debug, Serialize, Deserialize, Default, Clone, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct KarsReceiptStatus { + /// Standard Kubernetes conditions describing the advisory receipt lifecycle. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub conditions: Option>, + + /// RFC3339 issuance time (unsigned — not part of the attested payload). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub issued_at: Option, + + /// The task `metadata.generation` this receipt was minted from. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub observed_task_generation: Option, + + /// Sequence number of this receipt's entry in the `kars-receipt-log` + /// inclusion log (the cross-receipt tamper-evidence chain). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub inclusion_seq: Option, + + /// Hash of this receipt's inclusion-log entry. An auditor checks the log + /// chain is intact and that this hash is present (`kars receipt verify`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub inclusion_entry_hash: Option, +} + +// ───────────────────────────────────────────────────────────────────── +// in-toto Statement model (the signed payload) +// ───────────────────────────────────────────────────────────────────── + +/// An in-toto Statement carrying the Governance Receipt predicate. Serialized +/// to canonical JSON and signed; struct field order is the canonical order. +#[derive(Debug, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Statement { + #[serde(rename = "_type")] + pub typ: String, + pub subject: Vec, + pub predicate_type: String, + pub predicate: Predicate, +} + +/// The artifact the receipt is about: the governed task, bound to its +/// envelope digest. +#[derive(Debug, Serialize, Clone)] +pub struct Subject { + pub name: String, + pub digest: SubjectDigest, +} + +/// Subject digest of the current task authorization; historical receipts may +/// carry the former truncated envelope-only identifier. +#[derive(Debug, Serialize, Clone)] +pub struct SubjectDigest { + /// Full 64-hex-character SHA-256 for newly emitted task authorization. + pub sha256: String, +} + +/// The Governance Receipt predicate. +#[derive(Debug, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Predicate { + pub task: PredicateTask, + pub envelope: PredicateEnvelope, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub lineage: Vec, + pub delegation: PredicateDelegation, + pub execution: PredicateExecution, + /// Decisions whose bindings match current task authority, not proof of consumption. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub approvals: Vec, + /// Historical decisions retain their original binding and never grant current authority. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub approval_history: Vec, + pub conformance: PredicateConformance, + /// Which completeness-floor controls (design note §24b) the controller + /// observed enforced when the receipt was minted. This is what makes the + /// `completeness` claim concrete rather than a bare label. + pub completeness: PredicateCompleteness, + pub claims: Vec, + pub issuer: PredicateIssuer, +} + +/// Effective controls; false means NOT VERIFIED, not absent. Policy names +/// alone are not enforcement evidence. Runtime/kernel witnesses are not bound. +#[derive(Debug, Serialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct PredicateCompleteness { + pub task_namespace_floor_vap: bool, + pub exec_ban_vap: bool, + pub posture_lock_vap: bool, + pub default_deny_egress: bool, + /// `true` only when all effective controls above are verified. + pub floor_enforced: bool, +} + +impl PredicateCompleteness { + /// Compute the rollup flag from the individual observations. + pub fn with_rollup(mut self) -> Self { + self.floor_enforced = self.task_namespace_floor_vap + && self.exec_ban_vap + && self.posture_lock_vap + && self.default_deny_egress; + self + } +} + +/// One human decision bound into the receipt. Built from a decided +/// `KarsApproval` (Approved or Denied). +#[derive(Debug, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct PredicateApproval { + pub name: String, + pub action_kind: String, + pub summary: String, + /// `approve` or `deny`. + pub verdict: String, + pub decider: String, + pub decided_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub requested_tier: Option, +} + +#[path = "kars_receipt_approvals.rs"] +mod approval_evidence; +pub use approval_evidence::{PredicateHistoricalApproval, historical_approval_facts}; + +#[derive(Debug, Serialize, Clone)] +pub struct PredicateTask { + pub namespace: String, + pub name: String, + pub objective: String, +} + +#[derive(Debug, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct PredicateEnvelope { + pub tier: i32, + pub authority_ceiling: i32, + pub delegation_depth: i32, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_policy_ref: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub egress_allowlist_ref: Option, + pub digest: String, +} + +#[derive(Debug, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct PredicateDelegation { + pub is_child: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub parent_ref: Option, + pub depth_from_root: usize, +} + +#[derive(Debug, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct PredicateExecution { + pub launched: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub phase: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sandbox_ref: Option, +} + +#[derive(Debug, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct PredicateConformance { + /// Whether the trust envelope passed validation (always true for an + /// emitted receipt — degraded tasks get no receipt). + pub envelope_valid: bool, + /// `Some(true)` if this is a child whose envelope strictly attenuated its + /// parent's; `None` for a root task with no delegation to check. + #[serde(skip_serializing_if = "Option::is_none")] + pub attenuates_parent: Option, +} + +#[derive(Debug, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct PredicateIssuer { + pub component: String, + pub key_id: String, + pub scheme: String, +} + +/// Build the in-toto Statement for a governed task. Pure and deterministic — +/// no timestamps, no I/O — so it is unit-testable and re-derivable by a +/// verifier. +/// +/// `key_id` is the controller's signing fingerprint (bound into the issuer). +/// Returns `None` when the task is not governance-`Ready` (no envelope digest), +/// because a receipt must never bind to authority that did not validate. +pub fn build_statement( + task: &KarsTask, + status: &KarsTaskStatus, + key_id: &str, + approvals: &[PredicateApproval], + completeness: PredicateCompleteness, +) -> Option { + let digest = status + .envelope_digest + .clone() + .filter(|digest| digest == &task.envelope_digest())?; + let namespace = task + .metadata + .namespace + .clone() + .unwrap_or_else(|| "default".to_string()); + let name = task.metadata.name.clone().unwrap_or_default(); + let env = &task.spec.envelope; + + let is_child = task.spec.parent_ref.is_some(); + let attenuates_parent = is_child.then_some(true); + let parent_ref = task.spec.parent_ref.as_ref().map(|p| p.name.clone()); + + let launched = task + .spec + .execution + .as_ref() + .map(|e| e.launch) + .unwrap_or(false); + + // The honest claim matrix (see module docs). conformance is PASS because a + // receipt is only emitted for a validated, attenuating task. + let conformance_detail = if is_child { + "Trust envelope validated; delegation strictly attenuates parent authority on every axis (controller-enforced)." + } else { + "Trust envelope validated; root task with no delegation to attenuate." + }; + // Completeness stays PARTIAL: runtime/kernel evidence is not bound. + let completeness_detail = if completeness.floor_enforced { + "Completeness-floor controls observed enforced (CREATE-time task-namespace VAP, exec-ban VAP, posture-lock VAP, default-deny egress). NOT yet bound: the runtime egress-guard iptables-ruleset hash (V1), the router token/cost audit chain (V1), and the eBPF kernel-datapath witness (V2)." + } else { + "Some completeness-floor controls were not observed enforced (see predicate.completeness). NOT yet bound: the runtime egress-guard iptables-ruleset hash (V1), the router token/cost audit chain (V1), and the eBPF kernel-datapath witness (V2)." + }; + let claims = vec![ + Claim::new( + "integrity", + "PASS", + "DSSE/Ed25519 signature binds this payload to the trust-envelope digest.", + ), + Claim::new("conformance", "PASS", conformance_detail), + Claim::new("completeness", "PARTIAL", completeness_detail), + Claim::new( + "regulatory", + "OMITTED", + "V0 uses local controller signing. No external transparency-log or KMS anchor yet (V1).", + ), + ]; + + let predicate = Predicate { + task: PredicateTask { + namespace: namespace.clone(), + name: name.clone(), + objective: task.spec.objective.clone(), + }, + envelope: PredicateEnvelope { + tier: env.tier, + authority_ceiling: env.authority_ceiling, + delegation_depth: env.delegation_depth, + tool_policy_ref: env.tool_policy_ref.as_ref().map(|r| r.name.clone()), + egress_allowlist_ref: env.egress_allowlist_ref.as_ref().map(|r| r.name.clone()), + digest: digest.clone(), + }, + lineage: status.lineage.clone(), + delegation: PredicateDelegation { + is_child, + parent_ref, + depth_from_root: status.lineage.len(), + }, + execution: PredicateExecution { + launched, + phase: status.execution_phase.clone(), + sandbox_ref: status.sandbox_ref.as_ref().map(|r| r.name.clone()), + }, + approvals: approvals.to_vec(), + approval_history: Vec::new(), + conformance: PredicateConformance { + envelope_valid: true, + attenuates_parent, + }, + completeness, + claims: claims.clone(), + issuer: PredicateIssuer { + component: "kars-controller".to_string(), + key_id: key_id.to_string(), + scheme: SIGNING_SCHEME.to_string(), + }, + }; + + Some(Statement { + typ: STATEMENT_TYPE.to_string(), + subject: vec![Subject { + name: format!("{namespace}/{name}"), + // Strip the algorithm prefix from the current authorization digest. + digest: SubjectDigest { + sha256: digest + .strip_prefix("sha256:") + .unwrap_or(&digest) + .to_string(), + }, + }], + predicate_type: PREDICATE_TYPE.to_string(), + predicate, + }) +} + +/// Canonical JSON bytes for signing. serde serializes struct fields in +/// declaration order, so this is stable across processes. +pub fn canonical_json(statement: &Statement) -> Vec { + serde_json::to_vec(statement).expect("Statement always serializes") +} + +/// Assemble a [`KarsReceiptSpec`] from a signed envelope + statement. +pub fn build_spec( + task_name: &str, + envelope_digest: &str, + key_id: &str, + dsse: DsseEnvelope, + claims: Vec, +) -> KarsReceiptSpec { + KarsReceiptSpec { + task_ref: LocalObjectRef { + name: task_name.to_string(), + }, + envelope_digest: envelope_digest.to_string(), + predicate_type: PREDICATE_TYPE.to_string(), + scheme: SIGNING_SCHEME.to_string(), + key_id: key_id.to_string(), + dsse, + claims, + } +} + +/// Stable, sorted receipt facts from decided approvals with unchanged requests. +pub fn approval_facts(approvals: &[crate::kars_approval::KarsApproval]) -> Vec { + approval_evidence::decision_facts(approvals) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::kars_task::{KarsTaskSpec, TaskEnvelope, TaskExecution}; + + fn ready_task(child: bool) -> (KarsTask, KarsTaskStatus) { + let mut spec = KarsTaskSpec { + objective: "do the thing".to_string(), + envelope: TaskEnvelope { + tier: 3, + authority_ceiling: 2, + delegation_depth: 1, + ..Default::default() + }, + ..Default::default() + }; + if child { + spec.parent_ref = Some(LocalObjectRef { + name: "parent".to_string(), + }); + } + spec.execution = Some(TaskExecution { + launch: true, + runtime: None, + }); + let mut task = KarsTask::new("demo", spec); + task.metadata.namespace = Some("kars-system".to_string()); + let status = KarsTaskStatus { + phase: Some("Ready".to_string()), + envelope_digest: Some(task.envelope_digest()), + lineage: if child { + vec!["root".to_string(), "parent".to_string()] + } else { + vec![] + }, + execution_phase: Some("Degraded".to_string()), + ..Default::default() + }; + (task, status) + } + + #[test] + fn no_receipt_without_digest() { + let (task, mut status) = ready_task(false); + status.envelope_digest = None; + assert!( + build_statement( + &task, + &status, + "kid", + &[], + PredicateCompleteness::default().with_rollup() + ) + .is_none() + ); + } + + #[test] + fn root_statement_shape() { + let (task, status) = ready_task(false); + let st = build_statement( + &task, + &status, + "kid123", + &[], + PredicateCompleteness::default().with_rollup(), + ) + .unwrap(); + assert_eq!(st.typ, STATEMENT_TYPE); + assert_eq!(st.predicate_type, PREDICATE_TYPE); + assert_eq!(st.subject[0].name, "kars-system/demo"); + // sha256: prefix stripped for the in-toto digest field. + assert_eq!( + st.subject[0].digest.sha256, + task.envelope_digest().strip_prefix("sha256:").unwrap() + ); + assert!(!st.predicate.delegation.is_child); + assert_eq!(st.predicate.conformance.attenuates_parent, None); + assert_eq!(st.predicate.issuer.key_id, "kid123"); + } + + #[test] + fn child_statement_records_attenuation_and_lineage() { + let (task, status) = ready_task(true); + let st = build_statement( + &task, + &status, + "kid", + &[], + PredicateCompleteness::default().with_rollup(), + ) + .unwrap(); + assert!(st.predicate.delegation.is_child); + assert_eq!( + st.predicate.delegation.parent_ref.as_deref(), + Some("parent") + ); + assert_eq!(st.predicate.delegation.depth_from_root, 2); + assert_eq!(st.predicate.conformance.attenuates_parent, Some(true)); + assert_eq!(st.predicate.lineage, vec!["root", "parent"]); + } + + #[test] + fn claim_matrix_is_honest() { + let (task, status) = ready_task(false); + let st = build_statement( + &task, + &status, + "kid", + &[], + PredicateCompleteness::default().with_rollup(), + ) + .unwrap(); + let by = |c: &str| { + st.predicate + .claims + .iter() + .find(|x| x.class == c) + .unwrap() + .status + .clone() + }; + assert_eq!(by("integrity"), "PASS"); + assert_eq!(by("conformance"), "PASS"); + assert_eq!(by("completeness"), "PARTIAL"); + assert_eq!(by("regulatory"), "OMITTED"); + } + + #[test] + fn canonical_json_is_stable() { + let (task, status) = ready_task(true); + let a = canonical_json( + &build_statement( + &task, + &status, + "kid", + &[], + PredicateCompleteness::default().with_rollup(), + ) + .unwrap(), + ); + let b = canonical_json( + &build_statement( + &task, + &status, + "kid", + &[], + PredicateCompleteness::default().with_rollup(), + ) + .unwrap(), + ); + assert_eq!(a, b); + // Sanity: it really is the in-toto envelope. + let s = String::from_utf8(a).unwrap(); + assert!(s.contains("\"_type\":\"https://in-toto.io/Statement/v1\"")); + assert!(s.contains("\"predicateType\"")); + } + + #[test] + fn launched_execution_is_recorded() { + let (task, status) = ready_task(false); + let st = build_statement( + &task, + &status, + "kid", + &[], + PredicateCompleteness::default().with_rollup(), + ) + .unwrap(); + assert!(st.predicate.execution.launched); + assert_eq!(st.predicate.execution.phase.as_deref(), Some("Degraded")); + } + + #[test] + fn approvals_are_bound_into_the_predicate() { + let (task, status) = ready_task(false); + let approvals = vec![PredicateApproval { + name: "raise-tier".to_string(), + action_kind: "tierRaise".to_string(), + summary: "raise to tier 4 for the migration".to_string(), + verdict: "approve".to_string(), + decider: "alice@example.com".to_string(), + decided_at: "2026-06-26T10:00:00+00:00".to_string(), + requested_tier: Some(4), + }]; + let st = build_statement( + &task, + &status, + "kid", + &approvals, + PredicateCompleteness::default().with_rollup(), + ) + .unwrap(); + assert_eq!(st.predicate.approvals.len(), 1); + assert_eq!(st.predicate.approvals[0].verdict, "approve"); + assert_eq!(st.predicate.approvals[0].requested_tier, Some(4)); + let json = String::from_utf8(canonical_json(&st)).unwrap(); + assert!(json.contains("\"approvals\"")); + assert!(json.contains("alice@example.com")); + } + + #[test] + fn approval_facts_filters_to_decided_and_sorts() { + use crate::kars_approval::{ + ApprovalAction, ApprovalDecision, KarsApproval, KarsApprovalSpec, KarsApprovalStatus, + }; + let mk = |name: &str, phase: Option<&str>, decider: Option<&str>| { + let mut a = KarsApproval::new( + name, + KarsApprovalSpec { + task_ref: LocalObjectRef { + name: "t".to_string(), + }, + action: ApprovalAction { + kind: "checkpoint".to_string(), + summary: "ok?".to_string(), + ..Default::default() + }, + ttl: None, + decision: decider.map(|decider| ApprovalDecision { + verdict: if phase == Some("Approved") { + "approve" + } else { + "deny" + } + .into(), + decider: decider.into(), + reason: None, + }), + }, + ); + a.metadata.generation = Some(2); + a.status = Some(KarsApprovalStatus { + phase: phase.map(|s| s.to_string()), + observed_generation: Some(2), + decider: decider.map(|s| s.to_string()), + requested_at: Some("2026-06-26T09:30:00+00:00".into()), + expires_at: Some("2026-06-26T10:30:00+00:00".into()), + decided_at: decider.map(|_| "2026-06-26T10:00:00+00:00".to_string()), + bound_request: Some(crate::kars_approval::request_snapshot(&a.spec)), + ..Default::default() + }); + a + }; + let approvals = vec![ + mk("zebra", Some("Approved"), Some("z")), + mk("pending-one", Some("Pending"), None), + mk("alpha", Some("Denied"), Some("a")), + mk("stale-one", Some("Stale"), None), + ]; + let facts = approval_facts(&approvals); + assert_eq!(facts.len(), 2); + assert_eq!(facts[0].name, "alpha"); + assert_eq!(facts[0].verdict, "deny"); + assert_eq!(facts[1].name, "zebra"); + assert_eq!(facts[1].verdict, "approve"); + let mut mutated = approvals[0].clone(); + mutated.spec.action.summary = "a different action".into(); + assert!(approval_facts(&[mutated]).is_empty()); + } + + #[test] + fn completeness_rollup_requires_all_controls() { + let none = PredicateCompleteness::default().with_rollup(); + assert!(!none.floor_enforced); + + let all = PredicateCompleteness { + task_namespace_floor_vap: true, + exec_ban_vap: true, + posture_lock_vap: true, + default_deny_egress: true, + floor_enforced: false, + } + .with_rollup(); + assert!(all.floor_enforced); + + let partial = PredicateCompleteness { + task_namespace_floor_vap: true, + exec_ban_vap: true, + posture_lock_vap: false, + default_deny_egress: true, + floor_enforced: false, + } + .with_rollup(); + assert!(!partial.floor_enforced); + } + + #[test] + fn completeness_claim_detail_reflects_enforcement() { + let (task, status) = ready_task(false); + let enforced = PredicateCompleteness { + task_namespace_floor_vap: true, + exec_ban_vap: true, + posture_lock_vap: true, + default_deny_egress: true, + floor_enforced: false, + } + .with_rollup(); + let st = build_statement(&task, &status, "kid", &[], enforced).unwrap(); + assert!(st.predicate.completeness.floor_enforced); + let c = st + .predicate + .claims + .iter() + .find(|x| x.class == "completeness") + .unwrap(); + // Still PARTIAL (runtime hash + token/cost + eBPF unbound), but the + // detail must reflect the enforced controls — never overstated. + assert_eq!(c.status, "PARTIAL"); + assert!(c.detail.contains("observed enforced")); + + // When a control is missing, the detail flips to the not-enforced wording. + let weak = PredicateCompleteness::default().with_rollup(); + let st2 = build_statement(&task, &status, "kid", &[], weak).unwrap(); + let c2 = st2 + .predicate + .claims + .iter() + .find(|x| x.class == "completeness") + .unwrap(); + assert!(c2.detail.contains("not observed enforced")); + } +} diff --git a/controller/src/kars_receipt_approvals.rs b/controller/src/kars_receipt_approvals.rs new file mode 100644 index 000000000..6cd022411 --- /dev/null +++ b/controller/src/kars_receipt_approvals.rs @@ -0,0 +1,145 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Historical decision evidence is independent of current task authorization. + +use chrono::DateTime; +use serde::Serialize; + +use super::PredicateApproval; +use crate::kars_approval::{ + KarsApproval, PHASE_APPROVED, PHASE_DENIED, VERDICT_APPROVE, VERDICT_DENY, request_snapshot, +}; +use crate::kars_task::KarsTask; + +/// A recorded decision, not a current grant or evidence of a consumed transition. +#[derive(Debug, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct PredicateHistoricalApproval { + pub decision: PredicateApproval, + pub approval_uid: String, + pub task_uid: String, + pub task_name: String, + pub task_namespace: String, + /// Original D0 binding, even when the receipt's subject now describes D1. + pub bound_envelope_digest: String, + pub bound_request: String, + pub requested_at: String, + pub expires_at: String, + pub evidence_scope: &'static str, + /// This historical evidence record never confers current authority. + pub authorizes_current_task: bool, + /// No consumption or causal relationship to the task's current state is attested. + pub consumption_attested: bool, +} + +fn decision_fact(approval: &KarsApproval) -> Option { + let status = approval.status.as_ref()?; + let decision = approval.spec.decision.as_ref()?; + let verdict = match status.phase.as_deref()? { + PHASE_APPROVED => VERDICT_APPROVE, + PHASE_DENIED => VERDICT_DENY, + _ => return None, + }; + let generation = approval + .metadata + .generation + .filter(|generation| *generation > 0)?; + let requested_at = DateTime::parse_from_rfc3339(status.requested_at.as_deref()?).ok()?; + let decided_at = DateTime::parse_from_rfc3339(status.decided_at.as_deref()?).ok()?; + let expires_at = DateTime::parse_from_rfc3339(status.expires_at.as_deref()?).ok()?; + if status.observed_generation != Some(generation) + || decision.verdict != verdict + || decision.decider.trim().is_empty() + || status.decider.as_deref() != Some(decision.decider.as_str()) + || status.bound_request.as_deref() != Some(request_snapshot(&approval.spec).as_str()) + || approval.spec.action.kind.trim().is_empty() + || decided_at < requested_at + || decided_at >= expires_at + { + return None; + } + Some(PredicateApproval { + name: approval + .metadata + .name + .clone() + .filter(|name| !name.is_empty())?, + action_kind: approval.spec.action.kind.clone(), + summary: approval.spec.action.summary.clone(), + verdict: verdict.into(), + decider: decision.decider.clone(), + decided_at: status.decided_at.clone()?, + requested_tier: approval.spec.action.requested_tier, + }) +} + +pub fn decision_facts(approvals: &[KarsApproval]) -> Vec { + let mut facts: Vec<_> = approvals.iter().filter_map(decision_fact).collect(); + facts.sort_by(|a, b| a.name.cmp(&b.name)); + facts +} + +pub fn historical_approval_facts( + task: &KarsTask, + approvals: &[KarsApproval], +) -> Vec { + let Some(uid) = task.metadata.uid.as_deref().filter(|uid| !uid.is_empty()) else { + return Vec::new(); + }; + let Some(name) = task + .metadata + .name + .as_deref() + .filter(|name| !name.is_empty()) + else { + return Vec::new(); + }; + let namespace = task.metadata.namespace.as_deref().unwrap_or("default"); + let mut facts: Vec<_> = approvals + .iter() + .filter_map(|approval| { + let decision = decision_fact(approval)?; + let status = approval.status.as_ref()?; + if namespace.is_empty() + || approval.metadata.namespace.as_deref().unwrap_or("default") != namespace + || approval.spec.task_ref.name != name + || status.bound_task_uid.as_deref() != Some(uid) + { + return None; + } + let bound = status.bound_envelope_digest.as_deref()?; + let hash = bound.strip_prefix("sha256:")?; + if !matches!(hash.len(), 32 | 64) || !hash.bytes().all(|byte| byte.is_ascii_hexdigit()) + { + return None; + } + Some(PredicateHistoricalApproval { + decision, + approval_uid: approval + .metadata + .uid + .clone() + .filter(|uid| !uid.is_empty())?, + task_uid: uid.into(), + task_name: name.into(), + task_namespace: namespace.into(), + bound_envelope_digest: bound.into(), + bound_request: status.bound_request.clone()?, + requested_at: status.requested_at.clone()?, + expires_at: status.expires_at.clone()?, + evidence_scope: "historicalDecision", + authorizes_current_task: false, + consumption_attested: false, + }) + }) + .collect(); + facts.sort_by(|a, b| { + (&a.decision.name, &a.approval_uid).cmp(&(&b.decision.name, &b.approval_uid)) + }); + facts +} + +#[cfg(test)] +#[path = "kars_receipt_approvals_tests.rs"] +mod tests; diff --git a/controller/src/kars_receipt_approvals_tests.rs b/controller/src/kars_receipt_approvals_tests.rs new file mode 100644 index 000000000..065ee6725 --- /dev/null +++ b/controller/src/kars_receipt_approvals_tests.rs @@ -0,0 +1,227 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use super::*; +use crate::kars_approval::{ + ApprovalAction, ApprovalDecision, KarsApprovalSpec, KarsApprovalStatus, + approval_authorizes_task, +}; +use crate::kars_task::{KarsTaskSpec, KarsTaskStatus, TaskBlueprint, TaskModel}; +use crate::mcp_server::LocalObjectRef; + +fn ready(task: &mut KarsTask) { + task.status = Some(KarsTaskStatus { + phase: Some("Ready".into()), + observed_generation: task.metadata.generation, + envelope_digest: Some(task.envelope_digest()), + conditions: Some(vec![crate::status::conditions::new_condition( + "Ready", + "True", + "Reconciled", + "validated", + task.metadata.generation, + )]), + ..Default::default() + }); +} + +fn fixture() -> (KarsTask, KarsApproval) { + let mut task = KarsTask::new( + "task", + KarsTaskSpec { + objective: "Review a change".into(), + blueprint: Some(TaskBlueprint { + model: Some(TaskModel { + provider: "azure-openai".into(), + deployment: "reviewed-model".into(), + }), + ..Default::default() + }), + ..Default::default() + }, + ); + task.metadata.uid = Some("task-uid".into()); + task.metadata.namespace = Some("work".into()); + task.metadata.generation = Some(1); + ready(&mut task); + let mut approval = KarsApproval::new( + "promote", + KarsApprovalSpec { + task_ref: LocalObjectRef { + name: "task".into(), + }, + action: ApprovalAction { + kind: "tierRaise".into(), + summary: "Permit tier 2".into(), + requested_tier: Some(2), + ..Default::default() + }, + ttl: Some("PT1H".into()), + decision: Some(ApprovalDecision { + verdict: "approve".into(), + decider: "alice".into(), + reason: None, + }), + }, + ); + approval.metadata.uid = Some("approval-uid".into()); + approval.metadata.namespace = Some("work".into()); + approval.metadata.generation = Some(2); + approval.status = Some(KarsApprovalStatus { + phase: Some("Approved".into()), + observed_generation: Some(2), + bound_task_uid: task.metadata.uid.clone(), + bound_envelope_digest: Some(task.envelope_digest()), + bound_request: Some(request_snapshot(&approval.spec)), + requested_at: Some("2026-09-07T08:00:00Z".into()), + decided_at: Some("2026-09-07T08:15:00Z".into()), + expires_at: Some("2026-09-07T09:00:00Z".into()), + decider: Some("alice".into()), + ..Default::default() + }); + (task, approval) +} + +#[test] +fn d0_decision_survives_d1_without_becoming_a_current_grant() { + use crate::kars_receipt::{PredicateCompleteness, build_statement, canonical_json}; + let (mut task, approval) = fixture(); + assert!(approval_authorizes_task(&approval, &task)); + let d0 = task.envelope_digest(); + task.spec.envelope.tier = 2; + task.metadata.generation = Some(2); + ready(&mut task); + let d1 = task.envelope_digest(); + assert_ne!(d0, d1); + assert!(!approval_authorizes_task(&approval, &task)); + let history = historical_approval_facts(&task, std::slice::from_ref(&approval)); + assert_eq!(history.len(), 1); + assert_eq!(history[0].task_uid, "task-uid"); + assert_eq!(history[0].task_name, "task"); + assert_eq!(history[0].task_namespace, "work"); + assert_eq!(history[0].approval_uid, "approval-uid"); + assert_eq!(history[0].bound_envelope_digest, d0); + assert_eq!(history[0].bound_request, request_snapshot(&approval.spec)); + assert_eq!(history[0].evidence_scope, "historicalDecision"); + assert!(!history[0].authorizes_current_task); + assert!(!history[0].consumption_attested); + let mut statement = build_statement( + &task, + task.status.as_ref().unwrap(), + "key", + &[], + PredicateCompleteness::default(), + ) + .unwrap(); + statement.predicate.approval_history = history; + let signed_payload: serde_json::Value = + serde_json::from_slice(&canonical_json(&statement)).unwrap(); + assert_eq!( + signed_payload["subject"][0]["digest"]["sha256"], + d1.trim_start_matches("sha256:") + ); + assert_eq!( + signed_payload["predicate"]["approvalHistory"][0]["boundEnvelopeDigest"], + d0 + ); + assert!(signed_payload["predicate"].get("approvals").is_none()); + assert_eq!( + approval.status.as_ref().unwrap().phase.as_deref(), + Some("Approved") + ); +} + +#[test] +fn history_never_claims_an_unconsumed_approval_caused_a_transition() { + let (task, mut approval) = fixture(); + let before = historical_approval_facts(&task, std::slice::from_ref(&approval)); + assert!(!before[0].authorizes_current_task); + assert!(!before[0].consumption_attested); + approval.metadata.annotations = Some(std::collections::BTreeMap::from([( + "kars.azure.com/consumed-by".into(), + "untrusted-annotation".into(), + )])); + let after = historical_approval_facts(&task, &[approval]); + assert_eq!( + serde_json::to_value(before).unwrap(), + serde_json::to_value(after).unwrap() + ); +} + +#[test] +fn historical_records_reject_task_or_request_rebinding() { + type Change = fn(&mut KarsApproval); + let (task, approval) = fixture(); + let changes: &[Change] = &[ + |a| a.status.as_mut().unwrap().bound_task_uid = Some("replacement-task".into()), + |a| a.status.as_mut().unwrap().bound_task_uid = None, + |a| a.spec.task_ref.name = "another-task".into(), + |a| a.metadata.namespace = Some("other".into()), + |a| a.metadata.uid = None, + |a| a.metadata.name = None, + |a| a.spec.action.requested_tier = Some(5), + |a| a.status.as_mut().unwrap().bound_request = None, + |a| a.status.as_mut().unwrap().bound_envelope_digest = None, + |a| a.status.as_mut().unwrap().bound_envelope_digest = Some("invalid-digest".into()), + ]; + for change in changes { + let mut invalid = approval.clone(); + change(&mut invalid); + assert!(historical_approval_facts(&task, &[invalid]).is_empty()); + } + let mut replacement = task.clone(); + replacement.metadata.uid = Some("replacement-task".into()); + assert!(historical_approval_facts(&replacement, std::slice::from_ref(&approval)).is_empty()); + replacement.metadata.uid = None; + assert!(historical_approval_facts(&replacement, std::slice::from_ref(&approval)).is_empty()); +} + +#[test] +fn history_requires_a_coherent_terminal_decision_and_timely_recording() { + type Change = fn(&mut KarsApproval); + let (task, approval) = fixture(); + let changes: &[Change] = &[ + |a| a.status.as_mut().unwrap().phase = Some("Pending".into()), + |a| a.status.as_mut().unwrap().phase = Some("Expired".into()), + |a| a.status.as_mut().unwrap().phase = Some("Stale".into()), + |a| a.spec.decision = None, + |a| a.spec.decision.as_mut().unwrap().verdict = "deny".into(), + |a| a.spec.decision.as_mut().unwrap().decider = "mallory".into(), + |a| { + a.spec.decision.as_mut().unwrap().decider = " ".into(); + a.status.as_mut().unwrap().decider = Some(" ".into()); + }, + |a| a.status.as_mut().unwrap().decider = None, + |a| a.status.as_mut().unwrap().decided_at = None, + |a| a.status.as_mut().unwrap().decided_at = Some("not-a-time".into()), + |a| a.status.as_mut().unwrap().decided_at = Some("2026-09-07T07:59:00Z".into()), + |a| a.status.as_mut().unwrap().decided_at = Some("2026-09-07T09:00:00Z".into()), + |a| a.status.as_mut().unwrap().requested_at = None, + |a| a.status.as_mut().unwrap().expires_at = None, + |a| a.status.as_mut().unwrap().observed_generation = Some(1), + |a| a.metadata.generation = None, + ]; + for change in changes { + let mut invalid = approval.clone(); + change(&mut invalid); + assert!(historical_approval_facts(&task, std::slice::from_ref(&invalid)).is_empty()); + assert!(decision_facts(&[invalid]).is_empty()); + } +} + +#[test] +fn approved_and_denied_history_is_deterministic_and_retains_old_deadlines() { + let (task, mut approved) = fixture(); + approved.metadata.name = Some("alpha".into()); + let mut denied = approved.clone(); + denied.metadata.name = Some("zebra".into()); + denied.metadata.uid = Some("denial-uid".into()); + denied.spec.decision.as_mut().unwrap().verdict = "deny".into(); + denied.status.as_mut().unwrap().phase = Some("Denied".into()); + let history = historical_approval_facts(&task, &[denied, approved]); + assert_eq!(history.len(), 2); + assert_eq!(history[0].decision.name, "alpha"); + assert_eq!(history[1].decision.verdict, "deny"); + assert_eq!(history[0].expires_at, "2026-09-07T09:00:00Z"); + assert!(history.iter().all(|fact| !fact.consumption_attested)); +} diff --git a/controller/src/kars_receipt_log.rs b/controller/src/kars_receipt_log.rs new file mode 100644 index 000000000..00295858c --- /dev/null +++ b/controller/src/kars_receipt_log.rs @@ -0,0 +1,452 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Receipt inclusion log — an operator-controlled, hash-chained transparency +//! log of emitted Governance Receipts (kars Bridge Inc 5, the Wave-2 anchoring +//! precursor). +//! +//! ## What this is — and, honestly, what it is not +//! +//! Each emitted [`crate::kars_receipt::KarsReceipt`] is entered into an +//! append-only, hash-chained log stored in the `kars-receipt-log` ConfigMap in +//! `kars-system`. Every entry binds the receipt's signed-payload digest to the +//! previous entry's hash, so the **set** of receipts becomes tamper-evident: +//! deleting or altering any one receipt (or reordering them) breaks the chain +//! at that point, which a verifier detects — something a per-receipt signature +//! alone cannot catch (a signature proves *a* receipt is authentic, not that +//! *none were removed*). +//! +//! This is the **self-hosted-Rekor precursor** named in the roadmap (§22 wave +//! 2 / §24c). It is deliberately scoped and labelled with no overclaim: +//! +//! - It gives **cross-receipt tamper-evidence** and an **inclusion proof**. +//! - It does **NOT** give operator-non-repudiation: the operator controls the +//! ConfigMap and could rewrite the *entire* chain. Closing that needs an +//! **external witness** gossiping signed tree heads (V2), and +//! **KMS-attested signing** (SKR/MAA on a confidential router, V2) — both +//! gated on confidential-compute hardware and partner-environment answers. +//! The receipt's `regulatory` claim therefore stays `OMITTED`. +//! +//! The chain hash recipe is a standard SHA-256 Merkle-style link +//! (`entryHash = sha256(seq | receipt | payloadSha | prevHash)`); it lives here +//! because this file is allowlisted for hash chaining in `ci/no-custom-crypto.sh`. + +use anyhow::{Context, Result}; +use k8s_openapi::api::core::v1::ConfigMap; +use kube::{ + Client, + api::{Api, PostParams}, +}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::providers::signing::receipt_namespace; + +/// ConfigMap holding the hash-chained inclusion log. +pub const LOG_CONFIGMAP_NAME: &str = "kars-receipt-log"; +/// ConfigMap holding the signed checkpoint (signed tree head). +pub const CHECKPOINT_CONFIGMAP_NAME: &str = "kars-receipt-checkpoint"; +/// Checkpoint note origin line (Go-sumdb-style signed note). +pub const CHECKPOINT_ORIGIN: &str = "kars-receipt-log"; +/// Data key inside the ConfigMap holding the JSON chain. +const CHAIN_KEY: &str = "chain.json"; +/// Genesis previous-hash for the first entry. +const GENESIS_PREV: &str = "genesis"; +/// Root-hash value used in a checkpoint over an empty log. +const EMPTY_ROOT: &str = "genesis"; +/// SSA field manager for checkpoint writes. +const CHECKPOINT_FIELD_MANAGER: &str = "kars-controller/receipt-checkpoint"; +/// Bounded optimistic-concurrency retries on append. +const MAX_APPEND_RETRIES: usize = 5; + +/// One entry in the inclusion log. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct InclusionEntry { + /// Monotonic sequence number, starting at 0. + pub seq: u64, + /// `/` of the receipt. + pub receipt: String, + /// Hex SHA-256 of the receipt's signed DSSE payload (the in-toto Statement + /// bytes). This is what binds the log to the receipt content. + pub payload_sha256: String, + /// Hash of the previous entry (`genesis` for seq 0). + pub prev_hash: String, + /// `sha256(seq | receipt | payloadSha256 | prevHash)`. + pub entry_hash: String, +} + +/// Compute the entry hash for a chain link. Pure. +pub fn entry_hash(seq: u64, receipt: &str, payload_sha256: &str, prev_hash: &str) -> String { + let mut h = Sha256::new(); + h.update(seq.to_string().as_bytes()); + h.update(b"|"); + h.update(receipt.as_bytes()); + h.update(b"|"); + h.update(payload_sha256.as_bytes()); + h.update(b"|"); + h.update(prev_hash.as_bytes()); + let digest = h.finalize(); + let mut out = String::with_capacity(64); + for b in digest.iter() { + use std::fmt::Write; + let _ = write!(out, "{b:02x}"); + } + out +} + +/// Hex SHA-256 of arbitrary bytes (used to digest the signed payload). +pub fn sha256_hex(bytes: &[u8]) -> String { + let digest = Sha256::digest(bytes); + let mut out = String::with_capacity(64); + for b in digest.iter() { + use std::fmt::Write; + let _ = write!(out, "{b:02x}"); + } + out +} + +/// Build the next entry to append after `chain`, for the given receipt. +/// Pure — the reconciler supplies the current chain and the payload digest. +pub fn next_entry(chain: &[InclusionEntry], receipt: &str, payload_sha256: &str) -> InclusionEntry { + let seq = chain.len() as u64; + let prev_hash = chain + .last() + .map(|e| e.entry_hash.clone()) + .unwrap_or_else(|| GENESIS_PREV.to_string()); + let entry_hash = entry_hash(seq, receipt, payload_sha256, &prev_hash); + InclusionEntry { + seq, + receipt: receipt.to_string(), + payload_sha256: payload_sha256.to_string(), + prev_hash, + entry_hash, + } +} + +/// Verify a chain is internally consistent: contiguous sequence numbers, +/// correct prev-hash linkage, and recomputed entry hashes. Returns the broken +/// sequence number on failure. +#[allow(dead_code)] // verification API mirrored by the CLI (`kars receipt log`); exercised in unit tests. +pub fn verify_chain(chain: &[InclusionEntry]) -> Result<(), u64> { + let mut prev = GENESIS_PREV.to_string(); + for (i, e) in chain.iter().enumerate() { + if e.seq != i as u64 { + return Err(i as u64); + } + if e.prev_hash != prev { + return Err(e.seq); + } + let recomputed = entry_hash(e.seq, &e.receipt, &e.payload_sha256, &e.prev_hash); + if recomputed != e.entry_hash { + return Err(e.seq); + } + prev = e.entry_hash.clone(); + } + Ok(()) +} + +/// Whether the chain already records this exact receipt + payload digest as its +/// most recent entry for that receipt (so emission is idempotent across +/// requeues — we only append when the receipt content actually changed). +fn already_current(chain: &[InclusionEntry], receipt: &str, payload_sha256: &str) -> bool { + chain + .iter() + .rev() + .find(|e| e.receipt == receipt) + .is_some_and(|e| e.payload_sha256 == payload_sha256) +} + +/// Append an inclusion entry for a freshly-emitted receipt. Idempotent and +/// concurrency-safe (optimistic resourceVersion retry). Returns the entry that +/// represents this receipt's current inclusion (existing or newly appended). +pub async fn append( + client: &Client, + receipt: &str, + payload_sha256: &str, +) -> Result { + let cms: Api = Api::namespaced(client.clone(), &receipt_namespace()); + + for _ in 0..MAX_APPEND_RETRIES { + let existing = cms.get_opt(LOG_CONFIGMAP_NAME).await?; + let (chain, resource_version) = match &existing { + Some(cm) => { + let chain = cm + .data + .as_ref() + .and_then(|d| d.get(CHAIN_KEY)) + .and_then(|s| serde_json::from_str::>(s).ok()) + .unwrap_or_default(); + (chain, cm.metadata.resource_version.clone()) + } + None => (Vec::new(), None), + }; + + if already_current(&chain, receipt, payload_sha256) { + // Nothing to do — return the current inclusion entry. + return Ok(chain + .into_iter() + .rev() + .find(|e| e.receipt == receipt) + .expect("already_current implies an entry exists")); + } + + let mut new_chain = chain; + let entry = next_entry(&new_chain, receipt, payload_sha256); + new_chain.push(entry.clone()); + let chain_json = + serde_json::to_string(&new_chain).context("serialize receipt inclusion chain")?; + + let result = if existing.is_none() { + // Create the log ConfigMap. + let cm: ConfigMap = serde_json::from_value(serde_json::json!({ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": { + "name": LOG_CONFIGMAP_NAME, + "namespace": receipt_namespace(), + "labels": { + "app.kubernetes.io/name": "kars", + "app.kubernetes.io/component": "receipt-inclusion-log", + }, + }, + "data": { CHAIN_KEY: chain_json }, + }))?; + cms.create(&PostParams::default(), &cm).await.map(|_| ()) + } else { + // Replace with optimistic concurrency on resourceVersion. + let cm: ConfigMap = serde_json::from_value(serde_json::json!({ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": { + "name": LOG_CONFIGMAP_NAME, + "namespace": receipt_namespace(), + "resourceVersion": resource_version, + }, + "data": { CHAIN_KEY: chain_json }, + }))?; + cms.replace(LOG_CONFIGMAP_NAME, &PostParams::default(), &cm) + .await + .map(|_| ()) + }; + + match result { + Ok(()) => { + tracing::debug!(receipt = %receipt, seq = entry.seq, "receipt entered in inclusion log"); + return Ok(entry); + } + // 409 Conflict (lost the optimistic race) → retry with a fresh read. + Err(kube::Error::Api(ae)) if ae.code == 409 => continue, + Err(e) => return Err(e).context("appending to receipt inclusion log"), + } + } + anyhow::bail!("receipt inclusion log append exhausted retries (contention)") +} + +/// Read and parse the full inclusion chain (for checkpointing + the CLI). +pub async fn read_chain(client: &Client) -> Result> { + let cms: Api = Api::namespaced(client.clone(), &receipt_namespace()); + let cm = cms.get_opt(LOG_CONFIGMAP_NAME).await?; + Ok(cm + .and_then(|c| { + c.data + .and_then(|d| d.get(CHAIN_KEY).cloned()) + .and_then(|s| serde_json::from_str::>(&s).ok()) + }) + .unwrap_or_default()) +} + +/// The root hash a checkpoint commits to: the head entry's hash (which, in a +/// hash chain, already commits to the entire prefix), or `genesis` for an empty +/// log. +pub fn chain_root(chain: &[InclusionEntry]) -> String { + chain + .last() + .map(|e| e.entry_hash.clone()) + .unwrap_or_else(|| EMPTY_ROOT.to_string()) +} + +/// Build the signed-note body for a checkpoint over a log of `tree_size` +/// entries with head `root_hash`. Go-sumdb signed-note style: origin line, then +/// size, then root, newline-terminated. Deterministic and timestamp-free so the +/// signature is stable for a given log state (the publish time is recorded +/// out-of-band in the ConfigMap, not in the signed body). +pub fn checkpoint_note(tree_size: u64, root_hash: &str) -> String { + format!("{CHECKPOINT_ORIGIN}\n{tree_size}\n{root_hash}\n") +} + +/// A published, signed checkpoint (signed tree head) over the inclusion log. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Checkpoint { + pub origin: String, + pub tree_size: u64, + pub root_hash: String, + /// Hex SHA-256 key fingerprint of the signer (matches the trust anchor). + pub key_id: String, + /// Base64 Ed25519 signature over [`checkpoint_note`]. + pub signature: String, +} + +/// Publish a signed checkpoint for the current chain to the +/// `kars-receipt-checkpoint` ConfigMap. Idempotent: re-publishing the same log +/// state is a byte-identical no-op write (Ed25519 is deterministic). +pub async fn publish_checkpoint( + client: &Client, + signer: &crate::providers::signing::ReceiptSigner, + chain: &[InclusionEntry], +) -> Result { + let tree_size = chain.len() as u64; + let root_hash = chain_root(chain); + let note = checkpoint_note(tree_size, &root_hash); + let signature = signer.sign_note(note.as_bytes()); + let checkpoint = Checkpoint { + origin: CHECKPOINT_ORIGIN.to_string(), + tree_size, + root_hash, + key_id: signer.key_id.clone(), + signature, + }; + + let cms: Api = Api::namespaced(client.clone(), &receipt_namespace()); + let cm: ConfigMap = serde_json::from_value(serde_json::json!({ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": { + "name": CHECKPOINT_CONFIGMAP_NAME, + "namespace": receipt_namespace(), + "labels": { + "app.kubernetes.io/name": "kars", + "app.kubernetes.io/component": "receipt-checkpoint", + }, + }, + "data": { + "treeSize": tree_size.to_string(), + "rootHash": checkpoint.root_hash, + "keyId": checkpoint.key_id, + "signature": checkpoint.signature, + "note": note, + "publishedAt": chrono::Utc::now().to_rfc3339(), + }, + }))?; + cms.patch( + CHECKPOINT_CONFIGMAP_NAME, + &kube::api::PatchParams::apply(CHECKPOINT_FIELD_MANAGER).force(), + &kube::api::Patch::Apply(&cm), + ) + .await + .context("publishing receipt checkpoint ConfigMap")?; + tracing::debug!(tree_size, "receipt checkpoint published"); + Ok(checkpoint) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn chain_of(n: u64) -> Vec { + let mut chain: Vec = Vec::new(); + for i in 0..n { + let e = next_entry(&chain, &format!("ns/r{i}"), &format!("sha{i}")); + chain.push(e); + } + chain + } + + #[test] + fn next_entry_links_to_genesis_then_prev() { + let chain = chain_of(0); + let e0 = next_entry(&chain, "ns/a", "shaA"); + assert_eq!(e0.seq, 0); + assert_eq!(e0.prev_hash, GENESIS_PREV); + + let e1 = next_entry(std::slice::from_ref(&e0), "ns/b", "shaB"); + assert_eq!(e1.seq, 1); + assert_eq!(e1.prev_hash, e0.entry_hash); + } + + #[test] + fn entry_hash_is_deterministic_and_sensitive() { + let a = entry_hash(3, "ns/x", "sha", "prev"); + let b = entry_hash(3, "ns/x", "sha", "prev"); + assert_eq!(a, b); + assert_ne!(a, entry_hash(3, "ns/x", "sha", "prev2")); + assert_ne!(a, entry_hash(4, "ns/x", "sha", "prev")); + assert_ne!(a, entry_hash(3, "ns/y", "sha", "prev")); + assert_eq!(a.len(), 64); + } + + #[test] + fn verify_chain_accepts_a_valid_chain() { + assert_eq!(verify_chain(&chain_of(5)), Ok(())); + assert_eq!(verify_chain(&[]), Ok(())); + } + + #[test] + fn verify_chain_detects_tampered_payload() { + let mut chain = chain_of(4); + // Tamper with entry 2's payload digest without recomputing hashes: + chain[2].payload_sha256 = "evil".to_string(); + assert_eq!(verify_chain(&chain), Err(2)); + } + + #[test] + fn verify_chain_detects_deleted_entry() { + let mut chain = chain_of(4); + // Remove the middle entry → seq numbers + linkage break at index 2. + chain.remove(2); + assert_eq!(verify_chain(&chain), Err(2)); + } + + #[test] + fn verify_chain_detects_reorder() { + let mut chain = chain_of(4); + chain.swap(1, 2); + assert!(verify_chain(&chain).is_err()); + } + + #[test] + fn already_current_is_idempotency_guard() { + let chain = chain_of(3); // receipts ns/r0..r2 + assert!(already_current(&chain, "ns/r2", "sha2")); + assert!(!already_current(&chain, "ns/r2", "sha-new")); + assert!(!already_current(&chain, "ns/r9", "sha9")); + } + + #[test] + fn chain_root_is_head_or_genesis() { + assert_eq!(chain_root(&[]), EMPTY_ROOT); + let chain = chain_of(3); + assert_eq!(chain_root(&chain), chain.last().unwrap().entry_hash); + } + + #[test] + fn checkpoint_note_is_stable_signed_note_format() { + let note = checkpoint_note(5, "abc123"); + assert_eq!(note, "kars-receipt-log\n5\nabc123\n"); + // Deterministic for a given state. + assert_eq!(note, checkpoint_note(5, "abc123")); + // Sensitive to size and root. + assert_ne!(note, checkpoint_note(6, "abc123")); + assert_ne!(note, checkpoint_note(5, "abc124")); + } + + #[test] + fn checkpoint_note_commits_to_head_which_commits_to_prefix() { + // The head entry hash chains over the whole prefix, so a checkpoint + // over it detects any prior-entry tamper without listing every entry. + let chain = chain_of(4); + let note = checkpoint_note(chain.len() as u64, &chain_root(&chain)); + // Tamper an earlier entry → recomputing the chain changes the head → + // the note (and thus its signature) would differ. + let mut tampered = chain.clone(); + tampered[1].payload_sha256 = "evil".to_string(); + // Recompute the tampered chain's head as an honest log would. + let mut rebuilt: Vec = Vec::new(); + for e in &tampered { + rebuilt.push(next_entry(&rebuilt, &e.receipt, &e.payload_sha256)); + } + let tampered_note = checkpoint_note(rebuilt.len() as u64, &chain_root(&rebuilt)); + assert_ne!(note, tampered_note); + } +} diff --git a/controller/src/kars_task.rs b/controller/src/kars_task.rs new file mode 100644 index 000000000..d18b485ee --- /dev/null +++ b/controller/src/kars_task.rs @@ -0,0 +1,767 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! `KarsTask` CRD — the task-as-trust-envelope primitive (kars Bridge V0). +//! +//! A `KarsTask` is a typed unit of governed agent work that carries its +//! **trust envelope**: the autonomy tier, resource budget, tool/egress +//! allow-list references, and the delegation limits (`delegationDepth`, +//! `authorityCeiling`) that bound how authority may propagate when an agent +//! spawns a sub-agent. +//! +//! This is the substrate primitive underneath kars Bridge. It is, by design, +//! **independently useful on a plain kars cluster with no Bridge installed**: +//! `kubectl apply` a `KarsTask` and the controller stamps a stable +//! `status.envelopeDigest` and lifecycle phase. Capability-attenuating +//! delegation (a child task whose envelope is a verified strict subset of its +//! parent) builds on this type in the next slice; the Governance Receipt +//! composes its envelope digest + lineage. +//! +//! ## Autonomy tier (1..5) +//! +//! The `tier` field adopts the industry-consensus five-level autonomy +//! taxonomy (NIST AI RMF Agentic Profile / IEEE 7007 / ISO SC 42): +//! +//! - **1 — Manual / assistance:** the agent proposes; a human performs every +//! priced or external action. +//! - **2 — Shared:** the agent acts on low-risk steps; everything else is +//! human-gated (HITL). +//! - **3 — Conditional:** routine actions are autonomous; exceptions escalate +//! to a human. +//! - **4 — Supervised:** autonomous with periodic human checkpoints + audit. +//! - **5 — Full:** autonomous within the envelope, bounded by budget + TTL. +//! +//! Higher tiers grant more authority. The envelope's `authorityCeiling` +//! caps the tier any *descendant* task may hold, and is itself `<= tier`. + +use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition; +use kube::CustomResource; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::mcp_server::LocalObjectRef; + +#[path = "kars_task_blueprint.rs"] +pub mod blueprint; + +/// Lowest valid autonomy tier. +pub const TIER_MIN: i32 = 1; +/// Highest valid autonomy tier. +pub const TIER_MAX: i32 = 5; + +/// `KarsTask.spec` — a governed unit of work plus its trust envelope. +#[derive(CustomResource, Debug, Serialize, Deserialize, Default, Clone, JsonSchema)] +#[kube( + group = "kars.azure.com", + version = "v1alpha1", + kind = "KarsTask", + namespaced, + status = "KarsTaskStatus", + shortname = "ctask", + printcolumn = r#"{"name":"Tier","type":"integer","jsonPath":".spec.envelope.tier"}"#, + printcolumn = r#"{"name":"Phase","type":"string","jsonPath":".status.phase"}"#, + printcolumn = r#"{"name":"Execution","type":"string","jsonPath":".status.executionPhase"}"#, + printcolumn = r#"{"name":"Depth","type":"integer","jsonPath":".spec.envelope.delegationDepth"}"#, + printcolumn = r#"{"name":"EnvelopeDigest","type":"string","jsonPath":".status.envelopeDigest"}"#, + printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"# +)] +#[serde(rename_all = "camelCase")] +pub struct KarsTaskSpec { + /// Human-readable statement of the task to be performed. This is the + /// instruction a task-giver writes; the agent fleet works to satisfy it. + pub objective: String, + + /// The trust envelope that governs this task and bounds any delegation. + pub envelope: TaskEnvelope, + + /// Optional reference to a parent `KarsTask` in the **same namespace**. + /// + /// When set, this task is a *delegated child*: the controller verifies + /// that this task's `envelope` is a strict subset of the parent's + /// (capability-attenuating delegation — a child may narrow authority but + /// never amplify it), and mints `status.lineage` from the parent's + /// ancestry. A child whose envelope exceeds its parent on any axis is + /// rejected as `Degraded` and never receives an envelope digest. This is + /// the substrate enforcement of OWASP ASI-08 (cascading authority) — done + /// by the controller, not asked of the model. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_ref: Option, + + /// Execution gate (plan §20). A task is *governed-but-idle* by default — + /// validated and digested, but not running. Execution begins only on an + /// explicit launch, mirroring the "review the package, then launch" + /// principle: the human reviews the trust envelope, then opts in. When + /// `execution.launch` is `true` and the envelope is valid, the controller + /// materializes a governed `KarsSandbox` (the running agent) bounded by + /// the envelope. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub execution: Option, + + /// The **run blueprint** — the concrete, editable shape of the agent that + /// will run this task: which harness, which model, the system prompt, the + /// connected services (MCP) and tools it may use, the network destinations + /// it may reach, and the sandbox isolation. This is the substance a human + /// reviews and edits on the §20 launch package; every field here drives a + /// real field on the materialized `InferencePolicy` / `KarsSandbox`. When a + /// field is unset the controller falls back to a safe default. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub blueprint: Option, + + /// Optional short label surfaced in CLI / UI listings. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub display_name: Option, +} + +impl KarsTask { + /// Current authorization digest, not the envelope-only delegation lattice + /// digest. Approval consumers must also match the bound Kubernetes task UID. + #[must_use] + pub fn envelope_digest(&self) -> String { + self.spec.authorization_digest() + } +} + +impl KarsTaskSpec { + /// Domain-separated SHA-256 over the envelope and full effective blueprint. + /// Changes to controller model defaults invalidate old authority bindings. + #[must_use] + pub fn authorization_digest(&self) -> String { + self.authorization_digest_with_model(&blueprint::controller_default_model()) + } + + #[must_use] + pub fn authorization_digest_with_model(&self, default_model: &TaskModel) -> String { + let authority = self.authorization_configuration_with_model(default_model); + let bytes = serde_json::to_vec(&authority).expect("task authority always serializes"); + format!("sha256:{:x}", Sha256::digest(bytes)) + } + + /// Serializable effective snapshot hashed by `authorization_digest_with_model`. + /// Resolve the model once with `blueprint::controller_default_model()` and + /// pass that same value to the snapshot and digest consumers. + #[must_use] + pub fn authorization_configuration_with_model( + &self, + default_model: &TaskModel, + ) -> serde_json::Value { + let mut envelope = self.envelope.clone(); + if let Some(budget) = &mut envelope.budget { + budget.tokens = budget.tokens.filter(|n| *n != 0); + budget.usd_micros = budget.usd_micros.filter(|n| *n != 0); + if budget.tokens.is_none() && budget.usd_micros.is_none() { + envelope.budget = None; + } + } + let mut authority = serde_json::json!({ + "domain": "kars.azure.com/task-authorization/v1", + "envelope": envelope, + "parentRef": self.parent_ref, + "blueprint": blueprint::effective_blueprint_with_model(self, default_model), + "networkPolicy": { "defaultDeny": true, "egressMode": "Strict" }, + }); + authority.sort_all_objects(); + authority + } +} + +/// The concrete, editable run blueprint reviewed on the launch package. +/// Every field maps to a real field on the materialized resources. +#[derive(Debug, Serialize, Deserialize, Default, Clone, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct TaskBlueprint { + /// Harness/runtime (`OpenClaw`, `OpenAIAgents`, `MicrosoftAgentFramework`, + /// `Hermes`; `MAF` is an alias). BYO requires configuration not supported + /// by task blueprints and is rejected. Defaults to `OpenClaw`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub runtime: Option, + + /// The model the agent reasons with. Drives + /// `InferencePolicy.spec.modelPreference.primary`. Defaults from controller + /// env when unset. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + + /// System prompt / standing instructions for the agent, in addition to the + /// objective. Drives `KarsSandbox.spec.agent.instructions`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub instructions: Option, + + /// Tools the agent may call, expressed as the name of an existing + /// same-namespace `ToolPolicy`. Drives `KarsSandbox.spec.governance` + /// (`enabled: true` + `toolPolicyRef`). Composing the existing `ToolPolicy` + /// CRD keeps the AGT profile + `appliesTo` scope authoritative rather than + /// duplicating an allow-list here. Required whenever `mcpServers` is set — + /// governed MCP access is meaningless without a tool policy to bound it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool_policy: Option, + + /// Connected services (MCP server names, same namespace) the mission may + /// use. Drives `KarsSandbox.spec.governance.mcpServerRefs`. Requires + /// `toolPolicy` to be set (governed MCP access is bounded by the tool + /// policy). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub mcp_servers: Vec, + + /// Network destinations the mission may reach. Drives + /// `KarsSandbox.spec.networkPolicy.allowedEndpoints`. Task sandboxes always + /// use Strict mode, including an empty list (no additional destinations). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub egress: Vec, + + /// Sandbox isolation level (`standard`, `enhanced`, `confidential`). Drives + /// `KarsSandbox.spec.sandbox.isolation`. Defaults to `standard`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub isolation: Option, + + /// Shared team memory — the name of a same-namespace `KarsMemory` the agent + /// reads/writes. Drives `KarsSandbox.spec.memoryRef`. This is how a + /// persistent team shares knowledge across members and over time; a short + /// one-off task usually leaves it unset. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub memory: Option, +} + +/// A model route: provider tag + deployment name. +#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct TaskModel { + /// Provider tag: `azure-openai`, `anthropic`, `gemini`, `bedrock`, + /// `ollama`, `github-models`. + pub provider: String, + /// Deployment / model name as the provider advertises it. + pub deployment: String, +} + +/// A network destination the mission may reach. +#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct TaskEgress { + /// Hostname, e.g. `api.github.com`. + pub host: String, + /// Optional TCP port (e.g. `443`); any port when omitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub port: Option, +} + +/// Execution settings for a `KarsTask`. The launch flag is the §20 gate +/// between *governed* (validated, digested, idle) and *executing* (a real +/// sandbox/agent materialized). +#[derive(Debug, Serialize, Deserialize, Default, Clone, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct TaskExecution { + /// When `true`, the controller materializes a governed `KarsSandbox` from + /// this task. Defaults to `false` — review before launch. + #[serde(default)] + pub launch: bool, + + /// Runtime to launch the agent on. Defaults to `OpenClaw`. Must match the + /// controller's `RuntimeKind` enum. Superseded by `blueprint.runtime` when + /// both are set. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub runtime: Option, +} + +/// The trust envelope carried by a `KarsTask`. +/// +/// Every field is a *ceiling*: a child task minted by delegation may +/// attenuate (narrow) any of these but never amplify them. The subset +/// relation over envelopes is the heart of capability-attenuating +/// delegation (next slice). +#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct TaskEnvelope { + /// Autonomy tier (1..5). See the module docs for the taxonomy. + pub tier: i32, + + /// Optional resource budget for the whole task subtree. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub budget: Option, + + /// Optional reference to a same-namespace `ToolPolicy` CR that bounds + /// which tools/MCP servers this task (and its descendants) may call. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool_policy_ref: Option, + + /// Reserved egress policy reference. This foundation cannot resolve it + /// and rejects it before Ready. Use `blueprint.egress` for Strict inline + /// destinations; standalone sandbox signed OCI allowlists are unchanged. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub egress_allowlist_ref: Option, + + /// Remaining number of delegation hops this task may still spawn. A child + /// task is minted with `delegationDepth = parent.delegationDepth - 1`; + /// at `0` no further delegation is permitted. Must be `>= 0`. + #[serde(default)] + pub delegation_depth: i32, + + /// The maximum autonomy tier any *descendant* task may hold. Must be in + /// `1..5` and `<= tier` — a task can never authorize a child to act with + /// more authority than it holds itself. + pub authority_ceiling: i32, +} + +impl Default for TaskEnvelope { + fn default() -> Self { + // A safe default envelope: lowest autonomy, no delegation, no budget. + Self { + tier: TIER_MIN, + budget: None, + tool_policy_ref: None, + egress_allowlist_ref: None, + delegation_depth: 0, + authority_ceiling: TIER_MIN, + } + } +} + +impl TaskEnvelope { + /// Compute the stable digest of this envelope. + /// + /// The digest is a `sha256:`-prefixed hex string over the canonical JSON + /// serialization of the envelope. serde serializes struct fields in + /// declaration order deterministically. This is the envelope-only lattice + /// identifier; task approvals and receipts use `KarsTask::envelope_digest` + /// to bind the full effective governed blueprint as well. + #[must_use] + pub fn digest(&self) -> String { + let bytes = serde_json::to_vec(self).expect("TaskEnvelope always serializes"); + let full = Sha256::digest(&bytes); + // 16 bytes (32 hex chars) is ample collision resistance for an + // authority-binding identifier while keeping status compact. + let mut out = String::with_capacity(7 + 32); + out.push_str("sha256:"); + for b in &full[..16] { + use std::fmt::Write; + let _ = write!(out, "{b:02x}"); + } + out + } + + /// Verify that `self` (a proposed child envelope) is a valid + /// **attenuation** of `parent` — i.e. it narrows or preserves authority on + /// every axis and never amplifies it. Returns the list of violated axes; + /// an empty list means `self` is a valid subset of `parent`. + /// + /// This is the pure heart of capability-attenuating delegation (Pillar A). + /// The lattice, axis by axis: + /// + /// - **tier:** `child.tier <= parent.authority_ceiling`. A child may hold + /// at most the authority the parent is willing to delegate — not the + /// parent's *own* tier, but the lower ceiling the parent declared for + /// descendants. + /// - **authority_ceiling:** `child.authority_ceiling <= parent.authority_ceiling`. + /// A child cannot widen the ceiling it in turn grants *its* descendants. + /// - **delegation_depth:** `child.delegation_depth <= parent.delegation_depth - 1`. + /// Each hop consumes one level; the parent must have depth budget left. + /// - **budget (tokens, usd):** a child cap must be present and `<=` the + /// parent cap whenever the parent declares one. An unbounded child under + /// a bounded parent is an amplification. + /// - **tool_policy / egress_allowlist:** if the parent pins a policy ref, + /// the child must pin the *same* ref. (Subset *intersection* of named + /// policies is a future refinement; for V0 the safe rule is "inherit the + /// parent's exact bound or be rejected".) + #[must_use] + pub fn attenuation_violations(&self, parent: &TaskEnvelope) -> Vec { + let mut v = Vec::new(); + + if self.tier > parent.authority_ceiling { + v.push(EnvelopeViolation::TierExceedsParentCeiling { + child_tier: self.tier, + parent_ceiling: parent.authority_ceiling, + }); + } + if self.authority_ceiling > parent.authority_ceiling { + v.push(EnvelopeViolation::CeilingExceedsParentCeiling { + child_ceiling: self.authority_ceiling, + parent_ceiling: parent.authority_ceiling, + }); + } + if self.delegation_depth > parent.delegation_depth - 1 { + v.push(EnvelopeViolation::DelegationDepthExceeded { + child_depth: self.delegation_depth, + parent_depth: parent.delegation_depth, + }); + } + + // Budget: a parent cap binds the whole subtree, so a child must not + // exceed it, and must not be unbounded where the parent is bounded. + attenuate_budget_axis( + self.budget.as_ref().and_then(|b| b.tokens), + parent.budget.as_ref().and_then(|b| b.tokens), + BudgetAxis::Tokens, + &mut v, + ); + attenuate_budget_axis( + self.budget.as_ref().and_then(|b| b.usd_micros), + parent.budget.as_ref().and_then(|b| b.usd_micros), + BudgetAxis::UsdMicros, + &mut v, + ); + + attenuate_policy_axis( + self.tool_policy_ref.as_ref().map(|r| r.name.as_str()), + parent.tool_policy_ref.as_ref().map(|r| r.name.as_str()), + PolicyAxis::ToolPolicy, + &mut v, + ); + attenuate_policy_axis( + self.egress_allowlist_ref.as_ref().map(|r| r.name.as_str()), + parent + .egress_allowlist_ref + .as_ref() + .map(|r| r.name.as_str()), + PolicyAxis::EgressAllowlist, + &mut v, + ); + + v + } +} + +/// Which numeric budget axis a violation concerns. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BudgetAxis { + Tokens, + UsdMicros, +} + +/// Which policy-reference axis a violation concerns. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PolicyAxis { + ToolPolicy, + EgressAllowlist, +} + +/// A single way in which a child envelope failed to attenuate its parent. +/// Carries enough detail to render an actionable `Degraded` message. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum EnvelopeViolation { + TierExceedsParentCeiling { + child_tier: i32, + parent_ceiling: i32, + }, + CeilingExceedsParentCeiling { + child_ceiling: i32, + parent_ceiling: i32, + }, + DelegationDepthExceeded { + child_depth: i32, + parent_depth: i32, + }, + BudgetExceeded { + axis: BudgetAxis, + child: i64, + parent: i64, + }, + BudgetUnbounded { + axis: BudgetAxis, + parent: i64, + }, + PolicyMismatch { + axis: PolicyAxis, + child: Option, + parent: String, + }, + /// A child's blueprint egress reaches a destination the parent does not + /// allow — egress must be a subset of the parent's (capability attenuation + /// applied to the *effective* network surface the sandbox enforces, not a + /// vestigial ref). + EgressNotSubset { + host: String, + port: Option, + }, +} + +impl std::fmt::Display for EnvelopeViolation { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + EnvelopeViolation::TierExceedsParentCeiling { + child_tier, + parent_ceiling, + } => write!( + f, + "tier {child_tier} exceeds parent authority ceiling {parent_ceiling}" + ), + EnvelopeViolation::CeilingExceedsParentCeiling { + child_ceiling, + parent_ceiling, + } => write!( + f, + "authorityCeiling {child_ceiling} exceeds parent authority ceiling {parent_ceiling}" + ), + EnvelopeViolation::DelegationDepthExceeded { + child_depth, + parent_depth, + } => write!( + f, + "delegationDepth {child_depth} exceeds parent budget (parent depth {parent_depth}, child must be <= {})", + parent_depth - 1 + ), + EnvelopeViolation::BudgetExceeded { + axis, + child, + parent, + } => write!(f, "budget {axis:?} {child} exceeds parent cap {parent}"), + EnvelopeViolation::BudgetUnbounded { axis, parent } => write!( + f, + "budget {axis:?} is unbounded but parent caps it at {parent}" + ), + EnvelopeViolation::PolicyMismatch { + axis, + child, + parent, + } => write!( + f, + "{axis:?} ref {} must match parent's bound `{parent}`", + child.as_deref().unwrap_or("") + ), + EnvelopeViolation::EgressNotSubset { host, port } => match port { + Some(p) => write!( + f, + "egress to {host}:{p} is not permitted by the parent (egress must be a subset of the parent's)" + ), + None => write!( + f, + "egress to {host} is not permitted by the parent (egress must be a subset of the parent's)" + ), + }, + } + } +} + +/// Compare one numeric budget axis. A parent cap binds the whole subtree. +fn attenuate_budget_axis( + child: Option, + parent: Option, + axis: BudgetAxis, + out: &mut Vec, +) { + let Some(parent_cap) = parent.filter(|cap| *cap > 0) else { + // Parent is unbounded on this axis — any child value is an attenuation. + return; + }; + match child.filter(|cap| *cap > 0) { + None => out.push(EnvelopeViolation::BudgetUnbounded { + axis, + parent: parent_cap, + }), + Some(c) if c > parent_cap => out.push(EnvelopeViolation::BudgetExceeded { + axis, + child: c, + parent: parent_cap, + }), + Some(_) => {} + } +} + +/// Compare one policy-reference axis. If the parent pins a ref, the child must +/// pin the same one (V0 rule; intersection semantics are a future refinement). +fn attenuate_policy_axis( + child: Option<&str>, + parent: Option<&str>, + axis: PolicyAxis, + out: &mut Vec, +) { + let Some(parent_ref) = parent else { + // Parent pins no policy on this axis — child is free to add one. + return; + }; + if child != Some(parent_ref) { + out.push(EnvelopeViolation::PolicyMismatch { + axis, + child: child.map(str::to_string), + parent: parent_ref.to_string(), + }); + } +} + +/// The *effective* tool policy a task runs under: the blueprint's tool policy +/// when set (it composes the sandbox governance), else the envelope's +/// `toolPolicyRef`. This is the single source attenuation must check so that +/// the verified subset relation matches what `materialize` actually enforces. +#[must_use] +pub fn effective_tool_policy(spec: &KarsTaskSpec) -> Option<&str> { + spec.blueprint + .as_ref() + .and_then(|b| b.tool_policy.as_deref()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .or_else(|| { + spec.envelope + .tool_policy_ref + .as_ref() + .map(|r| r.name.trim()) + .filter(|name| !name.is_empty()) + }) +} + +/// The *effective* egress allow-list a task runs under: the blueprint's egress +/// list (which materializes to `KarsSandbox.networkPolicy.allowedEndpoints`). +/// This is the real network surface, so it is what delegation must attenuate. +#[must_use] +pub fn effective_egress(spec: &KarsTaskSpec) -> &[TaskEgress] { + spec.blueprint + .as_ref() + .map(|b| b.egress.as_slice()) + .unwrap_or(&[]) +} + +/// Normalize the task's effective runtime to the existing sandbox contract. +pub fn task_runtime(spec: &KarsTaskSpec) -> Result { + use crate::crd::RuntimeKind; + let runtime = blueprint::effective_runtime_name(spec); + match runtime { + "OpenClaw" => Ok(RuntimeKind::OpenClaw), + "OpenAIAgents" => Ok(RuntimeKind::OpenAIAgents), + "MicrosoftAgentFramework" => Ok(RuntimeKind::MicrosoftAgentFramework), + "Hermes" => Ok(RuntimeKind::Hermes), + "BYO" => { + Err("BYO task runtime requires configuration not supported by task blueprints".into()) + } + _ => Err(format!("unsupported task runtime `{runtime}`")), + } +} + +/// Check that the effective launch contract does not exceed the declared +/// envelope or promise a ceiling this foundation cannot enforce. +pub fn validate_execution_contract(spec: &KarsTaskSpec) -> Result<(), String> { + task_runtime(spec)?; + if let Some(bound) = &spec.envelope.tool_policy_ref + && effective_tool_policy(spec) != Some(bound.name.as_str()) + { + return Err("blueprint.toolPolicy must match envelope.toolPolicyRef".into()); + } + if spec.envelope.egress_allowlist_ref.is_some() { + return Err("envelope.egressAllowlistRef cannot be resolved by this foundation; use blueprint.egress for enforced Strict destinations".into()); + } + if let Some(budget) = &spec.envelope.budget { + if budget.tokens.is_some_and(|n| n < 0) || budget.usd_micros.is_some_and(|n| n < 0) { + return Err("task budget values must be >= 0".into()); + } + if spec.execution.as_ref().is_some_and(|e| e.launch) + && (budget.tokens.is_some_and(|n| n > 0) || budget.usd_micros.is_some_and(|n| n > 0)) + { + return Err("UnsupportedLaunchBudget: total/subtree token and usdMicros ceilings are not enforced by this foundation; bounded tasks may be planned but cannot launch".into()); + } + } + Ok(()) +} + +/// Whether a child egress destination is covered by the parent's allow-list. +/// A parent entry with no port (any port) covers a child entry on the same +/// host with any port; otherwise host + port must match exactly. +fn egress_covers(parent: &[TaskEgress], child: &TaskEgress) -> bool { + parent + .iter() + .any(|p| p.host == child.host && (p.port.is_none() || p.port == child.port)) +} + +/// Full capability-attenuation check over the whole task spec: the numeric + +/// ref envelope axes **plus** the effective tool policy and effective egress +/// the sandbox will actually enforce. This closes the gap where attenuation +/// validated the envelope while execution used the blueprint — they now share +/// one source of truth. Returns an empty vec when the child strictly attenuates +/// the parent. +#[must_use] +pub fn spec_attenuation_violations( + child: &KarsTaskSpec, + parent: &KarsTaskSpec, +) -> Vec { + let mut v = child.envelope.attenuation_violations(&parent.envelope); + + // Effective tool policy: same equality rule as the envelope ref axis, but + // over the value the sandbox actually runs (blueprint-or-envelope). + attenuate_policy_axis( + effective_tool_policy(child), + effective_tool_policy(parent), + PolicyAxis::ToolPolicy, + &mut v, + ); + + // Effective egress must be a subset of the parent's: every destination the + // child may reach must already be permitted to the parent. An empty parent + // allow-list (model path only) permits no extra child egress. + let parent_egress = effective_egress(parent); + for dest in effective_egress(child) { + if !egress_covers(parent_egress, dest) { + v.push(EnvelopeViolation::EgressNotSubset { + host: dest.host.clone(), + port: dest.port, + }); + } + } + + v +} +#[derive(Debug, Serialize, Deserialize, Default, Clone, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct TaskBudget { + /// Maximum total tokens the task subtree may consume. `0`/absent means + /// "no token cap declared". Positive ceilings are planning declarations: + /// launch is rejected until durable total/subtree enforcement is available. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tokens: Option, + + /// Maximum total spend in micro-USD (1e-6 USD) for the task subtree. + /// `0`/absent means no cap declared. Positive ceilings block launch in this + /// foundation. Integer micro-USD avoids floating-point in an audit field. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub usd_micros: Option, +} + +/// `KarsTask.status`. +#[derive(Debug, Serialize, Deserialize, Default, Clone, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct KarsTaskStatus { + /// One of: `Pending`, `Ready`, `Degraded`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub phase: Option, + + /// The `.metadata.generation` most recently reconciled, so clients can + /// tell whether `status` reflects the current `spec`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub observed_generation: Option, + + /// Standard K8s conditions. `Ready` is set `True` once the envelope has + /// been validated and its digest stamped. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub conditions: Option>, + + /// `sha256:` authorization digest of the validated envelope and effective + /// governed blueprint, including resolved model defaults and capability refs. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub envelope_digest: Option, + + /// Ancestry of this task, oldest-first: the chain of parent task names + /// from the root delegation down to (but excluding) this task. Empty for + /// a root task. Populated by the delegation minting path (next slice). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub lineage: Vec, + + /// Execution phase (the §20 launch lifecycle), distinct from the + /// governance `phase`: + /// - `Idle` — governed but not launched (the default). + /// - `Launching` — a `KarsSandbox` has been materialized; awaiting it. + /// - `Running` — the sandbox reports Running. + /// - `Degraded` — the sandbox degraded (e.g. no inference endpoint). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub execution_phase: Option, + + /// Name of the `KarsSandbox` materialized for this task, when launched. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sandbox_ref: Option, + + /// Human-readable detail about the execution state — surfaced verbatim in + /// the product so a user understands *why* (e.g. the kind/Foundry caveat). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub execution_detail: Option, +} + +#[cfg(test)] +#[path = "kars_task_tests.rs"] +mod tests; + +#[cfg(test)] +#[path = "kars_task_authorization_tests.rs"] +mod authorization_tests; diff --git a/controller/src/kars_task_authorization_tests.rs b/controller/src/kars_task_authorization_tests.rs new file mode 100644 index 000000000..56486f168 --- /dev/null +++ b/controller/src/kars_task_authorization_tests.rs @@ -0,0 +1,413 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use super::*; +use crate::kars_approval::{ + ApprovalAction, ApprovalDecision, ApprovalOutcome, KarsApproval, KarsApprovalSpec, + KarsApprovalStatus, approval_authorizes_task, approval_binding_matches_task, evaluate, + request_snapshot, +}; + +fn model() -> TaskModel { + TaskModel { + provider: "azure-openai".into(), + deployment: "reviewed-model".into(), + } +} + +fn spec() -> KarsTaskSpec { + KarsTaskSpec { + objective: "Review the change".into(), + envelope: TaskEnvelope { + tier: 3, + authority_ceiling: 2, + delegation_depth: 1, + ..Default::default() + }, + blueprint: Some(TaskBlueprint { + runtime: Some("OpenClaw".into()), + model: Some(model()), + instructions: Some("Report findings".into()), + tool_policy: Some("read-only".into()), + mcp_servers: vec!["docs".into()], + egress: vec![TaskEgress { + host: "api.example.com".into(), + port: Some(443), + }], + isolation: Some("standard".into()), + memory: Some("team-memory".into()), + }), + ..Default::default() + } +} + +fn mark_ready(task: &mut KarsTask) { + task.status = Some(KarsTaskStatus { + phase: Some("Ready".into()), + observed_generation: task.metadata.generation, + envelope_digest: Some(task.envelope_digest()), + conditions: Some(vec![crate::status::conditions::new_condition( + "Ready", + "True", + "Reconciled", + "validated", + task.metadata.generation, + )]), + ..Default::default() + }); +} + +fn approved_task() -> (KarsTask, KarsApproval) { + let mut task = KarsTask::new("task", spec()); + task.metadata.uid = Some("task-uid".into()); + task.metadata.namespace = Some("work".into()); + task.metadata.generation = Some(1); + mark_ready(&mut task); + let mut approval = KarsApproval::new( + "approval", + KarsApprovalSpec { + task_ref: LocalObjectRef { + name: "task".into(), + }, + action: ApprovalAction { + kind: "tierRaise".into(), + summary: "Raise this task's tier".into(), + requested_tier: Some(4), + ..Default::default() + }, + decision: Some(ApprovalDecision { + verdict: "approve".into(), + decider: "alice".into(), + reason: None, + }), + ttl: Some("PT1H".into()), + }, + ); + approval.metadata.namespace = task.metadata.namespace.clone(); + approval.metadata.generation = Some(2); + approval.status = Some(KarsApprovalStatus { + phase: Some("Approved".into()), + observed_generation: Some(2), + bound_envelope_digest: Some(task.envelope_digest()), + bound_task_uid: task.metadata.uid.clone(), + bound_request: Some(request_snapshot(&approval.spec)), + decider: Some("alice".into()), + decided_at: Some("2026-09-07T12:00:00Z".into()), + ..Default::default() + }); + (task, approval) +} + +#[test] +fn every_effective_blueprint_axis_changes_task_authority() { + type SpecChange = fn(&mut KarsTaskSpec); + let original = spec(); + let changes: &[(&str, SpecChange)] = &[ + ("runtime", |s| { + s.blueprint.as_mut().unwrap().runtime = Some("Hermes".into()) + }), + ("model", |s| { + s.blueprint + .as_mut() + .unwrap() + .model + .as_mut() + .unwrap() + .deployment = "other-model".into() + }), + ("provider", |s| { + s.blueprint + .as_mut() + .unwrap() + .model + .as_mut() + .unwrap() + .provider = "github-models".into() + }), + ("isolation", |s| { + s.blueprint.as_mut().unwrap().isolation = Some("enhanced".into()) + }), + ("tool", |s| { + s.blueprint.as_mut().unwrap().tool_policy = Some("write-enabled".into()) + }), + ("mcp", |s| { + s.blueprint + .as_mut() + .unwrap() + .mcp_servers + .push("admin".into()) + }), + ("memory", |s| { + s.blueprint.as_mut().unwrap().memory = Some("other-memory".into()) + }), + ("egress host", |s| { + s.blueprint.as_mut().unwrap().egress[0].host = "other.example.com".into() + }), + ("egress port", |s| { + s.blueprint.as_mut().unwrap().egress[0].port = None + }), + ("instructions", |s| { + s.blueprint.as_mut().unwrap().instructions = Some("Publish changes".into()) + }), + ("objective", |s| { + s.objective = "A different objective".into() + }), + ("parent", |s| { + s.parent_ref = Some(LocalObjectRef { + name: "new-parent".into(), + }) + }), + ]; + for (axis, change) in changes { + let mut changed = original.clone(); + change(&mut changed); + assert_eq!( + changed.envelope.digest(), + original.envelope.digest(), + "{axis}: lattice unchanged" + ); + assert_ne!( + changed.authorization_digest(), + original.authorization_digest(), + "{axis}" + ); + } +} + +#[test] +fn defaults_aliases_and_runtime_precedence_have_one_canonical_digest() { + let baseline = KarsTaskSpec { + objective: "Review".into(), + ..Default::default() + }; + let digest = baseline.authorization_digest_with_model(&model()); + assert_eq!(digest.len(), 71); + assert_eq!( + digest, + "sha256:7089e6622e2ef5528f047701def62469360a849441ab9b285604da5f50b0c0c8" + ); + assert_ne!(digest, baseline.envelope.digest()); + let mut explicit = baseline.clone(); + explicit.blueprint = Some(TaskBlueprint { + runtime: Some("OpenClaw".into()), + model: Some(model()), + isolation: Some("standard".into()), + instructions: Some(" ".into()), + memory: Some(" ".into()), + ..Default::default() + }); + explicit.execution = Some(TaskExecution { + launch: true, + runtime: Some("Hermes".into()), + }); + explicit.display_name = Some("Display only".into()); + explicit.envelope.budget = Some(TaskBudget { + tokens: Some(0), + usd_micros: Some(0), + }); + assert_eq!(explicit.authorization_digest_with_model(&model()), digest); + explicit.blueprint.as_mut().unwrap().runtime = Some("MAF".into()); + let alias = explicit.authorization_digest_with_model(&model()); + explicit.blueprint.as_mut().unwrap().runtime = Some("MicrosoftAgentFramework".into()); + assert_eq!(explicit.authorization_digest_with_model(&model()), alias); + explicit.blueprint.as_mut().unwrap().runtime = None; + explicit.execution.as_mut().unwrap().runtime = Some("MAF".into()); + assert_eq!(explicit.authorization_digest_with_model(&model()), alias); +} + +#[test] +fn shared_authorization_snapshot_exposes_the_exact_effective_digest_input() { + let mut task = KarsTaskSpec { + objective: "Review".into(), + ..Default::default() + }; + task.envelope.budget = Some(TaskBudget { + tokens: Some(0), + usd_micros: Some(0), + }); + let configuration = task.authorization_configuration_with_model(&model()); + assert_eq!( + configuration, + serde_json::json!({ + "domain": "kars.azure.com/task-authorization/v1", + "envelope": { "tier": 1, "authorityCeiling": 1, "delegationDepth": 0 }, + "parentRef": null, + "blueprint": { + "runtime": "OpenClaw", + "model": { "deployment": "reviewed-model", "provider": "azure-openai" }, + "instructions": "Your objective:\nReview", + "isolation": "standard" + }, + "networkPolicy": { "defaultDeny": true, "egressMode": "Strict" } + }) + ); + assert_eq!( + task.authorization_digest_with_model(&model()), + "sha256:7089e6622e2ef5528f047701def62469360a849441ab9b285604da5f50b0c0c8" + ); +} + +#[test] +fn effective_controller_model_defaults_are_authority_not_invisible_ambient_config() { + let baseline = KarsTaskSpec::default(); + let different = TaskModel { + deployment: "different".into(), + ..model() + }; + assert_ne!( + baseline.authorization_digest_with_model(&model()), + baseline.authorization_digest_with_model(&different) + ); + let different = TaskModel { + provider: "github-models".into(), + ..model() + }; + assert_ne!( + baseline.authorization_digest_with_model(&model()), + baseline.authorization_digest_with_model(&different) + ); + let pinned = spec(); + assert_eq!( + pinned.authorization_digest_with_model(&model()), + pinned.authorization_digest_with_model(&different) + ); + let mut blank_provider = pinned.clone(); + blank_provider + .blueprint + .as_mut() + .unwrap() + .model + .as_mut() + .unwrap() + .provider + .clear(); + assert_eq!( + blank_provider.authorization_digest_with_model(&different), + pinned.authorization_digest_with_model(&different) + ); +} + +#[test] +fn pending_decisions_and_terminal_grants_cannot_authorize_changed_blueprints() { + let (mut task, approval) = approved_task(); + assert!(approval_authorizes_task(&approval, &task)); + let old_digest = task.envelope_digest(); + task.spec + .blueprint + .as_mut() + .unwrap() + .egress + .push(TaskEgress { + host: "admin.example.com".into(), + port: Some(443), + }); + task.metadata.generation = Some(2); + assert!(!approval_authorizes_task(&approval, &task)); + mark_ready(&mut task); + assert!(crate::kars_task_reconciler::task_is_ready(&task)); + assert_ne!(task.envelope_digest(), old_digest); + assert!(!approval_binding_matches_task(&approval, &task)); + assert!(!approval_authorizes_task(&approval, &task)); + assert_eq!( + approval.status.as_ref().unwrap().phase.as_deref(), + Some("Approved") + ); + assert!(matches!( + evaluate( + approval.spec.decision.as_ref(), + Some(&old_digest), + Some(&task.envelope_digest()), + false + ), + ApprovalOutcome::Stale(_) + )); +} + +#[test] +fn terminal_grants_require_current_task_uid_and_an_unchanged_decision_record() { + let (task, approval) = approved_task(); + let mut replacement = task.clone(); + replacement.metadata.uid = Some("replacement-task".into()); + assert_eq!(replacement.envelope_digest(), task.envelope_digest()); + assert!(!approval_authorizes_task(&approval, &replacement)); + let mut changed = approval.clone(); + changed.spec.action.requested_tier = Some(5); + assert!(!approval_authorizes_task(&changed, &task)); + let mut changed = approval.clone(); + changed.spec.decision.as_mut().unwrap().decider = "mallory".into(); + assert!(!approval_authorizes_task(&changed, &task)); + let mut changed = approval.clone(); + changed.status.as_mut().unwrap().phase = Some("Denied".into()); + assert!(!approval_authorizes_task(&changed, &task)); + let mut changed = approval; + changed.metadata.namespace = Some("other".into()); + assert!(!approval_authorizes_task(&changed, &task)); +} + +#[test] +fn blueprint_drift_invalidates_parent_readiness_without_weakening_attenuation() { + let (mut parent, _) = approved_task(); + let mut child = parent.spec.clone(); + child.envelope.tier = 2; + child.envelope.authority_ceiling = 2; + child.envelope.delegation_depth = 0; + assert!(spec_attenuation_violations(&child, &parent.spec).is_empty()); + parent.spec.blueprint.as_mut().unwrap().egress.clear(); + assert!(!crate::kars_task_reconciler::task_is_ready(&parent)); + assert!( + spec_attenuation_violations(&child, &parent.spec) + .iter() + .any(|violation| matches!(violation, EnvelopeViolation::EgressNotSubset { .. })) + ); + mark_ready(&mut parent); + assert!(crate::kars_task_reconciler::task_is_ready(&parent)); +} + +#[test] +fn invalid_pinned_tool_references_cannot_normalize_into_different_authority() { + for name in ["", " read-only "] { + let mut spec = KarsTaskSpec::default(); + spec.envelope.tool_policy_ref = Some(LocalObjectRef { name: name.into() }); + assert!(validate_execution_contract(&spec).is_err()); + } +} + +#[test] +fn receipt_subject_follows_task_authority_and_refuses_stale_status() { + use crate::kars_receipt::{PredicateCompleteness, build_statement}; + let (mut task, _) = approved_task(); + let old_status = task.status.clone().unwrap(); + let old = build_statement( + &task, + &old_status, + "key", + &[], + PredicateCompleteness::default(), + ) + .unwrap(); + task.spec.blueprint.as_mut().unwrap().memory = Some("different-memory".into()); + assert!( + build_statement( + &task, + &old_status, + "key", + &[], + PredicateCompleteness::default() + ) + .is_none() + ); + mark_ready(&mut task); + let new = build_statement( + &task, + task.status.as_ref().unwrap(), + "key", + &[], + PredicateCompleteness::default(), + ) + .unwrap(); + assert_ne!(old.subject[0].digest.sha256, new.subject[0].digest.sha256); + assert_eq!( + new.subject[0].digest.sha256, + task.envelope_digest().trim_start_matches("sha256:") + ); +} diff --git a/controller/src/kars_task_blueprint.rs b/controller/src/kars_task_blueprint.rs new file mode 100644 index 000000000..8cbff0fab --- /dev/null +++ b/controller/src/kars_task_blueprint.rs @@ -0,0 +1,135 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! One effective blueprint for materialization and authorization binding. + +use super::{KarsTaskSpec, TaskBlueprint, TaskModel, effective_tool_policy}; + +pub fn effective_runtime_name(spec: &KarsTaskSpec) -> &str { + match spec + .blueprint + .as_ref() + .and_then(|b| b.runtime.as_deref()) + .or_else(|| spec.execution.as_ref().and_then(|e| e.runtime.as_deref())) + .unwrap_or("OpenClaw") + { + "MAF" => "MicrosoftAgentFramework", + runtime => runtime, + } +} + +/// Includes effective instructions, model/provider defaults, every capability +/// reference, and the runtime override precedence used to create the sandbox. +/// Clone the entire blueprint so newly added fields cannot disappear from the +/// authorization digest merely because this normalizer has not changed yet. +pub fn effective_blueprint(spec: &KarsTaskSpec) -> TaskBlueprint { + effective_blueprint_with_model(spec, &controller_default_model()) +} + +pub fn effective_blueprint_with_model( + spec: &KarsTaskSpec, + default_model: &TaskModel, +) -> TaskBlueprint { + let mut blueprint = spec.blueprint.clone().unwrap_or_default(); + blueprint.runtime = Some(effective_runtime_name(spec).to_string()); + blueprint.model = Some(match &blueprint.model { + Some(model) if !model.deployment.trim().is_empty() => TaskModel { + deployment: model.deployment.clone(), + provider: if model.provider.trim().is_empty() { + "azure-openai".into() + } else { + model.provider.clone() + }, + }, + _ => default_model.clone(), + }); + blueprint.instructions = Some(build_instructions( + &spec.objective, + blueprint.instructions.as_deref(), + )); + blueprint.tool_policy = effective_tool_policy(spec).map(str::to_string); + blueprint.isolation = Some( + blueprint + .isolation + .take() + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| "standard".into()), + ); + blueprint.memory = blueprint + .memory + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string); + blueprint +} + +pub fn build_instructions(objective: &str, extra: Option<&str>) -> String { + let mut instructions = format!("Your objective:\n{}", objective.trim()); + if let Some(extra) = extra.map(str::trim).filter(|s| !s.is_empty()) { + instructions.push_str("\n\nAdditional instructions:\n"); + instructions.push_str(extra); + } + instructions +} + +pub fn controller_default_model() -> TaskModel { + resolve_default_model( + std::env::var("KARS_TASK_DEFAULT_MODEL").ok().as_deref(), + std::env::var("AZURE_OPENAI_DEPLOYMENT").ok().as_deref(), + std::env::var("DEFAULT_MODEL").ok().as_deref(), + std::env::var("KARS_TASK_DEFAULT_PROVIDER").ok().as_deref(), + ) +} + +fn resolve_default_model( + task: Option<&str>, + azure: Option<&str>, + default: Option<&str>, + provider: Option<&str>, +) -> TaskModel { + TaskModel { + deployment: [task, azure, default] + .into_iter() + .flatten() + .find(|s| !s.is_empty()) + .unwrap_or("gpt-4o-mini") + .into(), + provider: provider + .filter(|s| !s.is_empty()) + .unwrap_or("azure-openai") + .into(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_model_precedence_is_pure_and_does_not_mutate_process_environment() { + let builtin = resolve_default_model(None, None, None, None); + assert_eq!(builtin.deployment, "gpt-4o-mini"); + assert_eq!(builtin.provider, "azure-openai"); + assert_eq!( + resolve_default_model(None, None, Some("default"), None).deployment, + "default" + ); + assert_eq!( + resolve_default_model(None, Some("azure"), Some("default"), None).deployment, + "azure" + ); + let explicit = resolve_default_model( + Some("task"), + Some("azure"), + Some("default"), + Some("github-models"), + ); + assert_eq!(explicit.deployment, "task"); + assert_eq!(explicit.provider, "github-models"); + assert_eq!( + resolve_default_model(Some(""), Some("azure"), None, Some("")).deployment, + "azure" + ); + } +} diff --git a/controller/src/kars_task_execution.rs b/controller/src/kars_task_execution.rs new file mode 100644 index 000000000..6c83b5634 --- /dev/null +++ b/controller/src/kars_task_execution.rs @@ -0,0 +1,568 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! `KarsTask` execution bridge (Bridge V0.1b) — materialize a governed +//! `KarsSandbox` from a launched task. +//! +//! This is the wire that turns a *governed* task (validated envelope + digest) +//! into a *running* one. It is gated by `spec.execution.launch` (plan §20: +//! review the package, then launch). On launch the controller materializes, +//! owned by the task for cascade cleanup: +//! +//! 1. a minimal `InferencePolicy` (`-inference`) the sandbox references; +//! 2. a `KarsSandbox` (``) bounded by the task's envelope — the existing +//! sandbox reconciler then spawns the real pod + OpenClaw agent through the +//! secure inference router. +//! +//! **Honest limitation:** the sandbox needs a real AI Foundry inference +//! endpoint to perform inference. On a local kind cluster with no endpoint the +//! sandbox materializes but degrades at the inference step — the controller +//! surfaces that verbatim in `status.executionDetail` rather than hiding it. + +use kube::api::{Api, DeleteParams, DynamicObject, ObjectMeta, PostParams, Preconditions}; +use kube::core::ApiResource; +use kube::{Client, ResourceExt}; +use serde_json::json; + +use crate::kars_task::{KarsTask, TaskBlueprint, TaskEnvelope}; + +const FIELD_MANAGER: &str = crate::field_managers::CLAW_TASK; + +fn sandbox_api_resource() -> ApiResource { + ApiResource { + group: "kars.azure.com".into(), + version: "v1alpha1".into(), + api_version: "kars.azure.com/v1alpha1".into(), + kind: "KarsSandbox".into(), + plural: "karssandboxes".into(), + } +} + +fn inference_policy_api_resource() -> ApiResource { + ApiResource { + group: "kars.azure.com".into(), + version: "v1alpha1".into(), + api_version: "kars.azure.com/v1alpha1".into(), + kind: "InferencePolicy".into(), + plural: "inferencepolicies".into(), + } +} + +/// Outcome of a launch reconcile, reflected into `KarsTask.status`. +pub struct ExecutionOutcome { + /// `Launching` | `Running` | `Degraded`. + pub phase: String, + /// Name of the materialized sandbox. + pub sandbox_name: String, + /// Human-readable detail surfaced verbatim in the product. + pub detail: String, +} + +/// Controller owner reference binding materialized resources to the task UID. +fn owner_ref(task: &KarsTask) -> serde_json::Value { + json!([{ + "apiVersion": "kars.azure.com/v1alpha1", + "kind": "KarsTask", + "name": task.name_any(), + "uid": task.uid().unwrap_or_default(), + "controller": true, + "blockOwnerDeletion": true, + }]) +} + +fn runtime_spec(task: &KarsTask) -> Result { + use crate::crd::{RuntimeKind, RuntimeSpec}; + let kind = crate::kars_task::task_runtime(&task.spec).map_err(contract_error)?; + let mut runtime = RuntimeSpec { + kind: kind.clone(), + openclaw: None, + ..RuntimeSpec::default() + }; + match kind { + RuntimeKind::OpenClaw => runtime.openclaw = Some(Default::default()), + RuntimeKind::OpenAIAgents => runtime.openai_agents = Some(Default::default()), + RuntimeKind::MicrosoftAgentFramework => { + runtime.microsoft_agent_framework = Some(Default::default()); + } + RuntimeKind::Hermes => runtime.hermes = Some(Default::default()), + _ => return Err(contract_error("unsupported task runtime".into())), + } + Ok(runtime) +} + +fn contract_error(message: String) -> kube::Error { + kube::Error::Api(Box::new(kube::core::Status { + message, + reason: "Conflict".into(), + code: 409, + ..Default::default() + })) +} + +fn network_policy(blueprint: &TaskBlueprint) -> serde_json::Value { + json!({ + "defaultDeny": true, + "egressMode": "Strict", + "allowedEndpoints": blueprint.egress, + }) +} + +/// Materialize the InferencePolicy + KarsSandbox for a launched task using +/// atomic creation or version-checked owned updates, then read sandbox status. +pub async fn materialize( + client: &Client, + namespace: &str, + task: &KarsTask, +) -> Result { + crate::kars_task::validate_execution_contract(&task.spec).map_err(contract_error)?; + let task_name = task.name_any(); + let inference_name = format!("{task_name}-inference"); + let envelope = &task.spec.envelope; + let blueprint = crate::kars_task::blueprint::effective_blueprint(&task.spec); + let runtime = runtime_spec(task)?; + + // 1. InferencePolicy scoped to this sandbox. Model: blueprint wins, else + // the controller default (required — without it the sandbox degrades). + let inference_spec = json!({ + "appliesTo": { "sandboxName": task_name }, + "modelPreference": { + "primary": blueprint.model, + }, + }); + apply_dynamic( + client, + namespace, + &inference_policy_api_resource(), + &inference_name, + task, + inference_spec, + None, + ) + .await?; + + // 2. KarsSandbox bounded by the envelope + shaped by the blueprint. Each + // blueprint field drives a real sandbox field; unset → safe default. + let mut sandbox_spec = json!({ + "runtime": runtime, + "inferenceRef": { "name": inference_name }, + "sandbox": { "isolation": blueprint.isolation }, + "networkPolicy": network_policy(&blueprint), + }); + + // Agent instructions (the system prompt) — combine the objective with any + // standing instructions the blueprint carries, so the agent knows both + // *what* to do and *how* to behave. + sandbox_spec["agent"] = json!({ "instructions": blueprint.instructions }); + + // Governance: tools = an existing ToolPolicy (composed by reference), from + // the blueprint or the envelope; MCP servers (connected services) ride on + // top, bounded by that policy. See `governance_spec`. + sandbox_spec["governance"] = governance_spec(&blueprint, envelope); + + // Shared team memory: reference an existing KarsMemory so the agent + // reads/writes the team's shared knowledge (persistent teams share memory + // across members and over time). + if let Some(mem) = blueprint + .memory + .as_ref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + { + sandbox_spec["memoryRef"] = json!({ "name": mem }); + } + // Task attribution for router metering: the task id and its lineage *root* + // (the oldest ancestor, or the task itself when it is a root). The main + // reconciler forwards these to the router as KARS_TASK_ID / KARS_TASK_ROOT + // so token cost is attributable per task branch. + let task_root = task + .status + .as_ref() + .and_then(|s| s.lineage.first().cloned()) + .unwrap_or_else(|| task_name.clone()); + let attribution = std::collections::BTreeMap::from([ + ("kars.azure.com/task-id".to_string(), task_name.clone()), + ("kars.azure.com/task-root".to_string(), task_root), + ]); + apply_dynamic( + client, + namespace, + &sandbox_api_resource(), + &task_name, + task, + sandbox_spec, + Some(attribution), + ) + .await?; + + // 3. Read back the sandbox phase to reflect honest execution status. + let sb_api: Api = + Api::namespaced_with(client.clone(), namespace, &sandbox_api_resource()); + let (phase, detail) = match sb_api.get_opt(&task_name).await? { + Some(sb) => { + if !owned_by_task(&sb, task) { + return Err(contract_error( + "sandbox was replaced after materialization".into(), + )); + } + let sb_phase = sb + .data + .get("status") + .and_then(|s| s.get("phase")) + .and_then(|p| p.as_str()) + .unwrap_or("") + .to_string(); + map_sandbox_phase(&sb_phase) + } + None => ( + "Launching".to_string(), + "Sandbox materialized; awaiting the controller to reconcile it.".to_string(), + ), + }; + + Ok(ExecutionOutcome { + phase, + sandbox_name: task_name, + detail, + }) +} + +/// Tear down the materialized sandbox + inference policy when a task is +/// un-launched (`execution.launch` flipped back to false). Owner references +/// also cascade on task deletion; this handles the in-place un-launch. +/// Returns true only once no owned execution resources remain. +pub async fn teardown( + client: &Client, + namespace: &str, + task: &KarsTask, +) -> Result { + let task_name = task.name_any(); + let sb_api: Api = + Api::namespaced_with(client.clone(), namespace, &sandbox_api_resource()); + let ip_api: Api = + Api::namespaced_with(client.clone(), namespace, &inference_policy_api_resource()); + let sandbox_gone = delete_owned(&sb_api, &task_name, task).await?; + let policy_gone = delete_owned(&ip_api, &format!("{task_name}-inference"), task).await?; + Ok(sandbox_gone && policy_gone) +} + +fn owned_by_task(object: &DynamicObject, task: &KarsTask) -> bool { + task.metadata + .uid + .as_deref() + .filter(|uid| !uid.is_empty()) + .is_some_and(|uid| { + object + .metadata + .owner_references + .as_ref() + .is_some_and(|owners| { + owners + .iter() + .filter(|owner| owner.controller == Some(true)) + .count() + == 1 + && owners.iter().any(|owner| { + owner.controller == Some(true) + && owner.uid == uid + && owner.name == task.name_any() + && owner.kind == "KarsTask" + && owner.api_version == "kars.azure.com/v1alpha1" + }) + }) + }) +} + +fn object_preconditions(object: &DynamicObject) -> Result { + let uid = object + .uid() + .filter(|s| !s.is_empty()) + .ok_or_else(|| contract_error("resource UID missing".into()))?; + let resource_version = object + .resource_version() + .filter(|s| !s.is_empty()) + .ok_or_else(|| contract_error("resourceVersion missing".into()))?; + Ok(Preconditions { + uid: Some(uid), + resource_version: Some(resource_version), + }) +} + +async fn delete_owned( + api: &Api, + name: &str, + task: &KarsTask, +) -> Result { + let Some(object) = api.get_opt(name).await? else { + return Ok(true); + }; + if !owned_by_task(&object, task) { + return Ok(true); + } + if object.metadata.deletion_timestamp.is_none() { + let params = DeleteParams { + preconditions: Some(object_preconditions(&object)?), + ..Default::default() + }; + match api.delete(name, ¶ms).await { + Ok(_) => {} + Err(kube::Error::Api(error)) if error.code == 404 => return Ok(true), + Err(error) => return Err(error), + } + } + Ok(api + .get_opt(name) + .await? + .is_none_or(|object| !owned_by_task(&object, task))) +} + +/// Build the sandbox governance block by composing an existing `ToolPolicy` +/// (from the blueprint or the envelope) plus any MCP server refs. Tools are a +/// `ToolPolicy` reference rather than a duplicated allow-list, so the AGT +/// profile + `appliesTo` scope stay authoritative. MCP refs only attach when a +/// tool policy bounds them; without a policy governance stays `enabled: false` +/// (a valid, un-governed sandbox) instead of an invalid `enabled: true` with no +/// `toolPolicyRef`. +fn governance_spec(blueprint: &TaskBlueprint, envelope: &TaskEnvelope) -> serde_json::Value { + let tool_policy = blueprint + .tool_policy + .as_ref() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .or_else(|| envelope.tool_policy_ref.as_ref().map(|r| r.name.clone())); + match tool_policy { + Some(tp) => { + let mut g = json!({ "enabled": true, "toolPolicyRef": { "name": tp } }); + if !blueprint.mcp_servers.is_empty() { + let refs: Vec = blueprint + .mcp_servers + .iter() + .map(|name| json!({ "name": name })) + .collect(); + g["mcpServerRefs"] = json!(refs); + } + g + } + None => json!({ "enabled": false }), + } +} + +/// Map a `KarsSandbox` phase to the task's execution phase + honest detail. +fn map_sandbox_phase(sb_phase: &str) -> (String, String) { + match sb_phase { + "Running" => ( + "Running".to_string(), + "The governed agent is running in its sandbox.".to_string(), + ), + "Failed" | "Degraded" => ( + "Degraded".to_string(), + "Sandbox degraded. On a local cluster this is expected at the inference \ + step — a real AI Foundry endpoint is required for the agent to run." + .to_string(), + ), + "" | "Pending" | "Creating" => ( + "Launching".to_string(), + "Sandbox materialized; the controller is bringing the agent up.".to_string(), + ), + other => ("Launching".to_string(), format!("Sandbox phase: {other}.")), + } +} + +/// Create atomically or replace an already-owned object using its UID and +/// resourceVersion. Never adopt a same-name customer object or force ownership. +async fn apply_dynamic( + client: &Client, + namespace: &str, + ar: &ApiResource, + name: &str, + task: &KarsTask, + spec: serde_json::Value, + annotations: Option>, +) -> Result<(), kube::Error> { + if task.uid().is_none_or(|uid| uid.is_empty()) { + return Err(contract_error("task UID missing".into())); + } + let api: Api = Api::namespaced_with(client.clone(), namespace, ar); + let mut obj = DynamicObject::new(name, ar).within(namespace); + obj.metadata = ObjectMeta { + name: Some(name.to_string()), + namespace: Some(namespace.to_string()), + owner_references: serde_json::from_value(owner_ref(task)).ok(), + labels: Some(std::collections::BTreeMap::from([ + ( + "app.kubernetes.io/managed-by".to_string(), + "kars-controller".to_string(), + ), + ("kars.azure.com/karstask".to_string(), task.name_any()), + ])), + annotations, + ..Default::default() + }; + obj.data = json!({ "spec": spec }); + let params = PostParams { + field_manager: Some(FIELD_MANAGER.into()), + ..Default::default() + }; + match api.get_opt(name).await? { + None => { + api.create(¶ms, &obj).await?; + } + Some(mut current) => { + if !owned_by_task(¤t, task) || current.metadata.deletion_timestamp.is_some() { + return Err(contract_error(format!( + "refusing to replace {name}: not owned by this task UID or terminating" + ))); + } + object_preconditions(¤t)?; + current.data["spec"] = obj.data["spec"].clone(); + current + .metadata + .labels + .get_or_insert_default() + .extend(obj.metadata.labels.unwrap_or_default()); + current + .metadata + .annotations + .get_or_insert_default() + .extend(obj.metadata.annotations.unwrap_or_default()); + api.replace(name, ¶ms, ¤t).await?; + } + } + Ok(()) +} + +#[cfg(test)] +#[path = "kars_task_execution_tests.rs"] +mod api_tests; + +#[cfg(test)] +mod tests { + use super::*; + use crate::kars_task::blueprint::build_instructions; + + #[test] + fn build_instructions_includes_objective_and_extra() { + let only_obj = build_instructions("Summarize the doc", None); + assert!(only_obj.contains("Summarize the doc")); + assert!(only_obj.contains("Your objective")); + assert!(!only_obj.contains("Additional instructions")); + + let with_extra = build_instructions("Summarize the doc", Some("Be concise. Cite sources.")); + assert!(with_extra.contains("Summarize the doc")); + assert!(with_extra.contains("Additional instructions")); + assert!(with_extra.contains("Be concise")); + + // Blank extra is ignored. + let blank = build_instructions("X", Some(" ")); + assert!(!blank.contains("Additional instructions")); + } + + #[test] + fn runtime_variants_follow_the_sandbox_contract() { + for (input, canonical, key) in [ + ("OpenClaw", "OpenClaw", "openclaw"), + ("OpenAIAgents", "OpenAIAgents", "openaiAgents"), + ("MAF", "MicrosoftAgentFramework", "microsoftAgentFramework"), + ( + "MicrosoftAgentFramework", + "MicrosoftAgentFramework", + "microsoftAgentFramework", + ), + ("Hermes", "Hermes", "hermes"), + ] { + let task = KarsTask::new( + "t", + crate::kars_task::KarsTaskSpec { + blueprint: Some(TaskBlueprint { + runtime: Some(input.into()), + ..Default::default() + }), + ..Default::default() + }, + ); + let runtime = runtime_spec(&task).unwrap(); + crate::reconciler::runtime::validate_runtime_shape(&runtime).unwrap(); + let value = serde_json::to_value(runtime).unwrap(); + assert_eq!(value["kind"], canonical); + assert!(value.get(key).is_some()); + } + } + + #[test] + fn empty_task_egress_is_strict_without_changing_standalone_default() { + let policy = network_policy(&TaskBlueprint::default()); + assert_eq!(policy["egressMode"], "Strict"); + assert_eq!(policy["allowedEndpoints"], json!([])); + let standalone: crate::crd::NetworkPolicyConfig = + serde_json::from_value(json!({})).unwrap(); + assert_eq!( + serde_json::to_value(standalone).unwrap()["egressMode"], + "Learn" + ); + } + + #[test] + fn governance_disabled_without_tool_policy() { + let e = TaskEnvelope { + tier: 3, + authority_ceiling: 2, + delegation_depth: 1, + budget: None, + tool_policy_ref: None, + egress_allowlist_ref: None, + }; + let bp = TaskBlueprint::default(); + let g = governance_spec(&bp, &e); + assert_eq!(g["enabled"], false); + assert!(g.get("toolPolicyRef").is_none()); + } + + #[test] + fn governance_uses_envelope_tool_policy() { + let e = TaskEnvelope { + tier: 3, + authority_ceiling: 2, + delegation_depth: 1, + budget: None, + tool_policy_ref: Some(crate::mcp_server::LocalObjectRef { name: "tp".into() }), + egress_allowlist_ref: None, + }; + let g = governance_spec(&TaskBlueprint::default(), &e); + assert_eq!(g["enabled"], true); + assert_eq!(g["toolPolicyRef"]["name"], "tp"); + } + + #[test] + fn governance_blueprint_tool_policy_carries_mcp_refs() { + let e = TaskEnvelope { + tier: 3, + authority_ceiling: 2, + delegation_depth: 1, + budget: None, + tool_policy_ref: None, + egress_allowlist_ref: None, + }; + let bp = TaskBlueprint { + tool_policy: Some("eng-tools".into()), + mcp_servers: vec!["docs-index".into(), "jira".into()], + ..Default::default() + }; + let g = governance_spec(&bp, &e); + assert_eq!(g["enabled"], true); + assert_eq!(g["toolPolicyRef"]["name"], "eng-tools"); + assert_eq!(g["mcpServerRefs"][0]["name"], "docs-index"); + assert_eq!(g["mcpServerRefs"][1]["name"], "jira"); + } + + #[test] + fn degraded_phase_explains_inference_caveat() { + let (phase, detail) = map_sandbox_phase("Degraded"); + assert_eq!(phase, "Degraded"); + assert!(detail.contains("Foundry")); + } + + #[test] + fn running_phase_maps_through() { + let (phase, _) = map_sandbox_phase("Running"); + assert_eq!(phase, "Running"); + } +} diff --git a/controller/src/kars_task_execution_tests.rs b/controller/src/kars_task_execution_tests.rs new file mode 100644 index 000000000..08ce0c690 --- /dev/null +++ b/controller/src/kars_task_execution_tests.rs @@ -0,0 +1,284 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use super::*; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +const OBJECT_PATH: &str = "/apis/kars.azure.com/v1alpha1/namespaces/default/karssandboxes/demo"; +const COLLECTION_PATH: &str = "/apis/kars.azure.com/v1alpha1/namespaces/default/karssandboxes"; + +fn task() -> KarsTask { + let mut task = KarsTask::new("demo", Default::default()); + task.metadata.uid = Some("task-uid".into()); + task +} + +fn object(task: &KarsTask) -> DynamicObject { + serde_json::from_value(json!({ + "apiVersion": "kars.azure.com/v1alpha1", + "kind": "KarsSandbox", + "metadata": { + "name": "demo", "namespace": "default", + "uid": "sandbox-uid", "resourceVersion": "42", + "ownerReferences": owner_ref(task), + }, + "spec": { "oldField": "remove-me" }, + "status": { "phase": "Running" }, + })) + .unwrap() +} + +fn client(server: &MockServer) -> Client { + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + Client::try_from(kube::Config::new(server.uri().parse().unwrap())).unwrap() +} + +fn api_error(code: u16) -> ResponseTemplate { + let reason = match code { + 403 => "Forbidden", + 404 => "NotFound", + 409 => "Conflict", + 500 => "InternalError", + _ => "Failure", + }; + ResponseTemplate::new(code).set_body_json(json!({ + "apiVersion": "v1", "kind": "Status", "status": "Failure", + "message": "test API failure", "reason": reason, "code": code, + })) +} + +async fn apply(server: &MockServer) -> Result<(), kube::Error> { + apply_dynamic( + &client(server), + "default", + &sandbox_api_resource(), + "demo", + &task(), + json!({ "networkPolicy": { "egressMode": "Strict" } }), + None, + ) + .await +} + +#[tokio::test] +async fn unowned_and_previous_task_uid_objects_are_never_modified_or_deleted() { + for old_uid in [None, Some("previous-task-uid")] { + let server = MockServer::start().await; + let mut existing = object(&task()); + if let Some(uid) = old_uid { + existing.metadata.owner_references.as_mut().unwrap()[0].uid = uid.into(); + } else { + existing.metadata.owner_references = None; + } + Mock::given(method("GET")) + .and(path(OBJECT_PATH)) + .respond_with(ResponseTemplate::new(200).set_body_json(&existing)) + .mount(&server) + .await; + assert!(apply(&server).await.is_err()); + let api = Api::namespaced_with(client(&server), "default", &sandbox_api_resource()); + assert!(delete_owned(&api, "demo", &task()).await.unwrap()); + assert!( + server + .received_requests() + .await + .unwrap() + .iter() + .all(|r| r.method == "GET") + ); + } +} + +#[tokio::test] +async fn creation_collision_is_not_retried_as_an_adoption() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path(OBJECT_PATH)) + .respond_with(api_error(404)) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path(COLLECTION_PATH)) + .respond_with(api_error(409)) + .expect(1) + .mount(&server) + .await; + assert!(matches!(apply(&server).await, Err(kube::Error::Api(e)) if e.code == 409)); + assert_eq!(server.received_requests().await.unwrap().len(), 2); +} + +#[tokio::test] +async fn owned_update_has_uid_and_version_and_does_not_force_apply() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path(OBJECT_PATH)) + .respond_with(ResponseTemplate::new(200).set_body_json(object(&task()))) + .mount(&server) + .await; + Mock::given(method("PUT")) + .and(path(OBJECT_PATH)) + .respond_with(api_error(409)) + .expect(1) + .mount(&server) + .await; + assert!(matches!(apply(&server).await, Err(kube::Error::Api(e)) if e.code == 409)); + let requests = server.received_requests().await.unwrap(); + let update: serde_json::Value = requests[1].body_json().unwrap(); + assert_eq!(update["metadata"]["uid"], "sandbox-uid"); + assert_eq!(update["metadata"]["resourceVersion"], "42"); + assert_eq!(update["status"]["phase"], "Running"); + assert!(update["spec"].get("oldField").is_none()); + assert!(!requests[1].url.query().unwrap_or("").contains("force")); +} + +#[tokio::test] +async fn delete_failures_propagate_with_race_preconditions() { + for code in [403, 409, 500] { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path(OBJECT_PATH)) + .respond_with(ResponseTemplate::new(200).set_body_json(object(&task()))) + .mount(&server) + .await; + Mock::given(method("DELETE")) + .and(path(OBJECT_PATH)) + .respond_with(api_error(code)) + .mount(&server) + .await; + let api = Api::namespaced_with(client(&server), "default", &sandbox_api_resource()); + assert!( + matches!(delete_owned(&api, "demo", &task()).await, Err(kube::Error::Api(e)) if e.code == code) + ); + let requests = server.received_requests().await.unwrap(); + let deletion: serde_json::Value = requests[1].body_json().unwrap(); + assert_eq!(deletion["preconditions"]["uid"], "sandbox-uid"); + assert_eq!(deletion["preconditions"]["resourceVersion"], "42"); + } +} + +#[tokio::test] +async fn teardown_discovers_resources_without_task_status_and_waits_for_finalizers() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path(OBJECT_PATH)) + .respond_with(ResponseTemplate::new(200).set_body_json(object(&task()))) + .mount(&server) + .await; + Mock::given(method("DELETE")) + .and(path(OBJECT_PATH)) + .respond_with(ResponseTemplate::new(200).set_body_json(object(&task()))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path( + "/apis/kars.azure.com/v1alpha1/namespaces/default/inferencepolicies/demo-inference", + )) + .respond_with(api_error(404)) + .mount(&server) + .await; + assert!(task().status.is_none()); + assert!( + !teardown(&client(&server), "default", &task()) + .await + .unwrap() + ); +} + +#[tokio::test] +async fn deleting_an_already_absent_object_is_successful() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path(OBJECT_PATH)) + .respond_with(api_error(404)) + .mount(&server) + .await; + let api = Api::namespaced_with(client(&server), "default", &sandbox_api_resource()); + assert!(delete_owned(&api, "demo", &task()).await.unwrap()); + assert_eq!(server.received_requests().await.unwrap().len(), 1); +} + +#[tokio::test] +async fn materialized_resources_match_the_authorization_blueprint() { + use crate::kars_task::{TaskEgress, TaskModel}; + let server = MockServer::start().await; + let mut task = task(); + task.spec.objective = "Review the patch".into(); + task.spec.blueprint = Some(TaskBlueprint { + runtime: Some("MAF".into()), + model: Some(TaskModel { + deployment: "reviewed-model".into(), + provider: String::new(), + }), + instructions: Some(" Cite evidence. ".into()), + tool_policy: Some("read-only".into()), + mcp_servers: vec!["docs".into()], + memory: Some(" team-memory ".into()), + isolation: Some("enhanced".into()), + egress: vec![TaskEgress { + host: "docs.example.com".into(), + port: Some(443), + }], + }); + Mock::given(method("GET")) + .and(path(OBJECT_PATH)) + .respond_with(api_error(404)) + .with_priority(1) + .up_to_n_times(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path(OBJECT_PATH)) + .respond_with(ResponseTemplate::new(200).set_body_json(object(&task))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path( + "/apis/kars.azure.com/v1alpha1/namespaces/default/inferencepolicies/demo-inference", + )) + .respond_with(api_error(404)) + .mount(&server) + .await; + Mock::given(method("POST")) + .respond_with(|request: &wiremock::Request| { + let mut resource: serde_json::Value = request.body_json().unwrap(); + resource["metadata"]["uid"] = json!("created-resource"); + resource["metadata"]["resourceVersion"] = json!("1"); + ResponseTemplate::new(201).set_body_json(resource) + }) + .expect(2) + .mount(&server) + .await; + let effective = crate::kars_task::blueprint::effective_blueprint(&task.spec); + let outcome = materialize(&client(&server), "default", &task) + .await + .unwrap(); + assert_eq!(outcome.phase, "Running"); + let requests = server.received_requests().await.unwrap(); + let specs: Vec = requests + .iter() + .filter(|r| r.method == "POST") + .map(|r| r.body_json::().unwrap()["spec"].clone()) + .collect(); + assert_eq!( + specs[0]["modelPreference"]["primary"], + json!(effective.model) + ); + assert_eq!(specs[1]["runtime"]["kind"], "MicrosoftAgentFramework"); + assert_eq!(specs[1]["sandbox"]["isolation"], json!(effective.isolation)); + assert_eq!( + specs[1]["agent"]["instructions"], + json!(effective.instructions) + ); + assert_eq!( + specs[1]["networkPolicy"]["allowedEndpoints"], + json!(effective.egress) + ); + assert_eq!(specs[1]["networkPolicy"]["egressMode"], "Strict"); + assert_eq!( + specs[1]["governance"]["toolPolicyRef"]["name"], + json!(effective.tool_policy) + ); + assert_eq!(specs[1]["governance"]["mcpServerRefs"][0]["name"], "docs"); + assert_eq!(specs[1]["memoryRef"]["name"], json!(effective.memory)); +} diff --git a/controller/src/kars_task_reconciler.rs b/controller/src/kars_task_reconciler.rs new file mode 100644 index 000000000..3a721f5d5 --- /dev/null +++ b/controller/src/kars_task_reconciler.rs @@ -0,0 +1,768 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! `KarsTask` reconciler — kars Bridge V0, slice 1. +//! +//! Watches `KarsTask` CRs and, for each: +//! +//! 1. Ensures the cleanup finalizer. +//! 2. Validates the trust envelope (defence-in-depth behind CEL admission) +//! and computes its stable `envelopeDigest`. +//! 3. Stamps `status.phase`, `status.observedGeneration`, the `Ready` +//! condition, and `status.envelopeDigest`, preserving any `lineage` +//! written by the delegation-minting path (next slice). +//! +//! Launched tasks also materialize owned execution resources. Cleanup retains +//! the task finalizer until those resources are gone; invalid contracts cannot +//! launch or retain an execution sandbox. + +use anyhow::Result; +use futures::StreamExt; +use kube::{ + Client, ResourceExt, + api::{Api, ListParams, Patch, PatchParams}, + runtime::controller::{Action, Controller}, +}; +use serde_json::json; +use std::sync::Arc; +use std::time::Duration; + +use crate::kars_task::{KarsTask, KarsTaskStatus, TIER_MAX, TIER_MIN}; +use crate::status::conditions::{self, TYPE_READY, reason as cond_reason, status as cond_status}; +use crate::status::phase::{PHASE_DEGRADED, PHASE_PENDING, PHASE_READY}; + +const FIELD_MANAGER: &str = crate::field_managers::CLAW_TASK; +const FINALIZER: &str = "kars.azure.com/karstask-cleanup"; +/// Server-Side Apply field manager for Governance Receipt writes. +const RECEIPT_FIELD_MANAGER: &str = "kars-controller/receipt"; + +const REQUEUE_OK: Duration = Duration::from_secs(300); + +/// A child waiting on its parent requeues quickly so it converges to `Ready` +/// promptly once the parent reconciles, rather than waiting a full cycle. +const REQUEUE_PENDING: Duration = Duration::from_secs(10); + +#[derive(Debug, thiserror::Error)] +enum ReconcileError { + #[error("Kubernetes API error: {0}")] + Kube(#[from] kube::Error), + #[error("JSON serialization error: {0}")] + SerdeJson(#[from] serde_json::Error), +} + +impl ReconcileError { + fn class(&self) -> &'static str { + match self { + ReconcileError::Kube(_) => "kube_api", + ReconcileError::SerdeJson(_) => "serde", + } + } +} + +/// Result of validating an envelope: either valid, or a human-readable +/// reason the task is `Degraded`. Kept pure so it is unit-testable without +/// a cluster. +enum EnvelopeCheck { + Valid, + Invalid(String), +} + +/// Validate the trust-envelope invariants. This mirrors the CEL admission +/// rules as a second line of defence — a CR that somehow reached the +/// reconciler with a bad envelope is surfaced as `Degraded` rather than +/// silently digested. +fn check_envelope(task: &KarsTask) -> EnvelopeCheck { + let e = &task.spec.envelope; + if e.tier < TIER_MIN || e.tier > TIER_MAX { + return EnvelopeCheck::Invalid(format!("tier {} out of range 1..5", e.tier)); + } + if e.authority_ceiling < TIER_MIN || e.authority_ceiling > TIER_MAX { + return EnvelopeCheck::Invalid(format!( + "authorityCeiling {} out of range 1..5", + e.authority_ceiling + )); + } + if e.authority_ceiling > e.tier { + return EnvelopeCheck::Invalid(format!( + "authorityCeiling {} exceeds tier {} (a task cannot grant a child more authority than it holds)", + e.authority_ceiling, e.tier + )); + } + if !(0..=16).contains(&e.delegation_depth) { + return EnvelopeCheck::Invalid(format!( + "delegationDepth {} must be in 0..16", + e.delegation_depth + )); + } + if let Err(why) = crate::kars_task::validate_execution_contract(&task.spec) { + return EnvelopeCheck::Invalid(why); + } + EnvelopeCheck::Valid +} + +struct Ctx { + client: Client, + /// Receipt-signing identity, loaded once at startup. Used to emit a signed + /// Governance Receipt for each governance-`Ready` task. + signer: crate::providers::signing::ReceiptSigner, +} + +async fn reconcile(task: Arc, ctx: Arc) -> Result { + let name = task.name_any(); + let ns = task.namespace().unwrap_or_else(|| "default".into()); + let tasks: Api = Api::namespaced(ctx.client.clone(), &ns); + + // Keep the finalizer until all owned execution resources are gone. + if task.metadata.deletion_timestamp.is_some() { + if has_finalizer(&task) { + if !crate::kars_task_execution::teardown(&ctx.client, &ns, &task).await? { + return Ok(Action::requeue(REQUEUE_PENDING)); + } + // Drop our finalizer with a merge patch. A server-side *apply* that + // sets `finalizers: []` does not reliably remove a finalizer the + // apiserver no longer attributes to this manager (it 400s with + // "name must be provided"), which would strand the object in + // Terminating forever and leak its sandbox. A merge patch replaces + // the array deterministically. + let patch = json!({ "metadata": { + "uid": task.uid(), "resourceVersion": task.resource_version(), + "finalizers": drop_finalizer(&task), + } }); + tasks + .patch(&name, &PatchParams::default(), &Patch::Merge(patch)) + .await?; + } + return Ok(Action::await_change()); + } + + // Ensure the finalizer before doing any work, so deletion is observable. + if !has_finalizer(&task) { + let mut finalizers = task.metadata.finalizers.clone().unwrap_or_default(); + finalizers.push(FINALIZER.to_string()); + let patch = json!({ + "apiVersion": "kars.azure.com/v1alpha1", + "kind": "KarsTask", + "metadata": { "name": name, "finalizers": finalizers }, + }); + tasks + .patch( + &name, + &PatchParams::apply(FIELD_MANAGER).force(), + &Patch::Apply(patch), + ) + .await?; + return Ok(Action::requeue(Duration::from_secs(1))); + } + + let generation = task.metadata.generation; + let prior_conditions = task + .status + .as_ref() + .and_then(|s| s.conditions.clone()) + .unwrap_or_default(); + let prior_ready = conditions::find(&prior_conditions, TYPE_READY); + + // Resolve delegation: a task with `spec.parentRef` is a child whose + // envelope must attenuate its parent's, and whose lineage the controller + // mints from the parent's ancestry. A root task has no parent and empty + // lineage. The controller is the *sole* writer of lineage. + let delegation = resolve_delegation(&tasks, &task).await?; + + let mut new_status = match check_envelope(&task) { + EnvelopeCheck::Invalid(why) => degraded_status( + prior_ready, + generation, + &format!("invalid trust envelope: {why}"), + delegation.lineage(), + ), + EnvelopeCheck::Valid => match delegation { + Delegation::Root => { + ready_status(prior_ready, generation, task.envelope_digest(), Vec::new()) + } + Delegation::ParentMissing { parent } => { + tracing::warn!(karstask = %name, ns = %ns, %parent, "KarsTask parent not found"); + degraded_status( + prior_ready, + generation, + &format!("parentRef `{parent}` not found in namespace"), + Vec::new(), + ) + } + Delegation::ParentNotReady { parent } => { + tracing::info!(karstask = %name, ns = %ns, %parent, "KarsTask parent not yet ready — waiting"); + pending_status( + prior_ready, + generation, + &format!("waiting for parent `{parent}` to become ready"), + ) + } + Delegation::Child { + lineage, + violations, + } if violations.is_empty() => { + tracing::info!(karstask = %name, ns = %ns, depth = lineage.len(), "KarsTask delegated child ready"); + ready_status(prior_ready, generation, task.envelope_digest(), lineage) + } + Delegation::Child { + lineage, + violations, + } => { + let why = violations + .iter() + .map(|v| v.to_string()) + .collect::>() + .join("; "); + tracing::warn!(karstask = %name, ns = %ns, %why, "KarsTask delegation amplifies authority — rejected"); + degraded_status( + prior_ready, + generation, + &format!("delegation amplifies parent authority: {why}"), + lineage, + ) + } + }, + }; + + if new_status.phase.as_deref() == Some(PHASE_READY) { + tracing::debug!( + karstask = %name, + envelope_lattice_digest = %task.spec.envelope.digest(), + authorization_digest = ?new_status.envelope_digest, + "Task authority validated; blueprint drift is distinct from envelope-lattice drift" + ); + } + + // Execution bridge (§20 launch gate). Only a governance-Ready task may + // execute. Launch materializes a governed sandbox; un-launch tears it down. + // Any execution error is surfaced (Degraded) but never fails the whole + // reconcile — the governance status is already durable. + reconcile_execution(&ctx.client, &ns, &task, &mut new_status).await; + + let status_patch = json!({ + "apiVersion": "kars.azure.com/v1alpha1", + "kind": "KarsTask", + "metadata": { + "name": name, "namespace": ns, + "uid": task.uid(), "resourceVersion": task.resource_version(), + }, + "status": new_status, + }); + tasks + .patch_status( + &name, + &PatchParams::apply(FIELD_MANAGER).force(), + &Patch::Apply(status_patch), + ) + .await?; + + // Governance Receipt (Inc 3). A governance-`Ready` task — one whose + // envelope validated and (if delegated) attenuated its parent — gets a + // signed, independently-verifiable receipt. A `Degraded` task never does: + // there is no validated authority to attest. The receipt is deterministic, + // so this is idempotent across requeues. + reconcile_receipt(&ctx.client, &ns, &task, &new_status, &ctx.signer).await; + + // A child still waiting on its parent requeues quickly to converge. + let requeue = if new_status.phase.as_deref() == Some(PHASE_PENDING) + || matches!( + new_status.execution_phase.as_deref(), + Some("Stopping" | PHASE_DEGRADED) + ) { + REQUEUE_PENDING + } else { + REQUEUE_OK + }; + Ok(Action::requeue(requeue)) +} + +/// Outcome of resolving a task's `parentRef`. +enum Delegation { + /// No `parentRef` — this is a root task. + Root, + /// `parentRef` set but the parent does not exist. + ParentMissing { parent: String }, + /// `parentRef` resolved but the parent is not yet governance-`Ready` (no + /// validated envelope digest). A child must not be granted authority + /// against a parent whose own authority isn't established — it waits. + ParentNotReady { parent: String }, + /// `parentRef` resolved; carries the minted lineage and any attenuation + /// violations (empty = valid subset). + Child { + lineage: Vec, + violations: Vec, + }, +} + +impl Delegation { + /// The lineage to persist for this outcome (empty unless a child resolved). + fn lineage(&self) -> Vec { + match self { + Delegation::Child { lineage, .. } => lineage.clone(), + _ => Vec::new(), + } + } +} + +/// Resolve `spec.parentRef`: fetch the parent, mint lineage from its ancestry, +/// and compute whether this task's envelope attenuates the parent's. +async fn resolve_delegation( + tasks: &Api, + task: &KarsTask, +) -> Result { + let Some(parent_ref) = task.spec.parent_ref.as_ref() else { + return Ok(Delegation::Root); + }; + let parent = match tasks.get_opt(&parent_ref.name).await? { + Some(p) => p, + None => { + return Ok(Delegation::ParentMissing { + parent: parent_ref.name.clone(), + }); + } + }; + + // Parent-readiness gate: a child may only be granted authority once the + // parent's own authority is established (governance-`Ready` with a stamped + // envelope digest). Otherwise the subset relation would be checked against + // an unvalidated — possibly degraded or in-flux — parent envelope. + if !task_is_ready(&parent) { + return Ok(Delegation::ParentNotReady { + parent: parent_ref.name.clone(), + }); + } + + // Minted lineage = parent's ancestry + the parent itself. The controller + // owns this; a client-supplied lineage is ignored. + let mut lineage = parent + .status + .as_ref() + .map(|s| s.lineage.clone()) + .unwrap_or_default(); + lineage.push(parent.name_any()); + + // Full attenuation over the effective authority the sandbox enforces + // (envelope numeric/ref axes + effective tool policy + effective egress). + let violations = crate::kars_task::spec_attenuation_violations(&task.spec, &parent.spec); + Ok(Delegation::Child { + lineage, + violations, + }) +} + +/// A task is governance-`Ready` when its `Ready` condition is `True` and it +/// carries a stamped envelope digest — the proof its authority was validated. +pub(crate) fn task_is_ready(task: &KarsTask) -> bool { + let Some(status) = task.status.as_ref() else { + return false; + }; + let digest_ok = status.envelope_digest.as_deref() == Some(task.envelope_digest().as_str()); + let ready_ok = status + .conditions + .iter() + .flatten() + .any(|c| c.type_ == TYPE_READY && c.status == cond_status::TRUE); + digest_ok + && ready_ok + && status.phase.as_deref() == Some(PHASE_READY) + && status.observed_generation == task.metadata.generation + && task.metadata.deletion_timestamp.is_none() + && matches!(check_envelope(task), EnvelopeCheck::Valid) +} + +/// Build a `Ready` status with the given digest + lineage. +fn ready_status( + prior_ready: Option<&k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition>, + generation: Option, + digest: String, + lineage: Vec, +) -> KarsTaskStatus { + let ready = conditions::preserve_transition_time( + prior_ready, + TYPE_READY, + cond_status::TRUE, + cond_reason::RECONCILED, + "trust envelope validated and digested", + generation, + ); + KarsTaskStatus { + phase: Some(PHASE_READY.to_string()), + observed_generation: generation, + conditions: Some(vec![ready]), + envelope_digest: Some(digest), + lineage, + ..Default::default() + } +} + +/// Build a `Degraded` status with no digest — the receipt must never bind to +/// authority that didn't validate or that amplified its parent. +/// Build a `Pending` status for a child whose parent is not yet ready — a +/// transient, non-degraded waiting state (no digest, no execution) that +/// converges once the parent reconciles to `Ready`. +fn pending_status( + prior_ready: Option<&k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition>, + generation: Option, + message: &str, +) -> KarsTaskStatus { + let ready = conditions::preserve_transition_time( + prior_ready, + TYPE_READY, + cond_status::FALSE, + cond_reason::DEPENDENCY_MISSING, + message, + generation, + ); + KarsTaskStatus { + phase: Some(PHASE_PENDING.to_string()), + observed_generation: generation, + conditions: Some(vec![ready]), + envelope_digest: None, + lineage: Vec::new(), + ..Default::default() + } +} + +fn degraded_status( + prior_ready: Option<&k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition>, + generation: Option, + message: &str, + lineage: Vec, +) -> KarsTaskStatus { + let ready = conditions::preserve_transition_time( + prior_ready, + TYPE_READY, + cond_status::FALSE, + cond_reason::SPEC_INVALID, + message, + generation, + ); + KarsTaskStatus { + phase: Some(PHASE_DEGRADED.to_string()), + observed_generation: generation, + conditions: Some(vec![ready]), + envelope_digest: None, + lineage, + ..Default::default() + } +} + +/// Reconcile the execution bridge (§20 launch gate) and fold the result into +/// `status`. Rules: +/// - Only a governance-`Ready` task may execute. +/// - `execution.launch == true` → materialize a governed `KarsSandbox` and +/// reflect its phase as `executionPhase` (Launching/Running/Degraded). +/// - Otherwise → ensure any prior sandbox is torn down; `executionPhase=Idle`. +/// +/// Execution errors degrade *execution* only; the governance status stands. +async fn reconcile_execution( + client: &kube::Client, + ns: &str, + task: &KarsTask, + status: &mut KarsTaskStatus, +) { + let launched = task + .spec + .execution + .as_ref() + .map(|e| e.launch) + .unwrap_or(false); + let governance_ready = status.phase.as_deref() == Some(PHASE_READY); + + if launched && governance_ready { + match crate::kars_task_execution::materialize(client, ns, task).await { + Ok(outcome) => { + status.execution_phase = Some(outcome.phase); + status.sandbox_ref = Some(crate::mcp_server::LocalObjectRef { + name: outcome.sandbox_name, + }); + status.execution_detail = Some(outcome.detail); + } + Err(e) => { + tracing::warn!(karstask = %task.name_any(), ns = %ns, error = %e, "KarsTask execution materialize failed"); + status.execution_phase = Some(PHASE_DEGRADED.to_string()); + status.execution_detail = Some(format!("failed to materialize sandbox: {e}")); + status.sandbox_ref = task.status.as_ref().and_then(|s| s.sandbox_ref.clone()); + } + } + } else { + // Not launched (or not Ready): ensure no sandbox lingers from a prior + // launch, and report Idle. + match crate::kars_task_execution::teardown(client, ns, task).await { + Ok(true) => { + status.execution_phase = Some("Idle".to_string()); + status.sandbox_ref = None; + status.execution_detail = None; + } + result => { + status.execution_phase = Some("Stopping".to_string()); + status.sandbox_ref = task.status.as_ref().and_then(|s| s.sandbox_ref.clone()); + status.execution_detail = Some(match result { + Err(error) => format!("execution cleanup failed; retrying: {error}"), + _ => "waiting for owned execution resources to terminate".into(), + }); + } + } + } +} + +/// Emit (or retract) the Governance Receipt for a task. +/// +/// - Governance-`Ready` (an `envelopeDigest` is present) → build the in-toto +/// Statement, sign it with DSSE/Ed25519, and Server-Side-Apply a +/// `KarsReceipt` owned by the task. Deterministic ⇒ idempotent. +/// - Otherwise → ensure no stale receipt remains; a `Degraded` task has no +/// validated authority to attest. +/// +/// Receipt errors are surfaced in logs but never fail the reconcile — the +/// governance status is already durable. +async fn reconcile_receipt( + client: &kube::Client, + ns: &str, + task: &KarsTask, + status: &KarsTaskStatus, + signer: &crate::providers::signing::ReceiptSigner, +) { + use crate::kars_approval::KarsApproval; + use crate::kars_receipt::{ + KarsReceipt, approval_facts, build_spec, build_statement, canonical_json, + historical_approval_facts, + }; + + let name = task.name_any(); + let receipts: Api = Api::namespaced(client.clone(), ns); + + // Gather the human decisions (HITL approvals) bound to this task, so every + // steer is recorded in the signed receipt. Best-effort: a list failure + // must not block the receipt (it just omits approvals this pass). + let mut current_task = task.clone(); + current_task.status = Some(status.clone()); + let approvals: Api = Api::namespaced(client.clone(), ns); + let task_approvals = match approvals.list(&ListParams::default()).await { + Ok(list) => list.items, + Err(e) => { + tracing::debug!(karstask = %name, ns = %ns, error = %e, "could not list KarsApprovals for receipt"); + Vec::new() + } + }; + // Capture valid decisions before current-authority filtering: a promotion + // may have moved D0 to D1 before any receipt observed its D0 approval. + let history = historical_approval_facts(¤t_task, &task_approvals); + let current_approvals = task_approvals + .into_iter() + .filter(|a| { + crate::kars_approval::approval_binding_matches_task(a, ¤t_task) + && (a.status.as_ref().and_then(|s| s.phase.as_deref()) != Some("Approved") + || crate::kars_approval::approval_authorizes_task(a, ¤t_task)) + }) + .collect::>(); + let facts = approval_facts(¤t_approvals); + + // Gather the completeness-floor posture from cluster state (best-effort — + // a read failure yields a conservative "not enforced" observation, never a + // false positive). This is what makes the receipt's completeness claim + // concrete and re-derivable by an auditor. + let completeness = gather_completeness(); + + let Some(mut statement) = build_statement(task, status, &signer.key_id, &facts, completeness) + else { + // No digest → no receipt. Retract any prior one. + match receipts + .delete(&name, &kube::api::DeleteParams::default()) + .await + { + Ok(_) => {} + Err(kube::Error::Api(ae)) if ae.code == 404 => {} + Err(e) => { + tracing::warn!(karstask = %name, ns = %ns, error = %e, "failed to retract stale KarsReceipt"); + } + } + return; + }; + statement.predicate.approval_history = history; + + let digest = status + .envelope_digest + .clone() + .unwrap_or_else(|| "unknown".to_string()); + let payload = canonical_json(&statement); + let dsse = signer.sign_statement(&payload); + let claims = statement.predicate.claims.clone(); + let spec = build_spec(&name, &digest, &signer.key_id, dsse, claims); + + // Owner reference to the task so the receipt is GC'd with it. + let owner = json!({ + "apiVersion": "kars.azure.com/v1alpha1", + "kind": "KarsTask", + "name": name, + "uid": task.metadata.uid.clone().unwrap_or_default(), + "controller": true, + "blockOwnerDeletion": true, + }); + let receipt = json!({ + "apiVersion": "kars.azure.com/v1alpha1", + "kind": "KarsReceipt", + "metadata": { + "name": name, + "namespace": ns, + "ownerReferences": [owner], + }, + "spec": spec, + }); + + if let Err(e) = receipts + .patch( + &name, + &PatchParams::apply(RECEIPT_FIELD_MANAGER).force(), + &Patch::Apply(&receipt), + ) + .await + { + tracing::warn!(karstask = %name, ns = %ns, error = %e, "failed to emit KarsReceipt"); + return; + } + + // Enter the receipt in the hash-chained inclusion log (cross-receipt + // tamper-evidence). Best-effort: a log failure must not block the receipt, + // which is already durable and individually signed. + let payload_sha = crate::kars_receipt_log::sha256_hex(&payload); + let log_ref = format!("{ns}/{name}"); + let inclusion = match crate::kars_receipt_log::append(client, &log_ref, &payload_sha).await { + Ok(entry) => { + // Publish a fresh signed checkpoint (signed tree head) over the log + // so clients / an external witness can pin the log's size + head + // without the full chain. Best-effort; never blocks the receipt. + match crate::kars_receipt_log::read_chain(client).await { + Ok(chain) => { + if let Err(e) = + crate::kars_receipt_log::publish_checkpoint(client, signer, &chain).await + { + tracing::warn!(karstask = %name, ns = %ns, error = %e, "failed to publish receipt checkpoint"); + } + } + Err(e) => { + tracing::debug!(karstask = %name, ns = %ns, error = %e, "could not read chain for checkpoint"); + } + } + Some(entry) + } + Err(e) => { + tracing::warn!(karstask = %name, ns = %ns, error = %e, "failed to enter receipt in inclusion log"); + None + } + }; + + // Informational status echo (unsigned). Stamp issuance time on first write; + // observedTaskGeneration tracks freshness; inclusion fields bind to the log. + let mut status_obj = json!({ + "issuedAt": chrono::Utc::now().to_rfc3339(), + "observedTaskGeneration": task.metadata.generation, + }); + if let Some(entry) = &inclusion { + status_obj["inclusionSeq"] = json!(entry.seq as i64); + status_obj["inclusionEntryHash"] = json!(entry.entry_hash); + } + let status_patch = json!({ + "apiVersion": "kars.azure.com/v1alpha1", + "kind": "KarsReceipt", + "status": status_obj, + }); + if let Err(e) = receipts + .patch_status( + &name, + &PatchParams::apply(RECEIPT_FIELD_MANAGER).force(), + &Patch::Apply(&status_patch), + ) + .await + { + tracing::debug!(karstask = %name, ns = %ns, error = %e, "KarsReceipt status echo failed (non-fatal)"); + } + + tracing::info!(karstask = %name, ns = %ns, key_id = %signer.key_id, "Governance Receipt emitted"); +} + +/// Policy names alone prove neither binding nor applicability to the actual +/// sandbox namespace, pod selectors or tier exemptions. This foundation does +/// not verify those effective controls: false means NOT VERIFIED, not absent. +fn gather_completeness() -> crate::kars_receipt::PredicateCompleteness { + crate::kars_receipt::PredicateCompleteness::default().with_rollup() +} + +/// True iff the task carries our cleanup finalizer. +fn has_finalizer(task: &KarsTask) -> bool { + task.metadata + .finalizers + .as_ref() + .is_some_and(|f| f.iter().any(|s| s == FINALIZER)) +} + +/// Return the finalizer list with our finalizer removed. +fn drop_finalizer(task: &KarsTask) -> Vec { + task.metadata + .finalizers + .clone() + .unwrap_or_default() + .into_iter() + .filter(|s| s != FINALIZER) + .collect() +} + +fn error_policy(task: Arc, error: &ReconcileError, _ctx: Arc) -> Action { + crate::metrics::record_reconcile_error("KarsTask", error.class()); + tracing::warn!( + karstask = %task.name_any(), + error_class = error.class(), + error = %error, + "KarsTask reconcile error — requeuing in ~30s (±20% jitter)" + ); + Action::requeue(crate::backoff::requeue_secs_with_jitter(30)) +} + +pub async fn run(client: Client) -> Result<()> { + let tasks: Api = Api::all(client.clone()); + loop { + match tasks.list(&ListParams::default().limit(1)).await { + Ok(_) => { + tracing::info!("KarsTask CRD found — starting controller"); + break; + } + Err(e) => { + tracing::warn!(error = %e, "KarsTask API unavailable; retrying discovery in 30s"); + tokio::time::sleep(Duration::from_secs(30)).await; + } + } + } + let signer = loop { + match crate::providers::signing::load_or_create(&client).await { + Ok(s) => { + tracing::info!(key_id = %s.key_id, "Governance Receipt signer ready"); + break s; + } + Err(e) => { + crate::metrics::record_reconcile_error("KarsTask", "signer_init"); + tracing::error!(error = %e, "KarsTask signer unavailable; retrying initialization in 30s"); + tokio::time::sleep(Duration::from_secs(30)).await; + } + } + }; + let ctx = Arc::new(Ctx { client, signer }); + Controller::new(tasks, crate::watch_config::bounded()) + .run( + |x, ctx| async move { + crate::metrics::observe_reconcile("KarsTask", reconcile(x, ctx)).await + }, + error_policy, + ctx, + ) + .for_each(|res| async move { + match res { + Ok(o) => tracing::debug!("KarsTask reconciled {:?}", o), + Err(e) => tracing::warn!("KarsTask reconcile failed: {e:?}"), + } + }) + .await; + Ok(()) +} + +#[cfg(test)] +#[path = "kars_task_reconciler_tests.rs"] +mod tests; diff --git a/controller/src/kars_task_reconciler_tests.rs b/controller/src/kars_task_reconciler_tests.rs new file mode 100644 index 000000000..1e18e3773 --- /dev/null +++ b/controller/src/kars_task_reconciler_tests.rs @@ -0,0 +1,271 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use super::*; +use crate::kars_task::{KarsTaskSpec, TaskEnvelope}; + +fn task_with(tier: i32, authority_ceiling: i32, delegation_depth: i32) -> KarsTask { + let mut task = KarsTask::new( + "t", + KarsTaskSpec { + objective: "do the thing".into(), + envelope: TaskEnvelope { + tier, + authority_ceiling, + delegation_depth, + ..TaskEnvelope::default() + }, + parent_ref: None, + execution: None, + blueprint: None, + display_name: None, + }, + ); + task.metadata.namespace = Some("default".into()); + task +} + +#[test] +fn valid_envelope_passes() { + let task = task_with(3, 3, 2); + assert!(matches!(check_envelope(&task), EnvelopeCheck::Valid)); +} + +#[test] +fn root_policy_conflict_never_becomes_ready() { + let mut task = task_with(3, 3, 2); + task.spec.envelope.tool_policy_ref = Some(crate::mcp_server::LocalObjectRef { + name: "read".into(), + }); + task.spec.blueprint = Some(crate::kars_task::TaskBlueprint { + tool_policy: Some("write".into()), + ..Default::default() + }); + assert!(matches!(check_envelope(&task), EnvelopeCheck::Invalid(_))); +} + +#[test] +fn readiness_requires_current_generation_digest_and_valid_contract() { + let mut task = task_with(3, 3, 2); + task.metadata.generation = Some(1); + task.status = Some(ready_status(None, Some(1), task.envelope_digest(), vec![])); + assert!(task_is_ready(&task)); + task.metadata.generation = Some(2); + assert!(!task_is_ready(&task)); + task.metadata.generation = Some(1); + task.spec.envelope.tier = 4; + assert!(!task_is_ready(&task)); +} + +#[test] +fn completeness_floor_is_not_inferred_from_resource_names() { + let completeness = gather_completeness(); + assert!(!completeness.floor_enforced); + assert!(!completeness.task_namespace_floor_vap); + assert!(!completeness.exec_ban_vap); + assert!(!completeness.posture_lock_vap); + assert!(!completeness.default_deny_egress); +} + +#[tokio::test] +async fn cleanup_errors_preserve_stopping_reference_until_a_successful_retry() { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + let server = MockServer::start().await; + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + let client = Client::try_from(kube::Config::new(server.uri().parse().unwrap())).unwrap(); + let mut task = task_with(3, 3, 1); + task.metadata.uid = Some("task-uid".into()); + task.status = Some(KarsTaskStatus { + sandbox_ref: Some(crate::mcp_server::LocalObjectRef { name: "t".into() }), + execution_phase: Some("Running".into()), + ..Default::default() + }); + let sandbox_path = "/apis/kars.azure.com/v1alpha1/namespaces/default/karssandboxes/t"; + Mock::given(method("GET")) + .and(path(sandbox_path)) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "apiVersion": "kars.azure.com/v1alpha1", "kind": "KarsSandbox", + "metadata": { + "name": "t", "uid": "sandbox-uid", "resourceVersion": "42", + "ownerReferences": [{ + "apiVersion": "kars.azure.com/v1alpha1", "kind": "KarsTask", + "name": "t", "uid": "task-uid", "controller": true, + }], + }, "spec": {}, + }))) + .mount(&server) + .await; + Mock::given(method("DELETE")) + .and(path(sandbox_path)) + .respond_with(ResponseTemplate::new(403).set_body_json(json!({ + "status": "Failure", "reason": "Forbidden", "message": "denied", "code": 403, + }))) + .mount(&server) + .await; + let mut status = KarsTaskStatus::default(); + reconcile_execution(&client, "default", &task, &mut status).await; + assert_eq!(status.execution_phase.as_deref(), Some("Stopping")); + assert_eq!(status.sandbox_ref.as_ref().unwrap().name, "t"); + assert!( + status + .execution_detail + .as_ref() + .unwrap() + .contains("retrying") + ); + server.reset().await; + Mock::given(method("GET")) + .respond_with(ResponseTemplate::new(404).set_body_json(json!({ + "status": "Failure", "reason": "NotFound", "message": "gone", "code": 404, + }))) + .mount(&server) + .await; + reconcile_execution(&client, "default", &task, &mut status).await; + assert_eq!(status.execution_phase.as_deref(), Some("Idle")); + assert!(status.sandbox_ref.is_none()); +} + +#[test] +fn authority_ceiling_above_tier_is_rejected() { + let task = task_with(2, 4, 1); + match check_envelope(&task) { + EnvelopeCheck::Invalid(why) => assert!(why.contains("authorityCeiling")), + EnvelopeCheck::Valid => panic!("expected rejection"), + } +} + +#[test] +fn tier_out_of_range_is_rejected() { + let task = task_with(9, 5, 0); + assert!(matches!(check_envelope(&task), EnvelopeCheck::Invalid(_))); +} + +#[test] +fn finalizer_roundtrip() { + let mut task = task_with(1, 1, 0); + assert!(!has_finalizer(&task)); + task.metadata.finalizers = Some(vec![FINALIZER.to_string(), "other/keep".to_string()]); + assert!(has_finalizer(&task)); + let dropped = drop_finalizer(&task); + assert_eq!(dropped, vec!["other/keep".to_string()]); +} + +#[tokio::test] +async fn receipt_collector_signs_d0_history_after_promotion_to_d1() { + use crate::kars_approval::{KarsApproval, KarsApprovalStatus, request_snapshot}; + use base64::Engine as _; + use wiremock::matchers::{method, path, path_regex}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + let server = MockServer::start().await; + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + let client = Client::try_from(kube::Config::new(server.uri().parse().unwrap())).unwrap(); + let mut task = task_with(1, 1, 0); + task.metadata.uid = Some("task-uid".into()); + task.metadata.generation = Some(1); + task.spec.blueprint = Some(crate::kars_task::TaskBlueprint { + model: Some(crate::kars_task::TaskModel { + deployment: "reviewed-model".into(), + provider: "azure-openai".into(), + }), + ..Default::default() + }); + let d0 = task.envelope_digest(); + let mut approval = KarsApproval::new( + "promotion", + serde_json::from_value(json!({ + "taskRef": { "name": "t" }, + "action": { "kind": "tierRaise", "summary": "Permit tier 2", "requestedTier": 2 }, + "ttl": "PT1H", + "decision": { "verdict": "approve", "decider": "alice" }, + })) + .unwrap(), + ); + approval.metadata.uid = Some("approval-uid".into()); + approval.metadata.namespace = Some("default".into()); + approval.metadata.generation = Some(2); + approval.status = Some(KarsApprovalStatus { + phase: Some("Approved".into()), + observed_generation: Some(2), + bound_envelope_digest: Some(d0.clone()), + bound_task_uid: task.metadata.uid.clone(), + bound_request: Some(request_snapshot(&approval.spec)), + requested_at: Some("2026-09-07T08:00:00Z".into()), + decided_at: Some("2026-09-07T08:15:00Z".into()), + expires_at: Some("2026-09-07T09:00:00Z".into()), + decider: Some("alice".into()), + ..Default::default() + }); + task.spec.envelope.tier = 2; + task.metadata.generation = Some(2); + let status = ready_status(None, Some(2), task.envelope_digest(), Vec::new()); + task.status = Some(status.clone()); + assert!(!crate::kars_approval::approval_authorizes_task( + &approval, &task + )); + Mock::given(method("GET")) + .and(path( + "/apis/kars.azure.com/v1alpha1/namespaces/default/karsapprovals", + )) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "apiVersion": "kars.azure.com/v1alpha1", "kind": "KarsApprovalList", + "metadata": { "resourceVersion": "1" }, "items": [approval], + }))) + .mount(&server) + .await; + let receipt_path = "/apis/kars.azure.com/v1alpha1/namespaces/default/karsreceipts/t"; + Mock::given(method("PATCH")) + .and(path(receipt_path)) + .respond_with(|request: &wiremock::Request| { + ResponseTemplate::new(200) + .set_body_json(request.body_json::().unwrap()) + }) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path_regex( + "/api/v1/namespaces/[^/]+/configmaps/kars-receipt-log", + )) + .respond_with(ResponseTemplate::new(403).set_body_json(json!({ + "status": "Failure", "code": 403, "reason": "Forbidden", "message": "log unavailable", + }))) + .mount(&server) + .await; + Mock::given(method("PATCH")).and(path(format!("{receipt_path}/status"))) + .respond_with(ResponseTemplate::new(503).set_body_json(json!({ + "status": "Failure", "code": 503, "reason": "ServiceUnavailable", "message": "echo unavailable", + }))).mount(&server).await; + let signer = crate::providers::signing::ReceiptSigner::from_bytes(&[7; 32]); + reconcile_receipt(&client, "default", &task, &status, &signer).await; + let requests = server.received_requests().await.unwrap(); + let receipt: serde_json::Value = requests + .iter() + .find(|request| request.method == "PATCH" && request.url.path() == receipt_path) + .unwrap() + .body_json() + .unwrap(); + let bytes = base64::engine::general_purpose::STANDARD + .decode(receipt["spec"]["dsse"]["payload"].as_str().unwrap()) + .unwrap(); + let payload: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + let history = &payload["predicate"]["approvalHistory"][0]; + assert_eq!( + receipt["spec"]["dsse"]["signatures"] + .as_array() + .unwrap() + .len(), + 1 + ); + assert_eq!(history["boundEnvelopeDigest"], d0); + assert_eq!(history["taskUid"], "task-uid"); + assert_eq!(history["decision"]["verdict"], "approve"); + assert_eq!(history["evidenceScope"], "historicalDecision"); + assert_eq!(history["authorizesCurrentTask"], false); + assert_eq!(history["consumptionAttested"], false); + assert_eq!( + payload["subject"][0]["digest"]["sha256"], + task.envelope_digest().trim_start_matches("sha256:") + ); + assert!(payload["predicate"].get("approvals").is_none()); +} diff --git a/controller/src/kars_task_tests.rs b/controller/src/kars_task_tests.rs new file mode 100644 index 000000000..833e361de --- /dev/null +++ b/controller/src/kars_task_tests.rs @@ -0,0 +1,504 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use super::*; + +fn sample_envelope() -> TaskEnvelope { + TaskEnvelope { + tier: 3, + budget: Some(TaskBudget { + tokens: Some(100_000), + usd_micros: Some(5_000_000), + }), + tool_policy_ref: Some(LocalObjectRef { + name: "default-tools".into(), + }), + egress_allowlist_ref: None, + delegation_depth: 2, + authority_ceiling: 3, + } +} + +#[test] +fn envelope_digest_is_deterministic() { + let envelope = sample_envelope(); + assert_eq!(envelope.digest(), envelope.digest()); +} + +#[test] +fn zero_and_absent_budgets_are_unbounded_on_both_axes() { + for axis in [BudgetAxis::Tokens, BudgetAxis::UsdMicros] { + for child in [None, Some(0)] { + let mut violations = Vec::new(); + attenuate_budget_axis(child, Some(100), axis, &mut violations); + assert_eq!( + violations, + vec![EnvelopeViolation::BudgetUnbounded { axis, parent: 100 }] + ); + } + for parent in [None, Some(0)] { + let mut violations = Vec::new(); + attenuate_budget_axis(Some(100), parent, axis, &mut violations); + assert!(violations.is_empty()); + } + } +} + +#[test] +fn bounded_launch_fails_closed_but_planning_remains_available() { + for budget in [ + TaskBudget { + tokens: Some(100), + usd_micros: None, + }, + TaskBudget { + tokens: None, + usd_micros: Some(100), + }, + ] { + let mut spec = KarsTaskSpec::default(); + spec.envelope.budget = Some(budget); + assert!(validate_execution_contract(&spec).is_ok()); + spec.execution = Some(TaskExecution { + launch: true, + runtime: None, + }); + assert!( + validate_execution_contract(&spec) + .unwrap_err() + .contains("UnsupportedLaunchBudget") + ); + } + let mut spec = KarsTaskSpec { + execution: Some(TaskExecution { + launch: true, + runtime: None, + }), + ..Default::default() + }; + assert!(validate_execution_contract(&spec).is_ok()); + spec.envelope.budget = Some(TaskBudget { + tokens: Some(0), + usd_micros: Some(0), + }); + assert!(validate_execution_contract(&spec).is_ok()); +} + +#[test] +fn root_blueprint_cannot_override_its_own_envelope() { + let mut spec = KarsTaskSpec::default(); + spec.envelope.tool_policy_ref = Some(LocalObjectRef { + name: "read-only".into(), + }); + spec.blueprint = Some(TaskBlueprint { + tool_policy: Some("write-enabled".into()), + ..Default::default() + }); + assert!( + validate_execution_contract(&spec) + .unwrap_err() + .contains("toolPolicy") + ); + spec.blueprint.as_mut().unwrap().tool_policy = Some("read-only".into()); + assert!(validate_execution_contract(&spec).is_ok()); + spec.envelope.egress_allowlist_ref = Some(LocalObjectRef { + name: "unresolved".into(), + }); + assert!( + validate_execution_contract(&spec) + .unwrap_err() + .contains("egressAllowlistRef") + ); +} + +#[test] +fn unsupported_runtime_is_rejected_in_both_task_fields() { + for runtime in ["BYO", "unknown", ""] { + let mut spec = KarsTaskSpec { + execution: Some(TaskExecution { + launch: true, + runtime: Some(runtime.into()), + }), + ..Default::default() + }; + assert!(validate_execution_contract(&spec).is_err()); + spec.execution = None; + spec.blueprint = Some(TaskBlueprint { + runtime: Some(runtime.into()), + ..Default::default() + }); + assert!(validate_execution_contract(&spec).is_err()); + } +} + +#[test] +fn envelope_digest_has_sha256_prefix_and_length() { + let digest = sample_envelope().digest(); + assert!(digest.starts_with("sha256:")); + assert_eq!(digest.len(), 39); +} + +#[test] +fn envelope_digest_changes_with_tier() { + let mut envelope = sample_envelope(); + let before = envelope.digest(); + envelope.tier = 4; + assert_ne!(before, envelope.digest()); +} + +#[test] +fn envelope_digest_changes_with_delegation_depth() { + let mut envelope = sample_envelope(); + let before = envelope.digest(); + envelope.delegation_depth += 1; + assert_ne!(before, envelope.digest()); +} + +#[test] +fn spec_roundtrips_through_camelcase_yaml() { + let spec = KarsTaskSpec { + objective: "fix the flaky test in payments".into(), + envelope: sample_envelope(), + parent_ref: None, + execution: None, + blueprint: None, + display_name: Some("payments-bugfix".into()), + }; + let yaml = serde_yaml::to_string(&spec).expect("serializes"); + assert!(yaml.contains("authorityCeiling:")); + assert!(yaml.contains("delegationDepth:")); + let back: KarsTaskSpec = serde_yaml::from_str(&yaml).expect("roundtrips"); + assert_eq!(back.envelope.tier, 3); + assert_eq!(back.envelope.authority_ceiling, 3); +} + +fn parent_envelope() -> TaskEnvelope { + TaskEnvelope { + tier: 5, + budget: Some(TaskBudget { + tokens: Some(1_000_000), + usd_micros: Some(50_000_000), + }), + tool_policy_ref: Some(LocalObjectRef { + name: "strict-tools".into(), + }), + egress_allowlist_ref: None, + delegation_depth: 3, + authority_ceiling: 4, + } +} + +#[test] +fn valid_child_attenuates_on_every_axis() { + let parent = parent_envelope(); + let child = TaskEnvelope { + tier: 4, + budget: Some(TaskBudget { + tokens: Some(100_000), + usd_micros: Some(5_000_000), + }), + tool_policy_ref: Some(LocalObjectRef { + name: "strict-tools".into(), + }), + egress_allowlist_ref: None, + delegation_depth: 2, + authority_ceiling: 3, + }; + assert!(child.attenuation_violations(&parent).is_empty()); +} + +#[test] +fn child_tier_above_parent_ceiling_is_amplification() { + let parent = parent_envelope(); + let child = TaskEnvelope { + tier: 5, + authority_ceiling: 4, + delegation_depth: 0, + ..parent_envelope() + }; + assert!( + child + .attenuation_violations(&parent) + .iter() + .any(|violation| { + matches!( + violation, + EnvelopeViolation::TierExceedsParentCeiling { .. } + ) + }) + ); +} + +#[test] +fn child_ceiling_above_parent_ceiling_is_amplification() { + let parent = parent_envelope(); + let mut child = parent_envelope(); + child.tier = 4; + child.authority_ceiling = 5; + child.delegation_depth = 0; + assert!( + child + .attenuation_violations(&parent) + .iter() + .any(|violation| { + matches!( + violation, + EnvelopeViolation::CeilingExceedsParentCeiling { .. } + ) + }) + ); +} + +#[test] +fn delegation_depth_must_decrement() { + let parent = parent_envelope(); + let mut child = parent_envelope(); + child.tier = 4; + child.authority_ceiling = 4; + child.delegation_depth = 3; + assert!( + child + .attenuation_violations(&parent) + .iter() + .any(|violation| { + matches!(violation, EnvelopeViolation::DelegationDepthExceeded { .. }) + }) + ); +} + +#[test] +fn exhausted_delegation_budget_rejects_any_child() { + let mut parent = parent_envelope(); + parent.delegation_depth = 0; + let mut child = parent_envelope(); + child.tier = 1; + child.authority_ceiling = 1; + child.delegation_depth = 0; + assert!( + child + .attenuation_violations(&parent) + .iter() + .any(|violation| { + matches!(violation, EnvelopeViolation::DelegationDepthExceeded { .. }) + }) + ); +} + +#[test] +fn child_budget_over_parent_cap_is_amplification() { + let parent = parent_envelope(); + let mut child = parent_envelope(); + child.tier = 4; + child.authority_ceiling = 3; + child.delegation_depth = 1; + child.budget = Some(TaskBudget { + tokens: Some(2_000_000), + usd_micros: Some(1_000_000), + }); + assert!( + child + .attenuation_violations(&parent) + .iter() + .any(|violation| { + matches!( + violation, + EnvelopeViolation::BudgetExceeded { + axis: BudgetAxis::Tokens, + .. + } + ) + }) + ); +} + +#[test] +fn unbounded_child_under_bounded_parent_is_amplification() { + let parent = parent_envelope(); + let mut child = parent_envelope(); + child.tier = 4; + child.authority_ceiling = 3; + child.delegation_depth = 1; + child.budget = None; + assert!( + child + .attenuation_violations(&parent) + .iter() + .any(|violation| matches!(violation, EnvelopeViolation::BudgetUnbounded { .. })) + ); +} + +#[test] +fn child_must_match_parent_pinned_tool_policy() { + let parent = parent_envelope(); + let mut child = parent_envelope(); + child.tier = 4; + child.authority_ceiling = 3; + child.delegation_depth = 1; + child.tool_policy_ref = Some(LocalObjectRef { + name: "looser-tools".into(), + }); + assert!( + child + .attenuation_violations(&parent) + .iter() + .any(|violation| { + matches!( + violation, + EnvelopeViolation::PolicyMismatch { + axis: PolicyAxis::ToolPolicy, + .. + } + ) + }) + ); +} + +#[test] +fn child_may_add_egress_bound_where_parent_has_none() { + let parent = parent_envelope(); + let mut child = parent_envelope(); + child.tier = 4; + child.authority_ceiling = 3; + child.delegation_depth = 1; + child.egress_allowlist_ref = Some(LocalObjectRef { + name: "tighter-egress".into(), + }); + assert!( + !child + .attenuation_violations(&parent) + .iter() + .any(|violation| { + matches!( + violation, + EnvelopeViolation::PolicyMismatch { + axis: PolicyAxis::EgressAllowlist, + .. + } + ) + }) + ); +} + +#[test] +fn default_envelope_is_least_privilege() { + let envelope = TaskEnvelope::default(); + assert_eq!(envelope.tier, TIER_MIN); + assert_eq!(envelope.delegation_depth, 0); + assert_eq!(envelope.authority_ceiling, TIER_MIN); + assert!(envelope.budget.is_none()); +} + +fn spec_with( + envelope: TaskEnvelope, + tool_policy: Option<&str>, + egress: Vec, +) -> KarsTaskSpec { + KarsTaskSpec { + objective: "x".into(), + envelope, + parent_ref: None, + execution: None, + blueprint: Some(TaskBlueprint { + tool_policy: tool_policy.map(str::to_string), + egress, + ..Default::default() + }), + display_name: None, + } +} + +fn egress(host: &str, port: Option) -> TaskEgress { + TaskEgress { + host: host.into(), + port, + } +} + +fn child_envelope() -> TaskEnvelope { + TaskEnvelope { + tier: 4, + budget: Some(TaskBudget { + tokens: Some(100_000), + usd_micros: Some(5_000_000), + }), + tool_policy_ref: Some(LocalObjectRef { + name: "strict-tools".into(), + }), + egress_allowlist_ref: None, + delegation_depth: 2, + authority_ceiling: 4, + } +} + +#[test] +fn effective_tool_policy_prefers_blueprint_then_envelope() { + let spec = spec_with(parent_envelope(), Some("bp-tools"), vec![]); + assert_eq!(effective_tool_policy(&spec), Some("bp-tools")); + let fallback = spec_with(parent_envelope(), None, vec![]); + assert_eq!(effective_tool_policy(&fallback), Some("strict-tools")); +} + +#[test] +fn child_egress_must_be_subset_of_parent() { + let parent = spec_with( + parent_envelope(), + Some("strict-tools"), + vec![ + egress("api.github.com", Some(443)), + egress("pkg.go.dev", None), + ], + ); + let valid = spec_with( + child_envelope(), + Some("strict-tools"), + vec![ + egress("api.github.com", Some(443)), + egress("pkg.go.dev", Some(443)), + ], + ); + assert!(spec_attenuation_violations(&valid, &parent).is_empty()); + let invalid = spec_with( + child_envelope(), + Some("strict-tools"), + vec![egress("evil.example.com", Some(443))], + ); + let violations = spec_attenuation_violations(&invalid, &parent); + assert!(matches!( + violations.as_slice(), + [EnvelopeViolation::EgressNotSubset { host, .. }] if host == "evil.example.com" + )); +} + +#[test] +fn empty_parent_egress_permits_no_child_egress() { + let parent = spec_with(parent_envelope(), Some("strict-tools"), vec![]); + let child = spec_with( + child_envelope(), + Some("strict-tools"), + vec![egress("api.github.com", Some(443))], + ); + assert!( + spec_attenuation_violations(&child, &parent) + .iter() + .any(|violation| matches!(violation, EnvelopeViolation::EgressNotSubset { .. })) + ); +} + +#[test] +fn child_tool_policy_must_match_parent_effective() { + let parent = spec_with(parent_envelope(), Some("strict-tools"), vec![]); + let child = spec_with(child_envelope(), Some("loose-tools"), vec![]); + assert!( + spec_attenuation_violations(&child, &parent) + .iter() + .any(|violation| { + matches!( + violation, + EnvelopeViolation::PolicyMismatch { + axis: PolicyAxis::ToolPolicy, + .. + } + ) + }) + ); +} diff --git a/controller/src/main.rs b/controller/src/main.rs index ab69b9707..0c7884e6e 100644 --- a/controller/src/main.rs +++ b/controller/src/main.rs @@ -38,13 +38,20 @@ mod helm_drift; mod inference_policy; mod inference_policy_compile; mod inference_policy_reconciler; +mod kars_approval; +mod kars_approval_reconciler; mod kars_eval; mod kars_eval_reconciler; mod kars_memory; mod kars_memory_compile; mod kars_memory_reconciler; +mod kars_receipt; +mod kars_receipt_log; mod kars_sre_action; mod kars_sre_action_reconciler; +mod kars_task; +mod kars_task_execution; +mod kars_task_reconciler; mod leader_election; mod mcp_server; mod mcp_server_reconciler; @@ -244,6 +251,14 @@ async fn main() -> Result<()> { let client = client.clone(); tokio::spawn(async move { kars_eval_reconciler::run(client).await }) }; + let kars_task_handle = { + let client = client.clone(); + tokio::spawn(async move { kars_task_reconciler::run(client).await }) + }; + let kars_approval_handle = { + let client = client.clone(); + tokio::spawn(async move { kars_approval_reconciler::run(client).await }) + }; let trust_graph_handle = { let client = client.clone(); tokio::spawn(async move { trust_graph_reconciler::run(client).await }) @@ -396,6 +411,12 @@ async fn main() -> Result<()> { res = kars_eval_handle => { res??; } + res = kars_task_handle => { + res??; + } + res = kars_approval_handle => { + res??; + } res = trust_graph_handle => { res??; } diff --git a/controller/src/providers/mod.rs b/controller/src/providers/mod.rs index 0436d3a09..e64ac9a9d 100644 --- a/controller/src/providers/mod.rs +++ b/controller/src/providers/mod.rs @@ -26,6 +26,10 @@ // lints are silenced at the module level until call-sites land. #![allow(dead_code)] +/// Governance Receipt signing (kars Bridge Inc 3). Allowlisted crypto +/// wrapper: Ed25519 over DSSE. See the module docs for the V0 trust model. +pub mod signing; + #[allow(unused_imports)] pub mod field_managers { //! Stable Server-Side Apply field managers per plan §6 #4. diff --git a/controller/src/providers/signing.rs b/controller/src/providers/signing.rs new file mode 100644 index 000000000..53d3e16fa --- /dev/null +++ b/controller/src/providers/signing.rs @@ -0,0 +1,349 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Governance Receipt signing provider (kars Bridge V0, Inc 3). +//! +//! This is an **allowlisted crypto wrapper** (see `ci/no-custom-crypto.sh`): +//! it is the single file whose job is to turn an in-toto Statement into a +//! signed [DSSE] envelope using Ed25519. No crypto primitives leak outside +//! this module — callers hand it canonical JSON bytes and receive a +//! [`DsseEnvelope`] they can persist verbatim. +//! +//! ## Trust model (V0, honest) +//! +//! - The controller holds a long-lived Ed25519 keypair, persisted to the +//! `controller-receipt-identity` Secret in `kars-system` (mirrors the mesh +//! peer identity). It is generated on first start. +//! - The **public** key is published, out of band, to the +//! `kars-receipt-pubkey` ConfigMap in `kars-system`. A verifier +//! (`kars receipt verify`) trusts *that* anchor, never a key embedded in a +//! receipt — so swapping the key inside a forged receipt does not help an +//! attacker. +//! - This is **local signing**. There is no external transparency log / KMS +//! anchor yet; that is the V1 upgrade and the receipt says so verbatim +//! (the `regulatory` claim class is `OMITTED`). We never imply more +//! assurance than we deliver. +//! +//! ## Wire format +//! +//! The signed payload is the [DSSE Pre-Authentication Encoding][PAE] over the +//! canonical in-toto Statement JSON with payload type +//! `application/vnd.in-toto+json`. Ed25519 signatures are deterministic, so +//! the same Statement always yields byte-identical output — which is exactly +//! what lets a verifier re-derive the Statement from the live `KarsTask` and +//! confirm it matches before checking the signature. +//! +//! [DSSE]: https://github.com/secure-systems-lab/dsse +//! [PAE]: https://github.com/secure-systems-lab/dsse/blob/master/protocol.md + +use anyhow::{Context, Result}; +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; +use ed25519_dalek::{Signer, SigningKey, VerifyingKey}; +use k8s_openapi::api::core::v1::{ConfigMap, Secret}; +use kube::{ + Client, + api::{Api, Patch, PatchParams, PostParams}, +}; +use rand::RngCore; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +/// Receipt identity, trust anchor and inclusion log share the controller +/// namespace. Keep the historical default for existing installations. +pub fn receipt_namespace() -> String { + resolve_namespace( + std::env::var("KARS_NAMESPACE").ok().as_deref(), + std::env::var("POD_NAMESPACE").ok().as_deref(), + ) +} + +fn resolve_namespace(kars: Option<&str>, pod: Option<&str>) -> String { + kars.map(str::trim) + .filter(|s| !s.is_empty()) + .or_else(|| pod.map(str::trim).filter(|s| !s.is_empty())) + .unwrap_or("kars-system") + .to_string() +} + +/// Secret holding the controller's receipt-signing private key. +const IDENTITY_SECRET_NAME: &str = "controller-receipt-identity"; +/// ConfigMap publishing the verifier trust anchor (public key + key id). +pub const PUBKEY_CONFIGMAP_NAME: &str = "kars-receipt-pubkey"; +/// DSSE payload type for in-toto Statements. +pub const DSSE_PAYLOAD_TYPE: &str = "application/vnd.in-toto+json"; +/// Signing scheme identifier embedded in receipts for forward-compat. +pub const SIGNING_SCHEME: &str = "DSSEv1+ed25519"; +/// Server-Side Apply field manager for receipt-signing writes. +const FIELD_MANAGER: &str = "kars-controller/receipt-signing"; + +/// A DSSE envelope, serialized verbatim into a `KarsReceipt`. +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct DsseEnvelope { + /// Base64 of the in-toto Statement JSON (the signed payload). + pub payload: String, + /// Always [`DSSE_PAYLOAD_TYPE`]. + pub payload_type: String, + /// One Ed25519 signature in V0. + pub signatures: Vec, +} + +/// A single DSSE signature line. +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +pub struct DsseSignature { + /// Hex SHA-256 fingerprint of the signing public key. + pub keyid: String, + /// Base64 of the 64-byte Ed25519 signature over the PAE. + pub sig: String, +} + +/// The controller's receipt-signing identity. +#[derive(Clone)] +pub struct ReceiptSigner { + signing_key: SigningKey, + /// Hex SHA-256 fingerprint of the public key — the receipt `keyid`. + pub key_id: String, +} + +impl ReceiptSigner { + /// Construct from 32 raw secret-key bytes. + pub fn from_bytes(secret_key_bytes: &[u8; 32]) -> Self { + let signing_key = SigningKey::from_bytes(secret_key_bytes); + let key_id = fingerprint(&signing_key.verifying_key()); + Self { + signing_key, + key_id, + } + } + + /// Generate a fresh random identity. + pub fn generate() -> Self { + let mut rng = rand::rng(); + let mut key_bytes = [0u8; 32]; + rng.fill_bytes(&mut key_bytes); + Self::from_bytes(&key_bytes) + } + + /// Base64 of the 32-byte Ed25519 public key (published to the anchor CM). + pub fn public_key_b64(&self) -> String { + BASE64.encode(self.signing_key.verifying_key().to_bytes()) + } + + /// Sign canonical in-toto Statement JSON, producing a DSSE envelope. + pub fn sign_statement(&self, statement_json: &[u8]) -> DsseEnvelope { + let pae = pae(DSSE_PAYLOAD_TYPE, statement_json); + let signature = self.signing_key.sign(&pae); + DsseEnvelope { + payload: BASE64.encode(statement_json), + payload_type: DSSE_PAYLOAD_TYPE.to_string(), + signatures: vec![DsseSignature { + keyid: self.key_id.clone(), + sig: BASE64.encode(signature.to_bytes()), + }], + } + } + + /// Sign raw note bytes with Ed25519, returning the base64 signature. + /// + /// Used for the inclusion-log **signed checkpoint** (a "signed tree head"): + /// a compact, signed commitment to the log's size + head hash that clients + /// and an external witness can pin without the full chain. Deterministic + /// (Ed25519) so re-signing the same note is byte-identical. + pub fn sign_note(&self, note: &[u8]) -> String { + BASE64.encode(self.signing_key.sign(note).to_bytes()) + } +} + +/// Hex SHA-256 fingerprint of an Ed25519 public key. +fn fingerprint(verifying_key: &VerifyingKey) -> String { + let hash = Sha256::digest(verifying_key.to_bytes()); + let mut out = String::with_capacity(64); + for b in hash.iter() { + use std::fmt::Write; + let _ = write!(out, "{b:02x}"); + } + out +} + +/// DSSE Pre-Authentication Encoding: +/// `"DSSEv1" SP len(type) SP type SP len(body) SP body`. +/// +/// This is the standard DSSE framing, not a bespoke construction — it exists +/// so the signature is unambiguously bound to both the payload type and the +/// payload, defeating type-confusion attacks. +pub fn pae(payload_type: &str, body: &[u8]) -> Vec { + let mut out = Vec::with_capacity(payload_type.len() + body.len() + 32); + out.extend_from_slice(b"DSSEv1 "); + out.extend_from_slice(payload_type.len().to_string().as_bytes()); + out.push(b' '); + out.extend_from_slice(payload_type.as_bytes()); + out.push(b' '); + out.extend_from_slice(body.len().to_string().as_bytes()); + out.push(b' '); + out.extend_from_slice(body); + out +} + +/// Load the controller's receipt identity from its Secret, generating and +/// persisting one on first start, then publish the public-key anchor +/// ConfigMap so verifiers can check signatures out of band. +pub async fn load_or_create(client: &Client) -> Result { + let secrets: Api = Api::namespaced(client.clone(), &receipt_namespace()); + + let signer = match secrets.get(IDENTITY_SECRET_NAME).await { + Ok(secret) => { + let key = secret + .data + .as_ref() + .and_then(|d| d.get("signing_key")) + .and_then(|b| <[u8; 32]>::try_from(b.0.as_slice()).ok()); + match key { + Some(bytes) => { + let signer = ReceiptSigner::from_bytes(&bytes); + tracing::info!(key_id = %signer.key_id, "Loaded receipt-signing identity"); + signer + } + None => { + anyhow::bail!( + "Receipt identity Secret malformed; refusing to rotate the trust anchor" + ) + } + } + } + Err(kube::Error::Api(ae)) if ae.code == 404 => { + tracing::info!("No receipt identity Secret — generating new one"); + create_identity(&secrets).await? + } + Err(e) => return Err(e).context("reading receipt identity Secret"), + }; + + publish_pubkey(client, &signer).await?; + Ok(signer) +} + +/// Generate a new identity and persist it to the Secret. +async fn create_identity(secrets: &Api) -> Result { + let signer = ReceiptSigner::generate(); + let secret: Secret = serde_json::from_value(serde_json::json!({ + "apiVersion": "v1", + "kind": "Secret", + "metadata": { + "name": IDENTITY_SECRET_NAME, + "namespace": receipt_namespace(), + }, + "data": { + "signing_key": BASE64.encode(signer.signing_key.to_bytes()), + "key_id": BASE64.encode(signer.key_id.as_bytes()), + } + }))?; + secrets + .create(&PostParams::default(), &secret) + .await + .context("creating receipt identity Secret")?; + tracing::info!(key_id = %signer.key_id, "Generated new receipt-signing identity"); + Ok(signer) +} + +/// Publish the public key + key id to the `kars-receipt-pubkey` ConfigMap. +/// This is the out-of-band trust anchor a verifier reads — never the key +/// inside a receipt. +async fn publish_pubkey(client: &Client, signer: &ReceiptSigner) -> Result<()> { + let cms: Api = Api::namespaced(client.clone(), &receipt_namespace()); + let cm: ConfigMap = serde_json::from_value(serde_json::json!({ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": { + "name": PUBKEY_CONFIGMAP_NAME, + "namespace": receipt_namespace(), + "labels": { + "app.kubernetes.io/name": "kars", + "app.kubernetes.io/component": "receipt-trust-anchor", + }, + }, + "data": { + "keyId": signer.key_id, + "publicKey": signer.public_key_b64(), + "scheme": SIGNING_SCHEME, + "payloadType": DSSE_PAYLOAD_TYPE, + } + }))?; + cms.patch( + PUBKEY_CONFIGMAP_NAME, + &PatchParams::apply(FIELD_MANAGER).force(), + &Patch::Apply(&cm), + ) + .await + .context("publishing receipt pubkey ConfigMap")?; + tracing::info!(key_id = %signer.key_id, "Published receipt trust anchor ConfigMap"); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use ed25519_dalek::Verifier; + + #[test] + fn namespace_precedence_and_default() { + assert_eq!(resolve_namespace(None, None), "kars-system"); + assert_eq!(resolve_namespace(Some(" "), Some("operator")), "operator"); + assert_eq!( + resolve_namespace(Some("custom"), Some("operator")), + "custom" + ); + } + + #[test] + fn pae_matches_dsse_spec() { + // Reference vector shape from the DSSE spec: framing is + // "DSSEv1 " + len + " " + type + " " + len + " " + body. + let got = pae("application/vnd.in-toto+json", b"{}"); + let expected = b"DSSEv1 28 application/vnd.in-toto+json 2 {}"; + assert_eq!(got, expected); + } + + #[test] + fn sign_then_verify_roundtrips() { + let signer = ReceiptSigner::generate(); + let statement = br#"{"_type":"https://in-toto.io/Statement/v1"}"#; + let env = signer.sign_statement(statement); + + // A verifier reconstructs the PAE and checks the signature against the + // published public key — exactly what `kars receipt verify` does. + let pub_bytes: [u8; 32] = BASE64 + .decode(signer.public_key_b64()) + .unwrap() + .try_into() + .unwrap(); + let vk = VerifyingKey::from_bytes(&pub_bytes).unwrap(); + let sig_bytes: [u8; 64] = BASE64 + .decode(&env.signatures[0].sig) + .unwrap() + .try_into() + .unwrap(); + let sig = ed25519_dalek::Signature::from_bytes(&sig_bytes); + let pae = pae(DSSE_PAYLOAD_TYPE, statement); + assert!(vk.verify(&pae, &sig).is_ok()); + assert_eq!(env.signatures[0].keyid, signer.key_id); + } + + #[test] + fn signatures_are_deterministic() { + // Ed25519 is deterministic: re-signing the same Statement yields the + // same bytes, so receipt emission is idempotent and a verifier can + // re-derive the exact artifact. + let signer = ReceiptSigner::generate(); + let statement = br#"{"subject":[{"name":"ns/task"}]}"#; + let a = signer.sign_statement(statement); + let b = signer.sign_statement(statement); + assert_eq!(a.signatures[0].sig, b.signatures[0].sig); + } + + #[test] + fn fingerprint_is_hex_sha256() { + let signer = ReceiptSigner::generate(); + assert_eq!(signer.key_id.len(), 64); + assert!(signer.key_id.chars().all(|c| c.is_ascii_hexdigit())); + } +} diff --git a/controller/src/reconciler/mod.rs b/controller/src/reconciler/mod.rs index 4f364bfc4..aced88312 100644 --- a/controller/src/reconciler/mod.rs +++ b/controller/src/reconciler/mod.rs @@ -1734,6 +1734,21 @@ async fn reconcile(sandbox: Arc, ctx: Arc) -> Result= 1.30 (VAP GA). +*/}} +{{- $admission := .Values.admission | default dict -}} +{{- $floor := $admission.taskNamespaceFloor | default dict -}} +{{- $enabled := true -}} +{{- /* `default true` would overwrite an existing explicit false. */ -}} +{{- if and (hasKey $floor "enabled") (ne $floor.enabled nil) -}} +{{- $enabled = $floor.enabled -}} +{{- end -}} +{{- if not (kindIs "bool" $enabled) -}} +{{- fail "admission.taskNamespaceFloor.enabled must be a boolean" -}} +{{- end -}} +{{- if $enabled -}} +apiVersion: admissionregistration.k8s.io/v1 +kind: ValidatingAdmissionPolicy +metadata: + name: kars-task-namespace-floor + labels: + app.kubernetes.io/name: kars + app.kubernetes.io/component: admission +spec: + failurePolicy: Fail + matchConstraints: + resourceRules: + - apiGroups: [""] + apiVersions: ["v1"] + operations: ["CREATE"] + resources: ["pods"] + namespaceSelector: + matchLabels: + kars.azure.com/isolated: strict + matchExpressions: + - key: kars.azure.com/break-glass + operator: NotIn + values: ["true"] + variables: + - name: allContainers + expression: | + (object.spec.?containers.orValue([])) + + (object.spec.?initContainers.orValue([])) + - name: usesHostNamespace + expression: | + object.spec.?hostNetwork.orValue(false) == true || + object.spec.?hostPID.orValue(false) == true || + object.spec.?hostIPC.orValue(false) == true + - name: hasPrivileged + expression: | + variables.allContainers.exists(c, + c.?securityContext.?privileged.orValue(false) == true) + - name: hasPrivEsc + expression: | + variables.allContainers.exists(c, + c.?securityContext.?allowPrivilegeEscalation.orValue(false) == true) + - name: hasEphemeral + expression: | + size(object.spec.?ephemeralContainers.orValue([])) > 0 + - name: hasHostPath + expression: | + object.spec.?volumes.orValue([]).exists(v, has(v.hostPath)) + validations: + - expression: "!variables.usesHostNamespace" + message: "hostNetwork / hostPID / hostIPC are denied in kars task namespaces (kars.azure.com/isolated=strict): they bypass the pod CNI and the per-pod egress-guard, breaking the receipt's no-bypass completeness claim. Emergency override: label the namespace kars.azure.com/break-glass=true (audited)." + reason: Forbidden + - expression: "!variables.hasPrivileged" + message: "privileged containers are denied in kars task namespaces: a privileged container can rewrite iptables / load kernel modules and defeat the egress-guard. Override: kars.azure.com/break-glass=true (audited)." + reason: Forbidden + - expression: "!variables.hasPrivEsc" + message: "allowPrivilegeEscalation=true is denied in kars task namespaces. Override: kars.azure.com/break-glass=true (audited)." + reason: Forbidden + - expression: "!variables.hasEphemeral" + message: "ephemeralContainers are denied at create time in kars task namespaces: they are the canonical sandbox escape hatch (join an existing pod's PID/net namespace with a different securityContext). Override: kars.azure.com/break-glass=true (audited)." + reason: Forbidden + - expression: "!variables.hasHostPath" + message: "hostPath volumes are denied in kars task namespaces: they mount the node filesystem and escape the sandbox. Override: kars.azure.com/break-glass=true (audited)." + reason: Forbidden +--- +apiVersion: admissionregistration.k8s.io/v1 +kind: ValidatingAdmissionPolicyBinding +metadata: + name: kars-task-namespace-floor-binding + labels: + app.kubernetes.io/name: kars + app.kubernetes.io/component: admission +spec: + policyName: kars-task-namespace-floor + validationActions: [Deny, Audit] +{{- end }} diff --git a/deploy/helm/kars/templates/crd-karsapproval.yaml b/deploy/helm/kars/templates/crd-karsapproval.yaml new file mode 100644 index 000000000..e2e34a1bc --- /dev/null +++ b/deploy/helm/kars/templates/crd-karsapproval.yaml @@ -0,0 +1,237 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: karsapprovals.kars.azure.com + labels: + app.kubernetes.io/name: kars + app.kubernetes.io/component: crd +spec: + group: kars.azure.com + names: + categories: [] + kind: KarsApproval + plural: karsapprovals + shortNames: + - cappr + singular: karsapproval + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.taskRef.name + name: Task + type: string + - jsonPath: .spec.action.kind + name: Action + type: string + - jsonPath: .status.phase + name: Phase + type: string + - jsonPath: .status.decider + name: Decider + type: string + - jsonPath: .status.expiresAt + name: Expires + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: Auto-generated derived type for KarsApprovalSpec via `CustomResource` + properties: + spec: + description: '`KarsApproval.spec` — a human decision a task is waiting on.' + properties: + action: + description: What needs a human decision. + properties: + detail: + description: Optional longer detail (e.g. the exact tool args or egress host). + maxLength: 8192 + nullable: true + type: string + kind: + description: |- + One of [`ACTION_KINDS`]. Not enum-constrained on the wire so the + primitive stays open; the Bridge treats unknown kinds as `custom`. + maxLength: 64 + type: string + requestedTier: + description: |- + For a `tierRaise`, the autonomy tier (1..5) being requested. Surfaced + so an approver sees exactly how much authority they are granting. + format: int32 + nullable: true + type: integer + summary: + description: One-line, human-readable statement of what the agent wants to do. + maxLength: 4096 + type: string + required: + - kind + - summary + type: object + decision: + description: |- + The human decision. Absent while the approval is pending; a person (or + the Bridge acting for them) patches this to drive the terminal + transition. The controller is the sole writer of `status`. + nullable: true + properties: + decider: + description: |- + Identity of the human (or delegated principal) who decided. Recorded + verbatim into status and, for granted approvals, into the receipt. + maxLength: 320 + type: string + reason: + description: Optional justification, surfaced to auditors. + maxLength: 8192 + nullable: true + type: string + verdict: + description: '`approve` or `deny`.' + maxLength: 7 + type: string + required: + - decider + - verdict + type: object + taskRef: + description: |- + The `KarsTask` this approval gates, in the **same namespace**. The + controller binds the approval to this task's envelope digest. + properties: + name: + maxLength: 253 + type: string + required: + - name + type: object + ttl: + description: |- + Time-to-live as an ISO-8601 duration (`PT15M`, `PT4H`, `P1D`). An + undecided approval past `requestedAt + ttl` becomes `Expired`. Defaults + to `PT1H` when omitted. + maxLength: 64 + nullable: true + type: string + required: + - action + - taskRef + type: object + x-kubernetes-validations: + - message: spec.action.kind must be non-empty + reason: FieldValueInvalid + rule: size(self.action.kind) > 0 + - message: spec.taskRef.name must be non-empty + reason: FieldValueInvalid + rule: size(self.taskRef.name) > 0 + - message: spec.taskRef and spec.action are immutable + reason: FieldValueForbidden + rule: self.taskRef == oldSelf.taskRef && self.action == oldSelf.action + - message: spec.ttl is immutable + reason: FieldValueForbidden + rule: '(!has(self.ttl) && !has(oldSelf.ttl)) || (has(self.ttl) && has(oldSelf.ttl) && self.ttl == oldSelf.ttl)' + - message: spec.decision is immutable once recorded + reason: FieldValueForbidden + rule: '!has(oldSelf.decision) || (has(self.decision) && self.decision == oldSelf.decision)' + - message: spec.decision requires approve/deny and a non-empty decider + reason: FieldValueInvalid + rule: '!has(self.decision) || (self.decision.verdict in [''approve'',''deny''] && size(self.decision.decider) > 0)' + status: + description: '`KarsApproval.status` — the controller is the sole writer.' + nullable: true + properties: + boundEnvelopeDigest: + description: |- + The task authorization digest (envelope plus effective blueprint). + Copied once from `status.envelopeDigest`; never changes after binding. + nullable: true + type: string + boundRequest: + description: |- + Controller snapshot of taskRef/action/ttl, excluding the later decision. + Prevents request mutation even on clusters with outdated admission rules. + nullable: true + type: string + boundTaskUid: + description: Immutable Kubernetes identity of the task whose authority was bound. + nullable: true + type: string + conditions: + description: |- + Standard K8s conditions; the `Decided` condition message surfaces + *why* (e.g. the staleness reason). + items: + description: Condition contains details for one aspect of the current state of this API Resource. + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating details about the transition. This may be an empty string. + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance. + format: int64 + type: integer + reason: + description: reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty. + type: string + status: + description: status of the condition, one of True, False, Unknown. + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + nullable: true + type: array + decidedAt: + description: |- + RFC-3339 time the human decision was first recorded. Immutable once + set — re-reconciles preserve it. + nullable: true + type: string + decider: + description: Echo of `spec.decision.decider` once decided, for the printer column. + nullable: true + type: string + expiresAt: + description: RFC-3339 expiry (`requestedAt + ttl`). Stable across re-reconciles. + nullable: true + type: string + observedGeneration: + description: '`metadata.generation` last reconciled.' + format: int64 + nullable: true + type: integer + phase: + description: '`Pending` | `Approved` | `Denied` | `Expired` | `Stale`.' + nullable: true + type: string + requestedAt: + description: |- + RFC-3339 request creation time (first observation when unavailable). + The TTL is measured from here; re-reconciles never bump it. + nullable: true + type: string + type: object + required: + - spec + title: KarsApproval + type: object + served: true + storage: true + subresources: + status: {} diff --git a/deploy/helm/kars/templates/crd-karsreceipt.yaml b/deploy/helm/kars/templates/crd-karsreceipt.yaml new file mode 100644 index 000000000..d88f683f5 --- /dev/null +++ b/deploy/helm/kars/templates/crd-karsreceipt.yaml @@ -0,0 +1,213 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: karsreceipts.kars.azure.com + labels: + app.kubernetes.io/name: kars + app.kubernetes.io/component: crd +spec: + group: kars.azure.com + names: + categories: [] + kind: KarsReceipt + plural: karsreceipts + shortNames: + - crcpt + singular: karsreceipt + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.taskRef.name + name: Task + type: string + - jsonPath: .spec.envelopeDigest + name: EnvelopeDigest + type: string + - jsonPath: .spec.keyId + name: KeyId + type: string + - jsonPath: .status.conditions[-1:].type + name: State + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: Auto-generated derived type for KarsReceiptSpec via `CustomResource` + properties: + spec: + description: |- + `KarsReceipt.spec` — the persisted, signed Governance Receipt for one + `KarsTask`. The controller is the sole writer; it owns the object via an + owner reference to the task, so the receipt is garbage-collected with it. + properties: + claims: + description: |- + The claim matrix, surfaced for `kubectl`/Bridge without base64-decoding + the payload. This is a copy of `predicate.claims`; the signed source of + truth is inside `dsse.payload`. + items: + description: |- + One claim-class assertion in the receipt. `class`/`status` are constrained + to the small vocabularies below; kept as strings for forward-compatible + wire stability. + properties: + class: + description: 'One of: `integrity`, `conformance`, `completeness`, `regulatory`.' + type: string + detail: + description: Human-readable justification, surfaced verbatim to the auditor. + type: string + status: + description: 'One of: `PASS`, `PARTIAL`, `FAIL`, `OMITTED`.' + type: string + required: + - class + - detail + - status + type: object + type: array + dsse: + description: 'The DSSE envelope: base64 in-toto Statement + Ed25519 signature(s).' + properties: + payload: + description: Base64 of the in-toto Statement JSON (the signed payload). + type: string + payloadType: + description: Always [`DSSE_PAYLOAD_TYPE`]. + type: string + signatures: + description: One Ed25519 signature in V0. + items: + description: A single DSSE signature line. + properties: + keyid: + description: Hex SHA-256 fingerprint of the signing public key. + type: string + sig: + description: Base64 of the 64-byte Ed25519 signature over the PAE. + type: string + required: + - keyid + - sig + type: object + type: array + required: + - payload + - payloadType + - signatures + type: object + envelopeDigest: + description: |- + `sha256:` authorization digest of the envelope and effective blueprint. + Mirrors `status.envelopeDigest` and is bound into the signed subject. + type: string + keyId: + description: |- + Hex SHA-256 fingerprint of the signing public key. A verifier matches + this against the out-of-band trust anchor, never the reverse. + type: string + predicateType: + description: in-toto predicate type URI — always [`PREDICATE_TYPE`] for V0. + type: string + scheme: + description: Signing scheme, e.g. `DSSEv1+ed25519`. + type: string + taskRef: + description: The `KarsTask` this receipt attests, in the same namespace. + properties: + name: + type: string + required: + - name + type: object + required: + - claims + - dsse + - envelopeDigest + - keyId + - predicateType + - scheme + - taskRef + type: object + x-kubernetes-validations: + - message: spec.claims must be non-empty + reason: FieldValueInvalid + rule: size(self.claims) > 0 + - message: spec.envelopeDigest must be non-empty + reason: FieldValueInvalid + rule: size(self.envelopeDigest) > 0 + status: + description: |- + `KarsReceipt.status` — informational echo. The receipt's authority comes + from its signature, not from this block. + nullable: true + properties: + conditions: + description: Standard Kubernetes conditions describing the advisory receipt lifecycle. + items: + description: Condition contains details for one aspect of the current state of this API Resource. + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating details about the transition. This may be an empty string. + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance. + format: int64 + type: integer + reason: + description: reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty. + type: string + status: + description: status of the condition, one of True, False, Unknown. + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + nullable: true + type: array + inclusionEntryHash: + description: |- + Hash of this receipt's inclusion-log entry. An auditor checks the log + chain is intact and that this hash is present (`kars receipt verify`). + nullable: true + type: string + inclusionSeq: + description: |- + Sequence number of this receipt's entry in the `kars-receipt-log` + inclusion log (the cross-receipt tamper-evidence chain). + format: int64 + nullable: true + type: integer + issuedAt: + description: RFC3339 issuance time (unsigned — not part of the attested payload). + nullable: true + type: string + observedTaskGeneration: + description: The task `metadata.generation` this receipt was minted from. + format: int64 + nullable: true + type: integer + type: object + required: + - spec + title: KarsReceipt + type: object + served: true + storage: true + subresources: + status: {} diff --git a/deploy/helm/kars/templates/crd-karstask.yaml b/deploy/helm/kars/templates/crd-karstask.yaml new file mode 100644 index 000000000..35dbdd411 --- /dev/null +++ b/deploy/helm/kars/templates/crd-karstask.yaml @@ -0,0 +1,426 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: karstasks.kars.azure.com + labels: + app.kubernetes.io/name: kars + app.kubernetes.io/component: crd +spec: + group: kars.azure.com + names: + categories: [] + kind: KarsTask + plural: karstasks + shortNames: + - ctask + singular: karstask + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.envelope.tier + name: Tier + type: integer + - jsonPath: .status.phase + name: Phase + type: string + - jsonPath: .status.executionPhase + name: Execution + type: string + - jsonPath: .spec.envelope.delegationDepth + name: Depth + type: integer + - jsonPath: .status.envelopeDigest + name: EnvelopeDigest + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: Auto-generated derived type for KarsTaskSpec via `CustomResource` + properties: + spec: + description: '`KarsTask.spec` — a governed unit of work plus its trust envelope.' + properties: + blueprint: + description: |- + The **run blueprint** — the concrete, editable shape of the agent that + will run this task: which harness, which model, the system prompt, the + connected services (MCP) and tools it may use, the network destinations + it may reach, and the sandbox isolation. This is the substance a human + reviews and edits on the §20 launch package; every field here drives a + real field on the materialized `InferencePolicy` / `KarsSandbox`. When a + field is unset the controller falls back to a safe default. + nullable: true + properties: + egress: + description: |- + Network destinations the mission may reach. Drives + `KarsSandbox.spec.networkPolicy.allowedEndpoints`. Task sandboxes always + use Strict mode, including an empty list (no additional destinations). + items: + description: A network destination the mission may reach. + properties: + host: + description: Hostname, e.g. `api.github.com`. + type: string + port: + description: Optional TCP port (e.g. `443`); any port when omitted. + format: uint16 + maximum: 65535.0 + minimum: 0.0 + nullable: true + type: integer + required: + - host + type: object + type: array + instructions: + description: |- + System prompt / standing instructions for the agent, in addition to the + objective. Drives `KarsSandbox.spec.agent.instructions`. + nullable: true + type: string + isolation: + description: |- + Sandbox isolation level (`standard`, `enhanced`, `confidential`). Drives + `KarsSandbox.spec.sandbox.isolation`. Defaults to `standard`. + nullable: true + type: string + mcpServers: + description: |- + Connected services (MCP server names, same namespace) the mission may + use. Drives `KarsSandbox.spec.governance.mcpServerRefs`. Requires + `toolPolicy` to be set (governed MCP access is bounded by the tool + policy). + items: + type: string + type: array + memory: + description: |- + Shared team memory — the name of a same-namespace `KarsMemory` the agent + reads/writes. Drives `KarsSandbox.spec.memoryRef`. This is how a + persistent team shares knowledge across members and over time; a short + one-off task usually leaves it unset. + nullable: true + type: string + model: + description: |- + The model the agent reasons with. Drives + `InferencePolicy.spec.modelPreference.primary`. Defaults from controller + env when unset. + nullable: true + properties: + deployment: + description: Deployment / model name as the provider advertises it. + type: string + provider: + description: |- + Provider tag: `azure-openai`, `anthropic`, `gemini`, `bedrock`, + `ollama`, `github-models`. + type: string + required: + - deployment + - provider + type: object + runtime: + description: |- + Harness/runtime (`OpenClaw`, `OpenAIAgents`, `MicrosoftAgentFramework`, + `Hermes`; `MAF` is an alias). BYO requires configuration not supported + by task blueprints and is rejected. Defaults to `OpenClaw`. + nullable: true + type: string + toolPolicy: + description: |- + Tools the agent may call, expressed as the name of an existing + same-namespace `ToolPolicy`. Drives `KarsSandbox.spec.governance` + (`enabled: true` + `toolPolicyRef`). Composing the existing `ToolPolicy` + CRD keeps the AGT profile + `appliesTo` scope authoritative rather than + duplicating an allow-list here. Required whenever `mcpServers` is set — + governed MCP access is meaningless without a tool policy to bound it. + nullable: true + type: string + type: object + displayName: + description: Optional short label surfaced in CLI / UI listings. + nullable: true + type: string + envelope: + description: The trust envelope that governs this task and bounds any delegation. + properties: + authorityCeiling: + description: |- + The maximum autonomy tier any *descendant* task may hold. Must be in + `1..5` and `<= tier` — a task can never authorize a child to act with + more authority than it holds itself. + format: int32 + type: integer + budget: + description: Optional resource budget for the whole task subtree. + nullable: true + properties: + tokens: + description: |- + Maximum total tokens the task subtree may consume. `0`/absent means + "no token cap declared". Positive ceilings are planning declarations: + launch is rejected until durable total/subtree enforcement is available. + format: int64 + nullable: true + type: integer + usdMicros: + description: |- + Maximum total spend in micro-USD (1e-6 USD) for the task subtree. + `0`/absent means no cap declared. Positive ceilings block launch in this + foundation. Integer micro-USD avoids floating-point in an audit field. + format: int64 + nullable: true + type: integer + type: object + delegationDepth: + default: 0 + description: |- + Remaining number of delegation hops this task may still spawn. A child + task is minted with `delegationDepth = parent.delegationDepth - 1`; + at `0` no further delegation is permitted. Must be `>= 0`. + format: int32 + type: integer + egressAllowlistRef: + description: |- + Reserved egress policy reference. This foundation cannot resolve it + and rejects it before Ready. Use `blueprint.egress` for Strict inline + destinations; standalone sandbox signed OCI allowlists are unchanged. + nullable: true + properties: + name: + type: string + required: + - name + type: object + tier: + description: Autonomy tier (1..5). See the module docs for the taxonomy. + format: int32 + type: integer + toolPolicyRef: + description: |- + Optional reference to a same-namespace `ToolPolicy` CR that bounds + which tools/MCP servers this task (and its descendants) may call. + nullable: true + properties: + name: + type: string + required: + - name + type: object + required: + - authorityCeiling + - tier + type: object + execution: + description: |- + Execution gate (plan §20). A task is *governed-but-idle* by default — + validated and digested, but not running. Execution begins only on an + explicit launch, mirroring the "review the package, then launch" + principle: the human reviews the trust envelope, then opts in. When + `execution.launch` is `true` and the envelope is valid, the controller + materializes a governed `KarsSandbox` (the running agent) bounded by + the envelope. + nullable: true + properties: + launch: + default: false + description: |- + When `true`, the controller materializes a governed `KarsSandbox` from + this task. Defaults to `false` — review before launch. + type: boolean + runtime: + description: |- + Runtime to launch the agent on. Defaults to `OpenClaw`. Must match the + controller's `RuntimeKind` enum. Superseded by `blueprint.runtime` when + both are set. + nullable: true + type: string + type: object + objective: + description: |- + Human-readable statement of the task to be performed. This is the + instruction a task-giver writes; the agent fleet works to satisfy it. + type: string + parentRef: + description: |- + Optional reference to a parent `KarsTask` in the **same namespace**. + + When set, this task is a *delegated child*: the controller verifies + that this task's `envelope` is a strict subset of the parent's + (capability-attenuating delegation — a child may narrow authority but + never amplify it), and mints `status.lineage` from the parent's + ancestry. A child whose envelope exceeds its parent on any axis is + rejected as `Degraded` and never receives an envelope digest. This is + the substrate enforcement of OWASP ASI-08 (cascading authority) — done + by the controller, not asked of the model. + nullable: true + properties: + name: + type: string + required: + - name + type: object + required: + - envelope + - objective + type: object + x-kubernetes-validations: + - message: spec.objective must be 1-4096 characters + reason: FieldValueInvalid + rule: size(self.objective) > 0 && size(self.objective) <= 4096 + - message: spec.envelope.tier must be in 1..5 + reason: FieldValueInvalid + rule: self.envelope.tier >= 1 && self.envelope.tier <= 5 + - message: spec.envelope.authorityCeiling must be in 1..5 + reason: FieldValueInvalid + rule: self.envelope.authorityCeiling >= 1 && self.envelope.authorityCeiling <= 5 + - message: spec.envelope.authorityCeiling must be <= spec.envelope.tier (a task cannot grant a child more authority than it holds) + reason: FieldValueInvalid + rule: self.envelope.authorityCeiling <= self.envelope.tier + - message: spec.envelope.delegationDepth must be in 0..16 + reason: FieldValueInvalid + rule: self.envelope.delegationDepth >= 0 && self.envelope.delegationDepth <= 16 + - message: spec.envelope.budget.tokens, when set, must be >= 0 + reason: FieldValueInvalid + rule: '!has(self.envelope.budget) || !has(self.envelope.budget.tokens) || self.envelope.budget.tokens >= 0' + - message: spec.envelope.budget.usdMicros, when set, must be >= 0 + reason: FieldValueInvalid + rule: '!has(self.envelope.budget) || !has(self.envelope.budget.usdMicros) || self.envelope.budget.usdMicros >= 0' + - message: spec.displayName, when set, must be 1-253 characters + reason: FieldValueInvalid + rule: '!has(self.displayName) || (size(self.displayName) > 0 && size(self.displayName) <= 253)' + - message: spec.blueprint.runtime must be OpenClaw, OpenAIAgents, MAF, MicrosoftAgentFramework or Hermes; BYO task configuration is unsupported + reason: FieldValueInvalid + rule: '!has(self.blueprint) || !has(self.blueprint.runtime) || self.blueprint.runtime in [''OpenClaw'',''OpenAIAgents'',''MAF'',''MicrosoftAgentFramework'',''Hermes'']' + - message: spec.execution.runtime must name a supported task runtime; BYO task configuration is unsupported + reason: FieldValueInvalid + rule: '!has(self.execution) || !has(self.execution.runtime) || self.execution.runtime in [''OpenClaw'',''OpenAIAgents'',''MAF'',''MicrosoftAgentFramework'',''Hermes'']' + - message: spec.blueprint.toolPolicy must match spec.envelope.toolPolicyRef + reason: FieldValueInvalid + rule: '!has(self.envelope.toolPolicyRef) || !has(self.blueprint) || !has(self.blueprint.toolPolicy) || self.blueprint.toolPolicy == self.envelope.toolPolicyRef.name' + - message: envelope.egressAllowlistRef is unsupported by this foundation; use blueprint.egress for enforced Strict destinations + reason: FieldValueInvalid + rule: '!has(self.envelope.egressAllowlistRef)' + - message: 'UnsupportedLaunchBudget: total/subtree token and usdMicros ceilings are not enforced; bounded tasks may be planned but cannot launch' + reason: FieldValueForbidden + rule: '!has(self.execution) || !self.execution.launch || !has(self.envelope.budget) || ((!has(self.envelope.budget.tokens) || self.envelope.budget.tokens == 0) && (!has(self.envelope.budget.usdMicros) || self.envelope.budget.usdMicros == 0))' + - message: spec.blueprint.isolation must be one of standard, enhanced, confidential + reason: FieldValueInvalid + rule: '!has(self.blueprint) || !has(self.blueprint.isolation) || self.blueprint.isolation in [''standard'',''enhanced'',''confidential'']' + - message: spec.blueprint.instructions, when set, must be <= 8192 characters + reason: FieldValueInvalid + rule: '!has(self.blueprint) || !has(self.blueprint.instructions) || size(self.blueprint.instructions) <= 8192' + - message: spec.blueprint.mcpServers may list at most 8 connected services + reason: FieldValueInvalid + rule: '!has(self.blueprint) || !has(self.blueprint.mcpServers) || size(self.blueprint.mcpServers) <= 8' + - message: spec.blueprint.mcpServers requires spec.blueprint.toolPolicy — governed MCP access must be bounded by a tool policy + reason: FieldValueInvalid + rule: '!has(self.blueprint) || !has(self.blueprint.mcpServers) || size(self.blueprint.mcpServers) == 0 || has(self.blueprint.toolPolicy)' + - message: spec.blueprint.egress may list at most 32 destinations + reason: FieldValueInvalid + rule: '!has(self.blueprint) || !has(self.blueprint.egress) || size(self.blueprint.egress) <= 32' + status: + description: '`KarsTask.status`.' + nullable: true + properties: + conditions: + description: |- + Standard K8s conditions. `Ready` is set `True` once the envelope has + been validated and its digest stamped. + items: + description: Condition contains details for one aspect of the current state of this API Resource. + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating details about the transition. This may be an empty string. + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance. + format: int64 + type: integer + reason: + description: reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty. + type: string + status: + description: status of the condition, one of True, False, Unknown. + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + nullable: true + type: array + envelopeDigest: + description: |- + `sha256:` authorization digest of the validated envelope and effective + governed blueprint, including resolved model defaults and capability refs. + nullable: true + type: string + executionDetail: + description: |- + Human-readable detail about the execution state — surfaced verbatim in + the product so a user understands *why* (e.g. the kind/Foundry caveat). + nullable: true + type: string + executionPhase: + description: |- + Execution phase (the §20 launch lifecycle), distinct from the + governance `phase`: + - `Idle` — governed but not launched (the default). + - `Launching` — a `KarsSandbox` has been materialized; awaiting it. + - `Running` — the sandbox reports Running. + - `Degraded` — the sandbox degraded (e.g. no inference endpoint). + nullable: true + type: string + lineage: + description: |- + Ancestry of this task, oldest-first: the chain of parent task names + from the root delegation down to (but excluding) this task. Empty for + a root task. Populated by the delegation minting path (next slice). + items: + type: string + type: array + observedGeneration: + description: |- + The `.metadata.generation` most recently reconciled, so clients can + tell whether `status` reflects the current `spec`. + format: int64 + nullable: true + type: integer + phase: + description: 'One of: `Pending`, `Ready`, `Degraded`.' + nullable: true + type: string + sandboxRef: + description: Name of the `KarsSandbox` materialized for this task, when launched. + nullable: true + properties: + name: + type: string + required: + - name + type: object + type: object + required: + - spec + title: KarsTask + type: object + served: true + storage: true + subresources: + status: {} diff --git a/deploy/helm/kars/templates/rbac.yaml b/deploy/helm/kars/templates/rbac.yaml index acfdefd6a..b2bc4380a 100644 --- a/deploy/helm/kars/templates/rbac.yaml +++ b/deploy/helm/kars/templates/rbac.yaml @@ -56,6 +56,15 @@ rules: - "karsauthconfigs/status" - "karssreactions" - "karssreactions/status" + - "karstasks" + - "karstasks/status" + - "karstasks/finalizers" + - "karsreceipts" + - "karsreceipts/status" + - "karsreceipts/finalizers" + - "karsapprovals" + - "karsapprovals/status" + - "karsapprovals/finalizers" verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] # Create and manage sandbox namespaces - apiGroups: [""] @@ -91,6 +100,12 @@ rules: - apiGroups: ["networking.k8s.io"] resources: ["networkpolicies"] verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + # Read admission policies — to attest which completeness-floor VAPs are + # enforced when minting a Governance Receipt (read-only; the chart, not the + # controller, installs them). + - apiGroups: ["admissionregistration.k8s.io"] + resources: ["validatingadmissionpolicies"] + verbs: ["get", "list", "watch"] # Events — Kubernetes ships two Event APIs: the legacy core v1 # Events ("" apiGroup) and the modern events.k8s.io/v1 Events. # The controller writes to events.k8s.io (the kube-rs Recorder diff --git a/deploy/helm/kars/values.yaml b/deploy/helm/kars/values.yaml index 277a1cecd..5d629ed84 100644 --- a/deploy/helm/kars/values.yaml +++ b/deploy/helm/kars/values.yaml @@ -238,6 +238,19 @@ admission: # handled by the controller's own pod template). # Requires Kubernetes >= 1.30 (VAP GA). enabled: true + taskNamespaceFloor: + # Missing/null sections and flags remain enabled on --reuse-values upgrades. + # Deploy ValidatingAdmissionPolicy that enforces the CREATE-time + # completeness floor (design note §24b) on pods in sandbox / task + # namespaces (kars.azure.com/isolated=strict): deny hostNetwork / + # hostPID / hostIPC, privileged, allowPrivilegeEscalation, + # ephemeralContainers at create, and hostPath volumes. Complements + # the UPDATE-only sandboxPostureLock by making the receipt's + # no-bypass completeness claim hold against a compromised controller + # or a direct `kubectl apply`, not just posture drift. + # Break-glass: kars.azure.com/break-glass=true on the namespace + # (audited). Requires Kubernetes >= 1.30 (VAP GA). + enabled: true seccompAutoStamp: # Deploy MutatingAdmissionPolicy that auto-stamps the # kars-strict seccomp profile onto sandbox-namespace pods that diff --git a/docs/api/crd-reference.md b/docs/api/crd-reference.md index f2009f861..32332d9a1 100644 --- a/docs/api/crd-reference.md +++ b/docs/api/crd-reference.md @@ -1,6 +1,6 @@ # CRD reference -kars exposes its API through **twelve** CustomResourceDefinitions in the `kars.azure.com` group, all at version `v1alpha1`. **Ten are workload CRDs** you author per agent or per policy (or, for `KarsSREAction`, that the SRE operator proposes on your behalf) — catalogued in [At a glance](#at-a-glance) below. **Two are infrastructure CRDs** you do not hand-write: [`KarsAuthConfig`](#karsauthconfig--cluster-trust-anchor) (a cluster-scoped singleton created by `kars mesh setup-trust`) and [`KarsPairing`](#infrastructure-crds) (a controller-internal binding record). This page is the canonical schema reference. For the prose explanation of how these fit together, see **[Architecture — CRDs as the API](../architecture.md#crds-as-the-api)**. +kars exposes its API through **fifteen** CustomResourceDefinitions in the `kars.azure.com` group, all at version `v1alpha1`. **Thirteen are workload CRDs** you author per agent, task or policy (or, for `KarsSREAction`, that the SRE operator proposes on your behalf) — catalogued in [At a glance](#at-a-glance) below. **Two are infrastructure CRDs** you do not hand-write: [`KarsAuthConfig`](#karsauthconfig--cluster-trust-anchor) (a cluster-scoped singleton created by `kars mesh setup-trust`) and [`KarsPairing`](#infrastructure-crds) (a controller-internal binding record). This page is the canonical schema reference. For the prose explanation of how these fit together, see **[Architecture — CRDs as the API](../architecture.md#crds-as-the-api)**. > **Version.** All CRDs are served at `kars.azure.com/v1alpha1`. The project is at `v0.1.18`; see [`CHANGELOG.md`](../../CHANGELOG.md) for what's shipped and [`docs/roadmap.md`](../roadmap.md) for what's next. @@ -18,6 +18,98 @@ kars exposes its API through **twelve** CustomResourceDefinitions in the `kars.a | `trustgraphs.kars.azure.com` | `TrustGraph` | `tg` | Cluster | Inline `spec.edges[].signature` (Ed25519 per edge, domain-separated payload) | Cross-namespace / cross-cluster mesh trust topology. | | `egressapprovals.kars.azure.com` | `EgressApproval` | `eappr` | Namespaced | None on the CR itself (it's a sibling overlay); the sandbox's signed `allowlistRef` is the cryptographic baseline | Ephemeral, TTL-bounded extra egress hosts (overlay on baseline allowlist). | | `karssreactions.kars.azure.com` | `KarsSREAction` | `sreaction` | Namespaced | None on the CR; execution is gated by `spec.approval.state` + a one-shot minted writer token | An approval-gated, TTL-bounded cluster remediation the SRE operator proposes. | +| `karstasks.kars.azure.com` | `KarsTask` | `ctask` | Namespaced | Envelope digest attested by a receipt | A governed task, optionally launched as an owned sandbox. | +| `karsapprovals.kars.azure.com` | `KarsApproval` | `cappr` | Namespaced | Immutable bound decisions enter receipts | A human decision on a task's current authority. | +| `karsreceipts.kars.azure.com` | `KarsReceipt` | `crcpt` | Namespaced | DSSE/Ed25519 signed predicate | Independently verifiable governance facts, not proof of complete runtime enforcement. | + +### Governed task foundation: supported limits + +`KarsTask` planning validates the envelope and effective blueprint before reporting +governance `Ready`. `execution.launch` is a separate opt-in: + +- Task sandboxes always use **Strict egress**, including an empty destination list. + This does not change the standalone `KarsSandbox` Learn default. +- `blueprint.toolPolicy` must equal a pinned `envelope.toolPolicyRef`. + `envelope.egressAllowlistRef` is rejected because this foundation cannot resolve + that reference; `blueprint.egress` supplies enforced inline destinations. +- `OpenClaw`, `OpenAIAgents`, `MicrosoftAgentFramework` (`MAF` alias), and `Hermes` + use the existing sandbox runtime contract. Task `BYO` is rejected until the + blueprint can carry its required image/contract configuration. +- **Budget ceilings are planning declarations, not operative quotas.** + `budget.tokens` and `budget.usdMicros` describe total task-subtree limits; + `0` or absence means no cap declared on that axis. An unbounded child cannot + attenuate a positive parent cap. Launch with either positive ceiling is + rejected as `UnsupportedLaunchBudget`. The existing per-sandbox daily/monthly + token counters reset independently and do not enforce subtree totals or money. + Bounded Bridge launches require durable aggregate enforcement before support + can be claimed. Do not clear a reviewed budget merely to bypass this gate. +- Same-name sandbox/policy conflicts are preserved. Only resources controller-owned + by the exact task UID can be updated or deleted, with UID/resourceVersion + concurrency checks. Cleanup remains `Stopping` and retries API errors or + resources awaiting finalization, even if the task's sandbox status reference is lost. + +`status.envelopeDigest` is the **task authorization digest**, not merely the +envelope lattice identifier. `KarsTask::envelope_digest()` (or +`KarsTaskSpec::authorization_digest()`) hashes the normalized envelope, parent +reference and full effective blueprint with a versioned domain and full SHA-256. +Canonical JSON uses UTF-8, compact encoding and recursively sorted object keys; +array order is preserved. The domain is `kars.azure.com/task-authorization/v1`. +Receipt producers can reuse `KarsTaskSpec::authorization_configuration_with_model` +to obtain this exact serializable effective snapshot. Resolve defaults once with +`kars_task::blueprint::controller_default_model()` and pass that value to both +the snapshot accessor and `authorization_digest_with_model`; do not duplicate +default resolution or substitute the raw declared spec for effective evidence. +This includes egress hosts/ports, tool/MCP/memory references, runtime, isolation, +model/provider and combined objective/instructions. Materialization consumes the +same normalizer: `MAF` equals `MicrosoftAgentFramework`, blueprint runtime overrides +execution runtime, absent isolation is `standard`, and zero/absent budget caps +normalize to unbounded. The launch switch and display label do not change authority. +Model defaults resolve `KARS_TASK_DEFAULT_MODEL` → `AZURE_OPENAI_DEPLOYMENT` → +`DEFAULT_MODEL` → `gpt-4o-mini`; default provider resolves +`KARS_TASK_DEFAULT_PROVIDER` → `azure-openai`. Changing an effective controller +default invalidates prior task bindings. The pure `TaskEnvelope::digest()` remains +available for lattice/team uses, **not** task approval authorization. +Reference names are bound; this digest does not attest mutable referenced resource +contents or container images. Those require separate policy/runtime evidence. + +`KarsApproval` freezes `taskRef`, action and TTL at admission; the human may set +`spec.decision` once. The controller snapshots the request, binds the task UID +and current Ready authorization digest, and checks expiry before accepting the +first decision. Blueprint changes invalidate pending requests even if the +envelope-only lattice fields did not change. Terminal decisions remain immutable +historical facts, **not perpetual grants**: consumers must compare the current +task UID and authorization digest before acting. `approval_authorizes_task` +checks current Ready authority, immutable request/decision coherence and the +`Approved` phase. Consumers must additionally check action kind, target, owner +identity and one-shot semantics. The existing status fields are +`boundEnvelopeDigest`, `boundTaskUid` and `boundRequest`; no new spec fields are +needed. The current-authority approval collection excludes old bindings, and +receipt subjects still refuse stale task status. A separate signed +`predicate.approvalHistory` retains valid historical decisions for the same +immutable task UID/name/namespace, including their original `boundEnvelopeDigest` +and `boundRequest`. Thus a D0 approval is recorded even when a promotion reaches +D1 before the first receipt observes it. +Historical records require matching immutable request and terminal decision +echoes, an observed approval generation, and +`requestedAt <= decidedAt < expiresAt`. They are explicitly tagged +`evidenceScope: historicalDecision`, `authorizesCurrentTask: false`, and +`consumptionAttested: false`. +They do not grant current authority or claim that an approval was consumed or +caused a transition. Current authorization still requires +`approval_authorizes_task()` and the consumer's action/owner/one-shot checks. +Legacy pending bindings without task/request identity become Stale and require a +new request. Controller snapshots also prevent mutated request echoes entering receipts. +Approval strings are schema-bounded for CEL evaluation: task names 253, kinds/TTL +64, summaries 4096, details/reasons 8192, and decider identities 320 characters. + +Receipt verification derives claims only from the verified signed predicate and +rejects conflicting unsigned echoes, including task identity, digest, issuer and +scheme. Completeness remains **PARTIAL**: false control flags mean **NOT VERIFIED**, +not necessarily absent. This foundation does not infer enforcement from policy +names or from a NetworkPolicy in the operator namespace. Signer secrets, public +anchors and receipt logs resolve `KARS_NAMESPACE`, then `POD_NAMESPACE`, then +`kars-system`; set the same namespace environment for the CLI verifier. +Initialization failures are logged and retried, without silently rotating malformed keys. ### Infrastructure CRDs @@ -28,7 +120,7 @@ Two more CRDs round out the API. You don't author these per agent, but the same | `karsauthconfigs.kars.azure.com` | `KarsAuthConfig` | `kac` | Cluster | `kars mesh setup-trust` (singleton, `metadata.name: default`) | Tenant-wide Entra Agent ID trust anchor. When absent, sandboxes run in the AGT anonymous tier. Fully documented in [KarsAuthConfig](#karsauthconfig--cluster-trust-anchor) below. | | `karspairings.kars.azure.com` | `KarsPairing` | `cp` | Namespaced | Controller | Binds two agents to their AgentMesh registry IDs and tracks handshake/trust state. Created from a one-time pairing token; read-only from your side. | -The full Kubernetes schema for all twelve lives in `deploy/helm/kars/templates/crd*.yaml`. Below we summarise what each CRD does, the spec fields you write, and the status fields the controller reports back. +The full Kubernetes schema lives in `deploy/helm/kars/templates/crd*.yaml`. Below we summarise what each CRD does, the spec fields you write, and the status fields the controller reports back. > **A note on short names.** The `c`-prefixed aliases (`cs`, `cmem`, `ceval`, `cp`) are retained from the project's earlier name and kept stable for API compatibility. One caveat: `cs` overlaps with kubectl's deprecated built-in `componentstatuses` alias, so in scripts prefer the unambiguous full plural (`karssandboxes`) or the kind (`KarsSandbox`). diff --git a/docs/security-audits/2026-09-03-core-governance-apis.md b/docs/security-audits/2026-09-03-core-governance-apis.md new file mode 100644 index 000000000..eba4a6100 --- /dev/null +++ b/docs/security-audits/2026-09-03-core-governance-apis.md @@ -0,0 +1,58 @@ +# Security Audit — Core governance APIs + +Date: 2026-09-03 +Scope: `controller/src/kars_task.rs`, `controller/src/kars_task_reconciler.rs`, `controller/src/kars_task_execution.rs`, `controller/src/kars_approval.rs`, `controller/src/kars_receipt.rs`, `controller/src/kars_receipt_log.rs`, `cli/src/commands/approval.ts`, `cli/src/commands/receipt.ts`. +Gated paths: `controller/src/crd_validations.rs`, `cli/src/commands/approval.ts`, `cli/src/commands/receipt.ts`. + +## Summary + +This slice introduces the minimal Kubernetes APIs for governed tasks, human +approval, signed receipts, and receipt-log checkpoints. The controller remains +the authority for execution materialization and status; CLI commands only +submit or verify typed resources. + +## T1: New capability / attack surface? (YES) + +- Adds namespaced `KarsTask`, `KarsApproval`, and `KarsReceipt` resources. +- Adds controller reconciliation for task-to-sandbox execution and approval + binding. +- Adds CLI read/write surfaces for approvals and receipt verification. + +## T2: Security-control change? (YES) + +- Delegated task envelopes must attenuate tier, budget, policy, egress, and + delegation depth relative to their parent. +- Receipts are DSSE/Ed25519 signed and linked through a checkpointed inclusion + log. +- Approval requests are bound to a task envelope digest and guarded by CEL + request-shape validation. +- The task authorization digest includes the full effective governed blueprint, + not only the envelope lattice: blueprint/default changes invalidate pending + bindings. Terminal decisions remain immutable, but consumers must recheck + current task UID, authorization digest, request and decision coherence. +- Task deletion removes owned execution resources so stale sandboxes do not + retain authority. + +## T3: Availability / fail-open risk? (REDUCED) + +- Invalid or amplified envelopes fail closed before sandbox materialization. +- Missing model bindings surface degraded task state rather than silently + launching unusable agents. +- Receipt and approval failures remain visible in status and do not fabricate + successful governance evidence. + +## Verification + +- Full Rust workspace tests and doctests. +- Controller, router, CLI, Helm, CNCF conformance, formatting, clippy, LOC, + no-stubs, no-custom-crypto, null-provider, module-isolation, and copyright + gates. +- CLI approval and receipt tests plus package build. + +## Verdict + +Accept. The new authority surfaces are typed, attenuating, controller-owned, +and covered by signed evidence plus fail-closed admission and reconciliation. + +Signed-off-by: Pal Lakatos-Toth +Signed-off-by: Copilot <223556219+Copilot@users.noreply.github.com> diff --git a/inference-router/src/metrics.rs b/inference-router/src/metrics.rs index faeb00f53..f5f6e54c3 100644 --- a/inference-router/src/metrics.rs +++ b/inference-router/src/metrics.rs @@ -51,6 +51,60 @@ pub static GUARDRAIL_SCANS: LazyLock = LazyLock::new(|| { .unwrap() }); +/// Token usage attributed by **task branch** (kars Bridge efficiency pillar). +/// +/// Distinct from [`TOKENS_USED`] (which is per-sandbox): this series is labelled +/// by the KarsTask id and its lineage *root*, so the cost of a delegated +/// sub-task tree rolls up to the root task that authorised it. Only emitted +/// when the router runs inside a task-materialized sandbox (the controller sets +/// `KARS_TASK_ID` / `KARS_TASK_ROOT`); non-task sandboxes produce no series, so +/// cardinality stays bounded by the number of tasks. +pub static TASK_TOKENS_USED: LazyLock = LazyLock::new(|| { + register_int_counter_vec!( + opts!( + "kars_task_tokens_total", + "Total tokens consumed, attributed by task branch" + ), + &["task", "root_task", "model", "direction"] + ) + .unwrap() +}); + +/// Task attribution read once from the environment: `(task_id, root_task)`. +/// `None` when this router is not inside a task-materialized sandbox. +pub static TASK_ATTRIBUTION: LazyLock> = LazyLock::new(|| { + parse_task_attribution( + std::env::var("KARS_TASK_ID").ok(), + std::env::var("KARS_TASK_ROOT").ok(), + ) +}); + +/// Pure attribution resolver (testable): a task id is required; the root +/// defaults to the task itself when unset (a root task is its own branch). +pub fn parse_task_attribution( + task_id: Option, + root: Option, +) -> Option<(String, String)> { + let task = task_id.filter(|s| !s.is_empty())?; + let root = root + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| task.clone()); + Some((task, root)) +} + +/// Record token usage on both the per-sandbox and (when this is a task +/// sandbox) the per-task-branch counters. `direction` is `input` or `output`. +pub fn record_tokens(sandbox: &str, model: &str, direction: &str, count: u64) { + TOKENS_USED + .with_label_values(&[sandbox, model, direction]) + .inc_by(count); + if let Some((task, root)) = TASK_ATTRIBUTION.as_ref() { + TASK_TOKENS_USED + .with_label_values(&[task, root, model, direction]) + .inc_by(count); + } +} + // ── AGT Governance metrics ────────────────────────────────────────────────── /// Total AGT policy evaluations by decision (allow, deny, requires_approval, rate_limited). @@ -369,3 +423,46 @@ pub static POLICY_BUNDLE_RELOADS: LazyLock = LazyLock::new(|| { ) .unwrap() }); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn attribution_requires_task_id() { + assert_eq!(parse_task_attribution(None, Some("root".into())), None); + assert_eq!(parse_task_attribution(Some("".into()), None), None); + } + + #[test] + fn attribution_defaults_root_to_task() { + assert_eq!( + parse_task_attribution(Some("child".into()), None), + Some(("child".into(), "child".into())) + ); + assert_eq!( + parse_task_attribution(Some("child".into()), Some("".into())), + Some(("child".into(), "child".into())) + ); + } + + #[test] + fn attribution_keeps_distinct_root() { + assert_eq!( + parse_task_attribution(Some("child".into()), Some("root".into())), + Some(("child".into(), "root".into())) + ); + } + + #[test] + fn record_tokens_increments_per_sandbox_counter() { + let before = TOKENS_USED + .with_label_values(&["sb-test", "m", "input"]) + .get(); + record_tokens("sb-test", "m", "input", 7); + let after = TOKENS_USED + .with_label_values(&["sb-test", "m", "input"]) + .get(); + assert_eq!(after - before, 7); + } +} diff --git a/inference-router/src/proxy.rs b/inference-router/src/proxy.rs index 5d79b145e..111ad7360 100644 --- a/inference-router/src/proxy.rs +++ b/inference-router/src/proxy.rs @@ -240,22 +240,20 @@ fn record_metrics( && let Some(usage) = body_json.get("usage") { if let Some(input) = usage.get("prompt_tokens").and_then(|v| v.as_i64()) { - metrics::TOKENS_USED - .with_label_values(&[ - &upstream.sandbox_name, - &upstream.deployment, - &"input".to_string(), - ]) - .inc_by(input as u64); + metrics::record_tokens( + &upstream.sandbox_name, + &upstream.deployment, + "input", + input as u64, + ); } if let Some(output) = usage.get("completion_tokens").and_then(|v| v.as_i64()) { - metrics::TOKENS_USED - .with_label_values(&[ - &upstream.sandbox_name, - &upstream.deployment, - &"output".to_string(), - ]) - .inc_by(output as u64); + metrics::record_tokens( + &upstream.sandbox_name, + &upstream.deployment, + "output", + output as u64, + ); } } } @@ -632,14 +630,10 @@ pub async fn forward_stream( .and_then(|v| v.as_i64()) .or_else(|| usage.get("output_tokens").and_then(|v| v.as_i64())); if let Some(input) = input_tokens { - metrics::TOKENS_USED - .with_label_values(&[&sandbox_name, &model, &"input".to_string()]) - .inc_by(input as u64); + metrics::record_tokens(&sandbox_name, &model, "input", input as u64); } if let Some(output) = output_tokens { - metrics::TOKENS_USED - .with_label_values(&[&sandbox_name, &model, &"output".to_string()]) - .inc_by(output as u64); + metrics::record_tokens(&sandbox_name, &model, "output", output as u64); } } } diff --git a/tests/compat/fixtures/task-floor-old-values.yaml b/tests/compat/fixtures/task-floor-old-values.yaml new file mode 100644 index 000000000..bf6de563c --- /dev/null +++ b/tests/compat/fixtures/task-floor-old-values.yaml @@ -0,0 +1,13 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Saved release settings predating admission.taskNamespaceFloor. +admission: + nullProviderBlock: + enabled: false + podExecBan: + enabled: false + sandboxPostureLock: + enabled: true + seccompAutoStamp: + enabled: false diff --git a/tests/e2e/run.sh b/tests/e2e/run.sh index 063de446d..3016b030a 100755 --- a/tests/e2e/run.sh +++ b/tests/e2e/run.sh @@ -798,6 +798,158 @@ EOF kubectl delete karseval e2e-karseval-lc -n kars-system --wait=false >/dev/null 2>&1 || true } +# KarsTask (kars Bridge V0, slice 1) — the task-as-trust-envelope CRD. +# Three assertions: +# 1. A valid task is admitted, reaches phase=Ready, and the controller +# stamps a sha256 envelopeDigest (the value a Governance Receipt binds). +# 2. CEL admission rejects an envelope whose authorityCeiling exceeds its +# tier (the anti-amplification rule) before it ever reaches etcd. +# 3. The reconciler is the sole writer of status — envelopeDigest appears +# only after reconcile, never asserted by the applicant. +test_crd_kars_task() { + cat <<'EOF' | kubectl apply -f - >/dev/null 2>&1 || { fail "KarsTask apply rejected"; return; } +--- +apiVersion: kars.azure.com/v1alpha1 +kind: KarsTask +metadata: + name: e2e-karstask + namespace: kars-system +spec: + objective: "fix the flaky payments integration test" + displayName: payments-bugfix + envelope: + tier: 3 + authorityCeiling: 2 + delegationDepth: 2 + budget: + tokens: 100000 + usdMicros: 5000000 +EOF + local phase ready digest + for _ in $(seq 1 20); do + phase=$(kubectl get karstask e2e-karstask -n kars-system \ + -o jsonpath='{.status.phase}' 2>/dev/null || true) + ready=$(kubectl get karstask e2e-karstask -n kars-system \ + -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}' 2>/dev/null || true) + digest=$(kubectl get karstask e2e-karstask -n kars-system \ + -o jsonpath='{.status.envelopeDigest}' 2>/dev/null || true) + if [[ "$phase" == "Ready" && "$ready" == "True" && -n "$digest" ]]; then + break + fi + sleep 2 + done + if [[ "$phase" == "Ready" && "$ready" == "True" ]]; then + pass "KarsTask: valid envelope → phase=Ready ready=True" + else + dump_cr_diagnostics karstask e2e-karstask kars-system + fail "KarsTask: expected phase=Ready ready=True (got phase=$phase ready=$ready)" + fi + if [[ "$digest" == sha256:* ]]; then + pass "KarsTask: controller stamped envelopeDigest ($digest)" + else + fail "KarsTask: envelopeDigest not stamped (got '$digest')" + fi + + # CEL must reject authorityCeiling > tier at admission (anti-amplification). + local reject_out + reject_out=$(cat <<'EOF' | kubectl apply -f - 2>&1 || true +--- +apiVersion: kars.azure.com/v1alpha1 +kind: KarsTask +metadata: + name: e2e-karstask-amplify + namespace: kars-system +spec: + objective: "attempt to grant a child more authority than held" + envelope: + tier: 2 + authorityCeiling: 4 + delegationDepth: 1 +EOF +) + if echo "$reject_out" | grep -qiE "authorityCeiling|Invalid|denied"; then + pass "KarsTask: CEL rejected authorityCeiling > tier at admission" + else + kubectl delete karstask e2e-karstask-amplify -n kars-system --wait=false >/dev/null 2>&1 || true + fail "KarsTask: amplifying envelope was NOT rejected by admission" + fi + + kubectl delete karstask e2e-karstask -n kars-system --wait=false >/dev/null 2>&1 || true +} + +# KarsTask capability-attenuating delegation (Bridge V0, slice 2 — Pillar A). +# A child task references a parent; the controller verifies the child's +# envelope attenuates the parent's and mints lineage. An amplifying child is +# self-valid (passes CEL) but rejected by the cross-object subset check, with +# NO envelope digest published — a receipt can never bind to amplified authority. +test_crd_kars_task_delegation() { + cat <<'EOF' | kubectl apply -f - >/dev/null 2>&1 || { fail "KarsTask delegation parent apply rejected"; return; } +--- +apiVersion: kars.azure.com/v1alpha1 +kind: KarsTask +metadata: { name: e2e-deleg-parent, namespace: kars-system } +spec: + objective: "orchestrate a governed migration" + envelope: { tier: 5, authorityCeiling: 4, delegationDepth: 3, budget: { tokens: 1000000 } } +EOF + # Wait for the parent to be Ready (children verify against it). + local pphase + for _ in $(seq 1 20); do + pphase=$(kubectl get karstask e2e-deleg-parent -n kars-system -o jsonpath='{.status.phase}' 2>/dev/null || true) + [[ "$pphase" == "Ready" ]] && break + sleep 2 + done + + # Valid child: attenuates on every axis. + cat <<'EOF' | kubectl apply -f - >/dev/null 2>&1 || { fail "KarsTask valid child apply rejected"; return; } +--- +apiVersion: kars.azure.com/v1alpha1 +kind: KarsTask +metadata: { name: e2e-deleg-child-ok, namespace: kars-system } +spec: + objective: "a bounded sub-step" + parentRef: { name: e2e-deleg-parent } + envelope: { tier: 4, authorityCeiling: 3, delegationDepth: 2, budget: { tokens: 100000 } } +EOF + # Amplifying child: tier 5 exceeds parent's delegated ceiling of 4. + cat <<'EOF' | kubectl apply -f - >/dev/null 2>&1 || { fail "KarsTask amplifying child apply rejected"; return; } +--- +apiVersion: kars.azure.com/v1alpha1 +kind: KarsTask +metadata: { name: e2e-deleg-child-amp, namespace: kars-system } +spec: + objective: "attempt to amplify authority" + parentRef: { name: e2e-deleg-parent } + envelope: { tier: 5, authorityCeiling: 5, delegationDepth: 2, budget: { tokens: 100000 } } +EOF + + local ok_phase ok_lineage amp_phase amp_digest + for _ in $(seq 1 20); do + ok_phase=$(kubectl get karstask e2e-deleg-child-ok -n kars-system -o jsonpath='{.status.phase}' 2>/dev/null || true) + amp_phase=$(kubectl get karstask e2e-deleg-child-amp -n kars-system -o jsonpath='{.status.phase}' 2>/dev/null || true) + [[ "$ok_phase" == "Ready" && "$amp_phase" == "Degraded" ]] && break + sleep 2 + done + + ok_lineage=$(kubectl get karstask e2e-deleg-child-ok -n kars-system -o jsonpath='{.status.lineage[0]}' 2>/dev/null || true) + if [[ "$ok_phase" == "Ready" && "$ok_lineage" == "e2e-deleg-parent" ]]; then + pass "KarsTask delegation: valid child Ready with controller-minted lineage ($ok_lineage)" + else + dump_cr_diagnostics karstask e2e-deleg-child-ok kars-system + fail "KarsTask delegation: valid child expected Ready+lineage (got phase=$ok_phase lineage=$ok_lineage)" + fi + + amp_digest=$(kubectl get karstask e2e-deleg-child-amp -n kars-system -o jsonpath='{.status.envelopeDigest}' 2>/dev/null || true) + if [[ "$amp_phase" == "Degraded" && -z "$amp_digest" ]]; then + pass "KarsTask delegation: amplifying child Degraded with NO digest (authority cannot be amplified)" + else + dump_cr_diagnostics karstask e2e-deleg-child-amp kars-system + fail "KarsTask delegation: amplifying child expected Degraded+no-digest (got phase=$amp_phase digest=$amp_digest)" + fi + + kubectl delete karstask e2e-deleg-child-ok e2e-deleg-child-amp e2e-deleg-parent -n kars-system --wait=false >/dev/null 2>&1 || true +} + # McpServer (dev-mode, no OAuth). The reconciler can't fetch JWKS in # Kind (no real issuer), so we assert only that the CR is admitted # and reaches a terminal status (Ready or Degraded — both indicate @@ -2910,6 +3062,8 @@ main() { test_crd_kars_memory || true test_crd_kars_eval || true test_crd_kars_eval_lifecycle || true + test_crd_kars_task || true + test_crd_kars_task_delegation || true test_crd_mcp_server || true test_crd_trustgraph_reconcile || true test_crd_karspairing_lifecycle || true