From 07038d8c198dfc33eb54835f54a1a80a274a74e9 Mon Sep 17 00:00:00 2001 From: Pal Lakatos-Toth Date: Fri, 26 Jun 2026 13:16:06 +0200 Subject: [PATCH 01/23] =?UTF-8?q?feat(controller):=20add=20KarsTask=20CRD?= =?UTF-8?q?=20=E2=80=94=20task-as-trust-envelope=20(Bridge=20V0=20slice=20?= =?UTF-8?q?1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The substrate primitive underneath kars Bridge: a typed unit of governed agent work that carries its trust envelope (autonomy tier 1..5, budget, tool/egress allow-list refs, delegationDepth, authorityCeiling). Independently usable on a plain kars cluster with no Bridge — kubectl apply a KarsTask and the controller validates the envelope and stamps a stable sha256 envelopeDigest (the value the Governance Receipt will bind to). - controller/src/kars_task.rs: KarsTaskSpec + TaskEnvelope + KarsTaskStatus, mirroring the KarsEval CRD conventions (kube-rs derive, camelCase serde, JsonSchema, printcolumns). Deterministic envelope digest. 6 unit tests. - controller/src/kars_task_reconciler.rs: finalizer + envelope validation (defence-in-depth behind CEL) + status stamping (phase, Ready condition, envelopeDigest), preserving lineage for the delegation-minting slice. The reconciler is the sole writer of envelope-derived status. 4 unit tests. - crd_validations.rs: CEL admission rules enforcing the trust-envelope invariants — including the anti-amplification rule authorityCeiling <= tier. - crd-karstask.yaml: generated via the helm_drift dumper; drift test green. - field_managers.rs: CLAW_TASK SSA manager. main.rs: reconciler wired in. - tests/e2e/run.sh: kind integration test (valid->Ready+digest; CEL rejects amplifying envelope). Verified live on kind: valid task -> phase=Ready + envelopeDigest stamped; digest recomputes on spec change; CEL rejects authorityCeiling>tier and out-of-range tier at admission. Full suite: 861 controller tests pass, clippy -D warnings clean, fmt clean, zero helm drift. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- controller/src/crd_validations.rs | 72 ++++ controller/src/field_managers.rs | 5 + controller/src/helm_drift.rs | 28 +- controller/src/kars_task.rs | 276 +++++++++++++++ controller/src/kars_task_reconciler.rs | 332 +++++++++++++++++++ controller/src/main.rs | 9 + deploy/helm/kars/templates/crd-karstask.yaml | 224 +++++++++++++ tests/e2e/run.sh | 80 +++++ 8 files changed, 1025 insertions(+), 1 deletion(-) create mode 100644 controller/src/kars_task.rs create mode 100644 controller/src/kars_task_reconciler.rs create mode 100644 deploy/helm/kars/templates/crd-karstask.yaml diff --git a/controller/src/crd_validations.rs b/controller/src/crd_validations.rs index 7ffa74beb..0ba24b0ef 100644 --- a/controller/src/crd_validations.rs +++ b/controller/src/crd_validations.rs @@ -54,6 +54,7 @@ use crate::inference_policy::InferencePolicy; use crate::kars_eval::KarsEval; use crate::kars_memory::KarsMemory; use crate::kars_sre_action::KarsSREAction; +use crate::kars_task::KarsTask; use crate::mcp_server::McpServer; use crate::tool_policy::ToolPolicy; @@ -507,6 +508,77 @@ 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() + }, + ] +} + +/// `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") +} + /// `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..1602cc98f 100644 --- a/controller/src/helm_drift.rs +++ b/controller/src/helm_drift.rs @@ -33,7 +33,7 @@ #[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, + 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 +66,11 @@ 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 TRUSTGRAPH_HELM_CRD_PATH: &str = concat!( env!("CARGO_MANIFEST_DIR"), "/../deploy/helm/kars/templates/crd-trustgraph.yaml" @@ -262,6 +267,27 @@ 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 trustgraph CRD. Run via: /// /// DUMP_TRUSTGRAPH_CRD_YAML=1 cargo test --bin kars-controller \ diff --git a/controller/src/kars_task.rs b/controller/src/kars_task.rs new file mode 100644 index 000000000..6fb3c641d --- /dev/null +++ b/controller/src/kars_task.rs @@ -0,0 +1,276 @@ +// 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; + +/// 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":"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 short label surfaced in CLI / UI listings. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub display_name: 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, + + /// Optional reference to a same-namespace `EgressAllowlist`-style CR that + /// bounds the network destinations this task (and its descendants) may + /// reach through the inference router. + #[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, so the same envelope always + /// produces the same digest across processes — the property the + /// Governance Receipt relies on to bind a task to the authority it ran + /// under. + #[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 + } +} + +/// Optional resource budget for a task subtree. +#[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" (governance still applies at the router). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tokens: Option, + + /// Maximum total spend in micro-USD (1e-6 USD) for the task subtree. + /// Integer micro-USD avoids floating-point in an audit-bound 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:` digest of the validated trust envelope. Stable for a given + /// envelope; recomputed whenever the spec changes. + #[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, +} + +#[cfg(test)] +mod tests { + 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 e = sample_envelope(); + assert_eq!(e.digest(), e.digest()); + } + + #[test] + fn envelope_digest_has_sha256_prefix_and_length() { + let d = sample_envelope().digest(); + assert!(d.starts_with("sha256:")); + // "sha256:" (7) + 16 bytes * 2 hex chars (32) = 39. + assert_eq!(d.len(), 39); + } + + #[test] + fn envelope_digest_changes_with_tier() { + let mut a = sample_envelope(); + let before = a.digest(); + a.tier = 4; + assert_ne!(before, a.digest()); + } + + #[test] + fn envelope_digest_changes_with_delegation_depth() { + let mut a = sample_envelope(); + let before = a.digest(); + a.delegation_depth += 1; + assert_ne!(before, a.digest()); + } + + #[test] + fn spec_roundtrips_through_camelcase_yaml() { + let spec = KarsTaskSpec { + objective: "fix the flaky test in payments".into(), + envelope: sample_envelope(), + display_name: Some("payments-bugfix".into()), + }; + let yaml = serde_yaml::to_string(&spec).expect("serializes"); + // Envelope fields must be camelCase on the wire. + 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); + } + + #[test] + fn default_envelope_is_least_privilege() { + let e = TaskEnvelope::default(); + assert_eq!(e.tier, TIER_MIN); + assert_eq!(e.delegation_depth, 0); + assert_eq!(e.authority_ceiling, TIER_MIN); + assert!(e.budget.is_none()); + } +} diff --git a/controller/src/kars_task_reconciler.rs b/controller/src/kars_task_reconciler.rs new file mode 100644 index 000000000..dc930659f --- /dev/null +++ b/controller/src/kars_task_reconciler.rs @@ -0,0 +1,332 @@ +// 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). +//! +//! This reconciler is intentionally side-effect-free on the cluster for V0: +//! it materializes verifiable *status* (the digest a Governance Receipt binds +//! to), not yet a governed sandbox. Sandbox materialization and +//! capability-attenuating child minting build on this in the following slices. + +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_READY}; + +const FIELD_MANAGER: &str = crate::field_managers::CLAW_TASK; +const FINALIZER: &str = "kars.azure.com/karstask-cleanup"; + +const REQUEUE_OK: 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", + } + } +} + +/// 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 e.delegation_depth < 0 { + return EnvelopeCheck::Invalid(format!( + "delegationDepth {} must be >= 0", + e.delegation_depth + )); + } + EnvelopeCheck::Valid +} + +struct Ctx { + client: Client, +} + +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); + + // Deletion: drop the finalizer and let the API server reap the object. + // There is nothing cluster-side to clean up in V0. + if task.metadata.deletion_timestamp.is_some() { + if has_finalizer(&task) { + let patch = json!({ + "apiVersion": "kars.azure.com/v1alpha1", + "kind": "KarsTask", + "metadata": { "finalizers": drop_finalizer(&task) }, + }); + tasks + .patch( + &name, + &PatchParams::apply(FIELD_MANAGER).force(), + &Patch::Apply(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": { "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); + // Lineage is owned by the delegation-minting path; never clobber it here. + let lineage = task + .status + .as_ref() + .map(|s| s.lineage.clone()) + .unwrap_or_default(); + + let new_status = match check_envelope(&task) { + EnvelopeCheck::Valid => { + let digest = task.spec.envelope.digest(); + let ready = conditions::preserve_transition_time( + prior_ready, + TYPE_READY, + cond_status::TRUE, + cond_reason::RECONCILED, + "trust envelope validated and digested", + generation, + ); + tracing::info!(karstask = %name, ns = %ns, digest = %digest, "KarsTask ready"); + KarsTaskStatus { + phase: Some(PHASE_READY.to_string()), + observed_generation: generation, + conditions: Some(vec![ready]), + envelope_digest: Some(digest), + lineage, + } + } + EnvelopeCheck::Invalid(why) => { + let ready = conditions::preserve_transition_time( + prior_ready, + TYPE_READY, + cond_status::FALSE, + cond_reason::SPEC_INVALID, + &format!("invalid trust envelope: {why}"), + generation, + ); + tracing::warn!(karstask = %name, ns = %ns, reason = %why, "KarsTask degraded"); + KarsTaskStatus { + phase: Some(PHASE_DEGRADED.to_string()), + observed_generation: generation, + conditions: Some(vec![ready]), + // No digest is published for an invalid envelope — the + // receipt must never bind to authority that didn't validate. + envelope_digest: None, + lineage, + } + } + }; + + let status_patch = json!({ + "apiVersion": "kars.azure.com/v1alpha1", + "kind": "KarsTask", + "status": new_status, + }); + tasks + .patch_status( + &name, + &PatchParams::apply(FIELD_MANAGER).force(), + &Patch::Apply(status_patch), + ) + .await?; + + Ok(Action::requeue(REQUEUE_OK)) +} + +/// 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()); + match tasks.list(&ListParams::default().limit(1)).await { + Ok(_) => tracing::info!("KarsTask CRD found — starting controller"), + Err(e) => { + tracing::warn!("KarsTask CRD not installed — reconciler disabled: {e}"); + std::future::pending::<()>().await; + #[allow(unreachable_code)] + return Ok(()); + } + } + let ctx = Arc::new(Ctx { client }); + 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(()) +} + +// ───────────────────────────────────────────────────────────────────── +// Unit tests — pure helpers only. K8s-API-touching paths are exercised +// by the kind-based integration harness. +// ───────────────────────────────────────────────────────────────────── +#[cfg(test)] +mod tests { + use super::*; + use crate::kars_task::{KarsTaskSpec, TaskEnvelope}; + + fn task_with(tier: i32, authority_ceiling: i32, delegation_depth: i32) -> KarsTask { + let mut t = KarsTask::new( + "t", + KarsTaskSpec { + objective: "do the thing".into(), + envelope: TaskEnvelope { + tier, + authority_ceiling, + delegation_depth, + ..TaskEnvelope::default() + }, + display_name: None, + }, + ); + t.metadata.namespace = Some("default".into()); + t + } + + #[test] + fn valid_envelope_passes() { + let t = task_with(3, 3, 2); + assert!(matches!(check_envelope(&t), EnvelopeCheck::Valid)); + } + + #[test] + fn authority_ceiling_above_tier_is_rejected() { + let t = task_with(2, 4, 1); + match check_envelope(&t) { + EnvelopeCheck::Invalid(why) => assert!(why.contains("authorityCeiling")), + EnvelopeCheck::Valid => panic!("expected rejection"), + } + } + + #[test] + fn tier_out_of_range_is_rejected() { + let t = task_with(9, 5, 0); + assert!(matches!(check_envelope(&t), EnvelopeCheck::Invalid(_))); + } + + #[test] + fn finalizer_roundtrip() { + let mut t = task_with(1, 1, 0); + assert!(!has_finalizer(&t)); + t.metadata.finalizers = Some(vec![FINALIZER.to_string(), "other/keep".to_string()]); + assert!(has_finalizer(&t)); + let dropped = drop_finalizer(&t); + assert_eq!(dropped, vec!["other/keep".to_string()]); + } +} diff --git a/controller/src/main.rs b/controller/src/main.rs index ab69b9707..639b37e9e 100644 --- a/controller/src/main.rs +++ b/controller/src/main.rs @@ -45,6 +45,8 @@ mod kars_memory_compile; mod kars_memory_reconciler; mod kars_sre_action; mod kars_sre_action_reconciler; +mod kars_task; +mod kars_task_reconciler; mod leader_election; mod mcp_server; mod mcp_server_reconciler; @@ -244,6 +246,10 @@ 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 trust_graph_handle = { let client = client.clone(); tokio::spawn(async move { trust_graph_reconciler::run(client).await }) @@ -396,6 +402,9 @@ async fn main() -> Result<()> { res = kars_eval_handle => { res??; } + res = kars_task_handle => { + res??; + } res = trust_graph_handle => { res??; } diff --git a/deploy/helm/kars/templates/crd-karstask.yaml b/deploy/helm/kars/templates/crd-karstask.yaml new file mode 100644 index 000000000..4fca07686 --- /dev/null +++ b/deploy/helm/kars/templates/crd-karstask.yaml @@ -0,0 +1,224 @@ +--- +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: .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: + 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" (governance still applies at the router). + format: int64 + nullable: true + type: integer + usdMicros: + description: |- + Maximum total spend in micro-USD (1e-6 USD) for the task subtree. + Integer micro-USD avoids floating-point in an audit-bound 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: |- + Optional reference to a same-namespace `EgressAllowlist`-style CR that + bounds the network destinations this task (and its descendants) may + reach through the inference router. + 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 + 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 + 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)' + 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:` digest of the validated trust envelope. Stable for a given + envelope; recomputed whenever the spec changes. + 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 + type: object + required: + - spec + title: KarsTask + type: object + served: true + storage: true + subresources: + status: {} + diff --git a/tests/e2e/run.sh b/tests/e2e/run.sh index 063de446d..c960d74cb 100755 --- a/tests/e2e/run.sh +++ b/tests/e2e/run.sh @@ -798,6 +798,85 @@ 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 +} + # 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 +2989,7 @@ main() { test_crd_kars_memory || true test_crd_kars_eval || true test_crd_kars_eval_lifecycle || true + test_crd_kars_task || true test_crd_mcp_server || true test_crd_trustgraph_reconcile || true test_crd_karspairing_lifecycle || true From 7997126def83c770c23d03f428b1b1f587700dd3 Mon Sep 17 00:00:00 2001 From: Pal Lakatos-Toth Date: Fri, 26 Jun 2026 15:58:28 +0200 Subject: [PATCH 02/23] feat(controller): capability-attenuating delegation for KarsTask (Bridge V0 slice 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pillar A — the security headline. A KarsTask may reference a parent via spec.parentRef; the controller verifies the child's trust envelope is a strict subset (attenuation) of the parent's and mints status.lineage from the parent's ancestry. A child that amplifies authority on ANY axis is rejected as Degraded with NO envelope digest — a Governance Receipt can never bind to amplified authority. This is OWASP ASI-08 (cascading authority) solved at the substrate, not asked of the model. - kars_task.rs: pure attenuation lattice — TaskEnvelope::attenuation_violations returns a precise EnvelopeViolation list across every axis: tier <= parent ceiling, ceiling <= parent ceiling, depth <= parent depth - 1, budget caps (no unbounded child under a bounded parent), and pinned tool/egress policy refs. 9 unit tests covering each amplification + valid attenuation. - kars_task_reconciler.rs: resolve_delegation fetches the parent, mints lineage (controller is sole writer), and routes Ready / Degraded / ParentMissing. - crd-karstask.yaml regenerated (parentRef); helm drift test green. - tests/e2e: delegation test (valid child Ready+lineage; amplifying child Degraded+no-digest). Verified live on kind: parent Ready (root); valid child Ready with lineage=[parent]; tier-5 child under a ceiling-4 parent Degraded with no digest and the exact violation message. 870 controller tests pass, clippy -D warnings clean, fmt clean, zero drift. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- controller/src/kars_task.rs | 411 ++++++++++++++++++- controller/src/kars_task_reconciler.rs | 201 +++++++-- deploy/helm/kars/templates/crd-karstask.yaml | 19 + tests/e2e/run.sh | 74 ++++ 4 files changed, 665 insertions(+), 40 deletions(-) diff --git a/controller/src/kars_task.rs b/controller/src/kars_task.rs index 6fb3c641d..aada69f5d 100644 --- a/controller/src/kars_task.rs +++ b/controller/src/kars_task.rs @@ -71,6 +71,19 @@ pub struct KarsTaskSpec { /// 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, + /// Optional short label surfaced in CLI / UI listings. #[serde(default, skip_serializing_if = "Option::is_none")] pub display_name: Option, @@ -152,9 +165,226 @@ impl TaskEnvelope { } 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 + } } -/// Optional resource budget for a task subtree. +/// 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, + }, +} + +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("") + ), + } + } +} + +/// 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 else { + // Parent is unbounded on this axis — any child value is an attenuation. + return; + }; + match child { + 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(), + }); + } +} #[derive(Debug, Serialize, Deserialize, Default, Clone, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct TaskBudget { @@ -254,6 +484,7 @@ mod tests { let spec = KarsTaskSpec { objective: "fix the flaky test in payments".into(), envelope: sample_envelope(), + parent_ref: None, display_name: Some("payments-bugfix".into()), }; let yaml = serde_yaml::to_string(&spec).expect("serializes"); @@ -265,6 +496,184 @@ mod tests { assert_eq!(back.envelope.authority_ceiling, 3); } + // ── Capability-attenuating delegation lattice (Pillar A) ────────────── + + /// A permissive parent: tier 5, ceiling 4, depth 3, generous budget. + 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, // <= parent ceiling 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, // <= 3 - 1 + authority_ceiling: 3, // <= 4 + }; + assert!( + child.attenuation_violations(&parent).is_empty(), + "{:?}", + child.attenuation_violations(&parent) + ); + } + + #[test] + fn child_tier_above_parent_ceiling_is_amplification() { + let parent = parent_envelope(); + let child = TaskEnvelope { + tier: 5, // parent ceiling is only 4 + authority_ceiling: 4, + delegation_depth: 0, + ..parent_envelope() + }; + let v = child.attenuation_violations(&parent); + assert!( + v.iter() + .any(|x| matches!(x, 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; // exceeds parent ceiling 4 + child.delegation_depth = 0; + let v = child.attenuation_violations(&parent); + assert!( + v.iter() + .any(|x| matches!(x, EnvelopeViolation::CeilingExceedsParentCeiling { .. })) + ); + } + + #[test] + fn delegation_depth_must_decrement() { + let parent = parent_envelope(); // depth 3 + let mut child = parent_envelope(); + child.tier = 4; + child.authority_ceiling = 4; + child.delegation_depth = 3; // must be <= 2 + let v = child.attenuation_violations(&parent); + assert!( + v.iter() + .any(|x| matches!(x, EnvelopeViolation::DelegationDepthExceeded { .. })) + ); + } + + #[test] + fn exhausted_delegation_budget_rejects_any_child() { + let mut parent = parent_envelope(); + parent.delegation_depth = 0; // no hops left + let mut child = parent_envelope(); + child.tier = 1; + child.authority_ceiling = 1; + child.delegation_depth = 0; + let v = child.attenuation_violations(&parent); + assert!( + v.iter() + .any(|x| matches!(x, 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), // parent caps at 1M + usd_micros: Some(1_000_000), + }); + let v = child.attenuation_violations(&parent); + assert!(v.iter().any(|x| matches!( + x, + 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; // parent bounds tokens + usd + let v = child.attenuation_violations(&parent); + assert!( + v.iter() + .any(|x| matches!(x, EnvelopeViolation::BudgetUnbounded { .. })) + ); + } + + #[test] + fn child_must_match_parent_pinned_tool_policy() { + let parent = parent_envelope(); // pins strict-tools + 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(), + }); + let v = child.attenuation_violations(&parent); + assert!(v.iter().any(|x| matches!( + x, + EnvelopeViolation::PolicyMismatch { + axis: PolicyAxis::ToolPolicy, + .. + } + ))); + } + + #[test] + fn child_may_add_egress_bound_where_parent_has_none() { + let parent = parent_envelope(); // egress_allowlist_ref None + 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(), + }); + // Adding a bound where the parent had none is attenuation, not amplification. + let v = child.attenuation_violations(&parent); + assert!(!v.iter().any(|x| matches!( + x, + EnvelopeViolation::PolicyMismatch { + axis: PolicyAxis::EgressAllowlist, + .. + } + ))); + } + #[test] fn default_envelope_is_least_privilege() { let e = TaskEnvelope::default(); diff --git a/controller/src/kars_task_reconciler.rs b/controller/src/kars_task_reconciler.rs index dc930659f..f09c833f6 100644 --- a/controller/src/kars_task_reconciler.rs +++ b/controller/src/kars_task_reconciler.rs @@ -147,53 +147,66 @@ async fn reconcile(task: Arc, ctx: Arc) -> Result { - let digest = task.spec.envelope.digest(); - let ready = conditions::preserve_transition_time( + 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, - TYPE_READY, - cond_status::TRUE, - cond_reason::RECONCILED, - "trust envelope validated and digested", generation, - ); - tracing::info!(karstask = %name, ns = %ns, digest = %digest, "KarsTask ready"); - KarsTaskStatus { - phase: Some(PHASE_READY.to_string()), - observed_generation: generation, - conditions: Some(vec![ready]), - envelope_digest: Some(digest), + task.spec.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::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.spec.envelope.digest(), + lineage, + ) } - } - EnvelopeCheck::Invalid(why) => { - let ready = conditions::preserve_transition_time( - prior_ready, - TYPE_READY, - cond_status::FALSE, - cond_reason::SPEC_INVALID, - &format!("invalid trust envelope: {why}"), - generation, - ); - tracing::warn!(karstask = %name, ns = %ns, reason = %why, "KarsTask degraded"); - KarsTaskStatus { - phase: Some(PHASE_DEGRADED.to_string()), - observed_generation: generation, - conditions: Some(vec![ready]), - // No digest is published for an invalid envelope — the - // receipt must never bind to authority that didn't validate. - envelope_digest: None, + 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, + ) } - } + }, }; let status_patch = json!({ @@ -212,6 +225,115 @@ async fn reconcile(task: Arc, ctx: Arc) -> Result, + 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(), + }); + } + }; + // 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()); + + let violations = task + .spec + .envelope + .attenuation_violations(&parent.spec.envelope); + Ok(Delegation::Child { + lineage, + violations, + }) +} + +/// 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, + } +} + +/// Build a `Degraded` status with no digest — the receipt must never bind to +/// authority that didn't validate or that amplified its parent. +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, + } +} + /// True iff the task carries our cleanup finalizer. fn has_finalizer(task: &KarsTask) -> bool { task.metadata @@ -292,6 +414,7 @@ mod tests { delegation_depth, ..TaskEnvelope::default() }, + parent_ref: None, display_name: None, }, ); diff --git a/deploy/helm/kars/templates/crd-karstask.yaml b/deploy/helm/kars/templates/crd-karstask.yaml index 4fca07686..9b5ef76ea 100644 --- a/deploy/helm/kars/templates/crd-karstask.yaml +++ b/deploy/helm/kars/templates/crd-karstask.yaml @@ -118,6 +118,25 @@ spec: 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 diff --git a/tests/e2e/run.sh b/tests/e2e/run.sh index c960d74cb..3016b030a 100755 --- a/tests/e2e/run.sh +++ b/tests/e2e/run.sh @@ -877,6 +877,79 @@ EOF 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 @@ -2990,6 +3063,7 @@ main() { 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 From 2ef4b3ecccbd415e1ec13874c3b455e900474968 Mon Sep 17 00:00:00 2001 From: Pal Lakatos-Toth Date: Fri, 26 Jun 2026 17:03:43 +0200 Subject: [PATCH 03/23] =?UTF-8?q?feat(controller):=20execution=20bridge=20?= =?UTF-8?q?=E2=80=94=20KarsTask=20materializes=20a=20governed=20KarsSandbo?= =?UTF-8?q?x=20(V0.1b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the governance/execution gap surfaced in live dogfood ("I created a task, nothing happens"). A governed task can now run a real agent — gated by an explicit launch, faithful to plan §20 (review the package, then launch). - kars_task.rs: spec.execution { launch, runtime } (default not-launched = governed-but-idle); status.executionPhase / sandboxRef / executionDetail; Execution printcolumn. - kars_task_execution.rs: on launch, server-side-apply an owned InferencePolicy + KarsSandbox bounded by the envelope (tool policy → governance, token budget → InferencePolicy), then read back the sandbox phase. Un-launch tears them down; owner refs cascade on task delete. 4 unit tests. - kars_task_reconciler.rs: reconcile_execution folds launch/teardown into status; execution errors degrade execution only, never the governance status. - crd-karstask.yaml regenerated; drift green. Verified live on kind: launch=true → real KarsSandbox + InferencePolicy materialized (owned, envelope-bounded), executionPhase=Degraded with the honest "needs a real Foundry endpoint" detail; launch=false tears the sandbox down (Idle); default (no execution) makes no sandbox (§20 gate holds). 874 controller tests pass, clippy -D warnings clean, fmt clean, zero drift. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- controller/src/kars_task.rs | 47 +++ controller/src/kars_task_execution.rs | 306 +++++++++++++++++++ controller/src/kars_task_reconciler.rs | 66 +++- controller/src/main.rs | 1 + deploy/helm/kars/templates/crd-karstask.yaml | 52 ++++ 5 files changed, 471 insertions(+), 1 deletion(-) create mode 100644 controller/src/kars_task_execution.rs diff --git a/controller/src/kars_task.rs b/controller/src/kars_task.rs index aada69f5d..7a26d8368 100644 --- a/controller/src/kars_task.rs +++ b/controller/src/kars_task.rs @@ -58,6 +58,7 @@ pub const TIER_MAX: i32 = 5; 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"}"# @@ -84,11 +85,38 @@ pub struct KarsTaskSpec { #[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, + /// Optional short label surfaced in CLI / UI listings. #[serde(default, skip_serializing_if = "Option::is_none")] pub display_name: 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. + #[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 @@ -427,6 +455,24 @@ pub struct KarsTaskStatus { /// 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)] @@ -485,6 +531,7 @@ mod tests { objective: "fix the flaky test in payments".into(), envelope: sample_envelope(), parent_ref: None, + execution: None, display_name: Some("payments-bugfix".into()), }; let yaml = serde_yaml::to_string(&spec).expect("serializes"); diff --git a/controller/src/kars_task_execution.rs b/controller/src/kars_task_execution.rs new file mode 100644 index 000000000..77b78b151 --- /dev/null +++ b/controller/src/kars_task_execution.rs @@ -0,0 +1,306 @@ +// 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, DynamicObject, ObjectMeta, Patch, PatchParams}; +use kube::core::ApiResource; +use kube::{Client, ResourceExt}; +use serde_json::json; + +use crate::kars_task::{KarsTask, 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, +} + +/// The owner reference making materialized resources cascade-delete with the +/// task and be server-side-apply-owned by this controller. +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, + }]) +} + +/// Runtime variant key for the sandbox spec discriminator. +fn runtime_variant_key(kind: &str) -> &'static str { + match kind { + "Hermes" => "hermes", + "OpenAIAgents" => "openaiAgents", + "MAF" => "maf", + _ => "openclaw", + } +} + +/// Materialize (or re-apply) the InferencePolicy + KarsSandbox for a launched +/// task, then read back the sandbox phase. Idempotent via server-side apply. +pub async fn materialize( + client: &Client, + namespace: &str, + task: &KarsTask, +) -> Result { + let task_name = task.name_any(); + let inference_name = format!("{task_name}-inference"); + let envelope = &task.spec.envelope; + let runtime_kind = task + .spec + .execution + .as_ref() + .and_then(|e| e.runtime.clone()) + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| "OpenClaw".to_string()); + + // 1. Minimal InferencePolicy scoped to this sandbox. Token budget mirrors + // the envelope when present (the router's TokenBudgetTracker enforces). + let mut inference_spec = json!({ + "appliesTo": { "sandboxName": task_name }, + }); + if let Some(tokens) = envelope.budget.as_ref().and_then(|b| b.tokens) + && tokens > 0 + { + inference_spec["tokenBudget"] = json!({ "dailyTokens": tokens }); + } + apply_dynamic( + client, + namespace, + &inference_policy_api_resource(), + &inference_name, + task, + inference_spec, + ) + .await?; + + // 2. KarsSandbox bounded by the envelope. Tool policy from the envelope is + // wired into governance; egress allow-list (when present) rides the + // existing per-sandbox egress machinery via the same-named ref. + let mut sandbox_spec = json!({ + "runtime": { + "kind": runtime_kind, + runtime_variant_key(&runtime_kind): {}, + }, + "inferenceRef": { "name": inference_name }, + "sandbox": { "isolation": "standard" }, + "networkPolicy": { "defaultDeny": true }, + }); + governance_block(envelope).inspect(|g| { + sandbox_spec["governance"] = g.clone(); + }); + apply_dynamic( + client, + namespace, + &sandbox_api_resource(), + &task_name, + task, + sandbox_spec, + ) + .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) => { + 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. +pub async fn teardown( + client: &Client, + namespace: &str, + task: &KarsTask, +) -> Result<(), kube::Error> { + use kube::api::DeleteParams; + 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()); + // Best-effort: ignore 404s. + let _ = sb_api.delete(&task_name, &DeleteParams::default()).await; + let _ = ip_api + .delete(&format!("{task_name}-inference"), &DeleteParams::default()) + .await; + Ok(()) +} + +/// Build the governance block from the envelope's tool-policy ref, if any. +fn governance_block(envelope: &TaskEnvelope) -> Option { + envelope.tool_policy_ref.as_ref().map(|r| { + json!({ + "enabled": true, + "toolPolicyRef": { "name": r.name }, + }) + }) +} + +/// 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}.")), + } +} + +/// Server-side-apply an owned dynamic object (spec only; status is the target +/// reconciler's). Idempotent — safe to call every reconcile. +async fn apply_dynamic( + client: &Client, + namespace: &str, + ar: &ApiResource, + name: &str, + task: &KarsTask, + spec: serde_json::Value, +) -> Result<(), kube::Error> { + 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()), + ])), + ..Default::default() + }; + obj.data = json!({ "spec": spec }); + api.patch( + name, + &PatchParams::apply(FIELD_MANAGER).force(), + &Patch::Apply(&obj), + ) + .await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::kars_task::TaskBudget; + + #[test] + fn runtime_variant_keys() { + assert_eq!(runtime_variant_key("OpenClaw"), "openclaw"); + assert_eq!(runtime_variant_key("Hermes"), "hermes"); + assert_eq!(runtime_variant_key("OpenAIAgents"), "openaiAgents"); + assert_eq!(runtime_variant_key("anything-else"), "openclaw"); + } + + #[test] + fn governance_block_present_only_with_tool_policy() { + let mut e = TaskEnvelope { + tier: 3, + authority_ceiling: 2, + delegation_depth: 1, + budget: Some(TaskBudget { + tokens: Some(1000), + usd_micros: None, + }), + tool_policy_ref: None, + egress_allowlist_ref: None, + }; + assert!(governance_block(&e).is_none()); + e.tool_policy_ref = Some(crate::mcp_server::LocalObjectRef { name: "tp".into() }); + let g = governance_block(&e).expect("present"); + assert_eq!(g["toolPolicyRef"]["name"], "tp"); + } + + #[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_reconciler.rs b/controller/src/kars_task_reconciler.rs index f09c833f6..275825d22 100644 --- a/controller/src/kars_task_reconciler.rs +++ b/controller/src/kars_task_reconciler.rs @@ -154,7 +154,7 @@ async fn reconcile(task: Arc, ctx: Arc) -> Result degraded_status( prior_ready, generation, @@ -209,6 +209,12 @@ async fn reconcile(task: Arc, ctx: Arc) -> Result { + 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}")); + } + } + } else { + // Not launched (or not Ready): ensure no sandbox lingers from a prior + // launch, and report Idle. + if task + .status + .as_ref() + .and_then(|s| s.sandbox_ref.as_ref()) + .is_some() + && let Err(e) = crate::kars_task_execution::teardown(client, ns, task).await + { + tracing::warn!(karstask = %task.name_any(), ns = %ns, error = %e, "KarsTask execution teardown failed"); + } + status.execution_phase = Some("Idle".to_string()); + status.sandbox_ref = None; + status.execution_detail = None; } } @@ -415,6 +478,7 @@ mod tests { ..TaskEnvelope::default() }, parent_ref: None, + execution: None, display_name: None, }, ); diff --git a/controller/src/main.rs b/controller/src/main.rs index 639b37e9e..6b9c94409 100644 --- a/controller/src/main.rs +++ b/controller/src/main.rs @@ -46,6 +46,7 @@ mod kars_memory_reconciler; 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; diff --git a/deploy/helm/kars/templates/crd-karstask.yaml b/deploy/helm/kars/templates/crd-karstask.yaml index 9b5ef76ea..9a0bbed16 100644 --- a/deploy/helm/kars/templates/crd-karstask.yaml +++ b/deploy/helm/kars/templates/crd-karstask.yaml @@ -24,6 +24,9 @@ spec: - jsonPath: .status.phase name: Phase type: string + - jsonPath: .status.executionPhase + name: Execution + type: string - jsonPath: .spec.envelope.delegationDepth name: Depth type: integer @@ -113,6 +116,30 @@ spec: - 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. + nullable: true + type: string + type: object objective: description: |- Human-readable statement of the task to be performed. This is the @@ -212,6 +239,22 @@ spec: envelope; recomputed whenever the spec changes. 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 @@ -231,6 +274,15 @@ spec: 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 From f5aa99537796be02e1afe3aafe74a36cbf6dc8f1 Mon Sep 17 00:00:00 2001 From: Pal Lakatos-Toth Date: Fri, 26 Jun 2026 18:14:22 +0200 Subject: [PATCH 04/23] fix(rbac): grant controller RBAC for KarsTask CRD MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The KarsTask reconciler (Bridge V0) needs cluster permission to get/list/watch/patch karstasks + status + finalizers. Without it the in-cluster controller ServiceAccount hits a 403 and the reconciler disables itself ("KarsTask CRD not installed — Forbidden"). Earlier kind tests ran the controller out-of-cluster (full kubeconfig), so RBAC was never exercised; running in-cluster surfaced the gap. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- deploy/helm/kars/templates/rbac.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/deploy/helm/kars/templates/rbac.yaml b/deploy/helm/kars/templates/rbac.yaml index acfdefd6a..c77b2817e 100644 --- a/deploy/helm/kars/templates/rbac.yaml +++ b/deploy/helm/kars/templates/rbac.yaml @@ -56,6 +56,9 @@ rules: - "karsauthconfigs/status" - "karssreactions" - "karssreactions/status" + - "karstasks" + - "karstasks/status" + - "karstasks/finalizers" verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] # Create and manage sandbox namespaces - apiGroups: [""] From b8f03a2cc7f8d7c1575e9236a61df494b65eff5d Mon Sep 17 00:00:00 2001 From: Pal Lakatos-Toth Date: Fri, 26 Jun 2026 19:43:08 +0200 Subject: [PATCH 05/23] =?UTF-8?q?feat(controller):=20Governance=20Receipt?= =?UTF-8?q?=20V0=20=E2=80=94=20signed=20DSSE/Ed25519=20attestation=20(Inc?= =?UTF-8?q?=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Governance Receipt is the auditor's moment: a signed, independently verifiable record that a KarsTask was governed under its trust envelope. Core (controller): - New KarsReceipt CRD (controller-written only; no admission CEL — integrity comes from the signature, not schema gates). - providers/signing.rs (allowlisted crypto): Ed25519 over DSSE PAE; loads/ persists a receipt identity Secret and publishes the public key to the out-of-band kars-receipt-pubkey ConfigMap (the verifier's trust anchor). - kars_receipt.rs: pure, deterministic in-toto Statement builder (no timestamps → idempotent emission, re-derivable by a verifier) + the honest claim matrix: integrity=PASS, conformance=PASS, completeness=PARTIAL (router token/cost audit chain is V1), regulatory=OMITTED (no external anchor in V0 local signing). - Reconciler: emits an owner-referenced KarsReceipt for every governance-Ready task; retracts it when the task is Degraded (no validated authority to attest). A child receipt records its attenuation of the parent. CLI (kars receipt verify / show): - Verifies the DSSE/Ed25519 signature against the published anchor (never a key embedded in the receipt), plus key-binding and envelope-binding, then prints the claim matrix. Works on a plain kars cluster, no Bridge required. - Unit tests cover the DSSE PAE framing and tamper/wrong-key rejection. Honest V0/V1 split: the plan puts the full emitter in the router (token/cost from the audit chain), which needs a real Foundry run; V0 ships the governance half in the controller and self-labels completeness/regulatory accordingly. Documented in the design note. Verified end-to-end on a kind kars-dev cluster: receipt emitted + CLI-verified for a Ready task; amplifying child Degraded with no receipt. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ci/no-custom-crypto.sh | 1 + cli/src/cli.ts | 4 +- cli/src/commands/receipt.test.ts | 119 +++++ cli/src/commands/receipt.ts | 372 +++++++++++++ controller/src/crd_validations.rs | 9 + controller/src/helm_drift.rs | 29 +- controller/src/kars_receipt.rs | 494 ++++++++++++++++++ controller/src/kars_task_reconciler.rs | 128 ++++- controller/src/main.rs | 1 + controller/src/providers/mod.rs | 4 + controller/src/providers/signing.rs | 313 +++++++++++ .../helm/kars/templates/crd-karsreceipt.yaml | 157 ++++++ deploy/helm/kars/templates/rbac.yaml | 3 + 13 files changed, 1631 insertions(+), 3 deletions(-) create mode 100644 cli/src/commands/receipt.test.ts create mode 100644 cli/src/commands/receipt.ts create mode 100644 controller/src/kars_receipt.rs create mode 100644 controller/src/providers/signing.rs create mode 100644 deploy/helm/kars/templates/crd-karsreceipt.yaml diff --git a/ci/no-custom-crypto.sh b/ci/no-custom-crypto.sh index d4ef2497e..2d008127a 100755 --- a/ci/no-custom-crypto.sh +++ b/ci/no-custom-crypto.sh @@ -18,6 +18,7 @@ cd "$REPO_ROOT" ALLOW_PATHS=( 'controller/src/providers/signing.rs' + '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..300646b00 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -30,6 +30,7 @@ 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 { migrateCommand } from "./commands/migrate.js"; import { toolPolicyCommand } from "./commands/toolpolicy.js"; import { inferencePolicyCommand } from "./commands/inferencepolicy.js"; @@ -100,6 +101,7 @@ export function createCli(): Command { // Attestation program.addCommand(attestCommand()); + program.addCommand(receiptCommand()); // Self-management program.addCommand(updateCommand()); @@ -113,7 +115,7 @@ 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 Quick start: diff --git a/cli/src/commands/receipt.test.ts b/cli/src/commands/receipt.test.ts new file mode 100644 index 000000000..92c6af4cf --- /dev/null +++ b/cli/src/commands/receipt.test.ts @@ -0,0 +1,119 @@ +// 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: { + 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); + }); +}); diff --git a/cli/src/commands/receipt.ts b/cli/src/commands/receipt.ts new file mode 100644 index 000000000..a7b70100c --- /dev/null +++ b/cli/src/commands/receipt.ts @@ -0,0 +1,372 @@ +// 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 { createPublicKey, verify as cryptoVerify } from "node:crypto"; + +const ANCHOR_NAMESPACE = "kars-system"; +const ANCHOR_CONFIGMAP = "kars-receipt-pubkey"; +const DSSE_PAYLOAD_TYPE = "application/vnd.in-toto+json"; +// 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; + + 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 }); + } + } + + // Binding: the signed subject digest must match the receipt's claimed + // envelopeDigest (sans the `sha256:` prefix the in-toto field drops). + const claimedDigest = spec.envelopeDigest ?? null; + if (statement && claimedDigest) { + const subj = (statement as { subject?: Array<{ digest?: { sha256?: string } }> }).subject; + const signedDigest = subj?.[0]?.digest?.sha256 ?? null; + const want = claimedDigest.replace(/^sha256:/, ""); + const bound = signedDigest === want; + checks.push({ + name: "envelopeBinding", + ok: bound, + detail: bound + ? `subject bound to envelope ${claimedDigest}` + : `subject digest '${signedDigest}' != claimed '${want}'`, + }); + } + + const ok = checks.length > 0 && checks.every((c) => c.ok); + return { + ok, + task, + namespace, + keyId: anchor.keyId, + envelopeDigest: claimedDigest, + checks, + claims: spec.claims ?? [], + 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", + ANCHOR_NAMESPACE, + ])) 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, + }; +} + +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 '${ANCHOR_NAMESPACE}'.\n` + + ` Cannot verify a receipt without the controller's published public key.\n`, + ), + ); + process.exit(5); + return; + } + + const result = verifyReceipt(receipt, anchor); + 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)); + }); + + return cmd; +} + +export const __test = { pae, verifyReceipt, importEd25519PublicKey }; diff --git a/controller/src/crd_validations.rs b/controller/src/crd_validations.rs index 0ba24b0ef..8e040507c 100644 --- a/controller/src/crd_validations.rs +++ b/controller/src/crd_validations.rs @@ -53,6 +53,7 @@ use crate::egress_approval::EgressApproval; use crate::inference_policy::InferencePolicy; 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; @@ -579,6 +580,14 @@ pub fn kars_task_crd() -> CustomResourceDefinition { .expect("kube-rs derive must produce a spec property on KarsTask") } +/// `KarsReceipt` CRD. The Governance Receipt is written solely by the +/// controller (never by users), so it carries no admission CEL rules — its +/// integrity comes from the DSSE/Ed25519 signature, not from schema gates. +#[must_use] +pub fn kars_receipt_crd() -> CustomResourceDefinition { + KarsReceipt::crd() +} + /// `TrustGraph.spec` CEL rules. Phase F1. /// /// 1. `vertices` must be non-empty (an empty graph yields a useless diff --git a/controller/src/helm_drift.rs b/controller/src/helm_drift.rs index 1602cc98f..88f95c343 100644 --- a/controller/src/helm_drift.rs +++ b/controller/src/helm_drift.rs @@ -33,7 +33,8 @@ #[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, kars_task_crd, mcp_server_crd, tool_policy_crd, trust_graph_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!( @@ -71,6 +72,11 @@ const KARSTASK_HELM_CRD_PATH: &str = concat!( "/../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 TRUSTGRAPH_HELM_CRD_PATH: &str = concat!( env!("CARGO_MANIFEST_DIR"), "/../deploy/helm/kars/templates/crd-trustgraph.yaml" @@ -288,6 +294,27 @@ mod tests { 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 trustgraph CRD. Run via: /// /// DUMP_TRUSTGRAPH_CRD_YAML=1 cargo test --bin kars-controller \ diff --git a/controller/src/kars_receipt.rs b/controller/src/kars_receipt.rs new file mode 100644 index 000000000..d444df962 --- /dev/null +++ b/controller/src/kars_receipt.rs @@ -0,0 +1,494 @@ +// 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 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":"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:` digest of the trust envelope the task ran under. Mirrors the + /// task's `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 { + /// 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, +} + +// ───────────────────────────────────────────────────────────────────── +// 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. kars truncates the envelope SHA-256 to 16 bytes for +/// compact status; the verifier compares the same truncated form. +#[derive(Debug, Serialize, Clone)] +pub struct SubjectDigest { + /// 32-hex-char (16-byte) truncated SHA-256 of the trust envelope. + 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, + pub conformance: PredicateConformance, + pub claims: Vec, + pub issuer: PredicateIssuer, +} + +#[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, +) -> Option { + let digest = status.envelope_digest.clone()?; + 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." + }; + 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", + "Covers governance facts (envelope, lineage, launch decision). The runtime token/cost audit chain emitted by the inference router is not yet bound into this receipt (V1).", + ), + 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()), + }, + conformance: PredicateConformance { + envelope_valid: true, + attenuates_parent, + }, + 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}"), + // Bind to the same truncated SHA-256 the envelope digest carries, + // stripping the `sha256:` algorithm prefix for the in-toto field. + 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, + } +} + +#[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("sha256:deadbeefdeadbeefdeadbeefdeadbeef".to_string()), + 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").is_none()); + } + + #[test] + fn root_statement_shape() { + let (task, status) = ready_task(false); + let st = build_statement(&task, &status, "kid123").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, "deadbeefdeadbeefdeadbeefdeadbeef"); + 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").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").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").unwrap()); + let b = canonical_json(&build_statement(&task, &status, "kid").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").unwrap(); + assert!(st.predicate.execution.launched); + assert_eq!(st.predicate.execution.phase.as_deref(), Some("Degraded")); + } +} diff --git a/controller/src/kars_task_reconciler.rs b/controller/src/kars_task_reconciler.rs index 275825d22..21df0ebeb 100644 --- a/controller/src/kars_task_reconciler.rs +++ b/controller/src/kars_task_reconciler.rs @@ -34,6 +34,8 @@ use crate::status::phase::{PHASE_DEGRADED, 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); @@ -94,6 +96,9 @@ fn check_envelope(task: &KarsTask) -> EnvelopeCheck { 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 { @@ -228,6 +233,13 @@ async fn reconcile(task: Arc, ctx: Arc) -> Result = Api::namespaced(client.clone(), ns); + + let Some(statement) = build_statement(task, status, &signer.key_id) 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; + }; + + 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; + } + + // Informational status echo (unsigned). Stamp issuance time on first write; + // observedTaskGeneration tracks freshness. + let status_patch = json!({ + "apiVersion": "kars.azure.com/v1alpha1", + "kind": "KarsReceipt", + "status": { + "issuedAt": chrono::Utc::now().to_rfc3339(), + "observedTaskGeneration": task.metadata.generation, + }, + }); + 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"); +} + /// True iff the task carries our cleanup finalizer. fn has_finalizer(task: &KarsTask) -> bool { task.metadata @@ -438,7 +552,19 @@ pub async fn run(client: Client) -> Result<()> { return Ok(()); } } - let ctx = Arc::new(Ctx { client }); + let signer = match crate::providers::signing::load_or_create(&client).await { + Ok(s) => { + tracing::info!(key_id = %s.key_id, "Governance Receipt signer ready"); + s + } + Err(e) => { + tracing::error!(error = %e, "failed to initialise receipt signer — KarsTask reconciler disabled"); + std::future::pending::<()>().await; + #[allow(unreachable_code)] + return Ok(()); + } + }; + let ctx = Arc::new(Ctx { client, signer }); Controller::new(tasks, crate::watch_config::bounded()) .run( |x, ctx| async move { diff --git a/controller/src/main.rs b/controller/src/main.rs index 6b9c94409..572347355 100644 --- a/controller/src/main.rs +++ b/controller/src/main.rs @@ -43,6 +43,7 @@ mod kars_eval_reconciler; mod kars_memory; mod kars_memory_compile; mod kars_memory_reconciler; +mod kars_receipt; mod kars_sre_action; mod kars_sre_action_reconciler; mod kars_task; 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..9f3799ec2 --- /dev/null +++ b/controller/src/providers/signing.rs @@ -0,0 +1,313 @@ +// 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}; + +use crate::mesh_peer::IDENTITY_NAMESPACE; + +/// 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()), + }], + } + } +} + +/// 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(), IDENTITY_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 => { + tracing::warn!("Receipt identity Secret malformed — regenerating"); + create_identity(&secrets).await? + } + } + } + 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": IDENTITY_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(), IDENTITY_NAMESPACE); + let cm: ConfigMap = serde_json::from_value(serde_json::json!({ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": { + "name": PUBKEY_CONFIGMAP_NAME, + "namespace": IDENTITY_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 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/deploy/helm/kars/templates/crd-karsreceipt.yaml b/deploy/helm/kars/templates/crd-karsreceipt.yaml new file mode 100644 index 000000000..4646e552a --- /dev/null +++ b/deploy/helm/kars/templates/crd-karsreceipt.yaml @@ -0,0 +1,157 @@ +--- +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: .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:` digest of the trust envelope the task ran under. Mirrors the + task's `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 + status: + description: |- + `KarsReceipt.status` — informational echo. The receipt's authority comes + from its signature, not from this block. + nullable: true + properties: + 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/rbac.yaml b/deploy/helm/kars/templates/rbac.yaml index c77b2817e..826765d3b 100644 --- a/deploy/helm/kars/templates/rbac.yaml +++ b/deploy/helm/kars/templates/rbac.yaml @@ -59,6 +59,9 @@ rules: - "karstasks" - "karstasks/status" - "karstasks/finalizers" + - "karsreceipts" + - "karsreceipts/status" + - "karsreceipts/finalizers" verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] # Create and manage sandbox namespaces - apiGroups: [""] From c72639257544b0116de14e766306d8fc4042e6ac Mon Sep 17 00:00:00 2001 From: Pal Lakatos-Toth Date: Fri, 26 Jun 2026 20:12:57 +0200 Subject: [PATCH 06/23] feat(controller): HITL approval primitive + receipt binding (Inc 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KarsApproval makes the autonomy tiers mean something: a priced/external/ irreversible action, a checkpoint, or a tier-raise becomes a human decision the fleet waits on — and the decision is recorded in the task's Governance Receipt. Core (controller): - New KarsApproval CRD + reconciler. The controller owns the authority binding: it copies the gated task's status.envelopeDigest into status.boundEnvelopeDigest on first observation and never changes it. If the task envelope later drifts, a still-pending approval goes Stale — you cannot grant authority against a moved target. - Pure, exhaustively-tested decision function: Pending → Approved/Denied (decidedAt/decider immutable) / Expired (past TTL) / Stale. A recorded human decision wins over expiry/staleness. Unknown verdicts fail closed. - Receipt closure (Inc 3 ↔ Inc 4): the in-toto predicate now carries the decided approvals (approve AND deny) for a task, deterministically ordered, so every steer is part of the signed record. The DSSE signature re-verifies over the enriched payload. CLI (kars approval list/approve/deny/show): - Plain-cluster parity for the steering primitive; approve/deny patch spec.decision, the controller drives the transition. The reconciler RECORDS decisions; acting on an approved action (actually widening egress, raising the tier) is the consuming reconciler's job and is the V1 wire — documented honestly in the design note. Verified end-to-end on kind kars-dev: bind, approve (CLI + UI), deny, TTL/staleness on envelope drift, and all three decisions bound into a re-verifiable receipt. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/src/cli.ts | 5 + cli/src/commands/approval.test.ts | 43 ++ cli/src/commands/approval.ts | 207 ++++++++ controller/src/crd_validations.rs | 9 + controller/src/helm_drift.rs | 32 +- controller/src/kars_approval.rs | 360 ++++++++++++++ controller/src/kars_approval_reconciler.rs | 444 ++++++++++++++++++ controller/src/kars_receipt.rs | 135 +++++- controller/src/kars_task_reconciler.rs | 22 +- controller/src/main.rs | 9 + .../helm/kars/templates/crd-karsapproval.yaml | 201 ++++++++ deploy/helm/kars/templates/rbac.yaml | 3 + 12 files changed, 1458 insertions(+), 12 deletions(-) create mode 100644 cli/src/commands/approval.test.ts create mode 100644 cli/src/commands/approval.ts create mode 100644 controller/src/kars_approval.rs create mode 100644 controller/src/kars_approval_reconciler.rs create mode 100644 deploy/helm/kars/templates/crd-karsapproval.yaml diff --git a/cli/src/cli.ts b/cli/src/cli.ts index 300646b00..63940a39f 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -31,6 +31,7 @@ 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"; @@ -106,6 +107,9 @@ export function createCli(): Command { // Self-management program.addCommand(updateCommand()); + // Steering + program.addCommand(approvalCommand()); + program.addHelpText("after", ` Command groups: Lifecycle up, dev, add, push, destroy @@ -117,6 +121,7 @@ Command groups: Governance toolpolicy, inferencepolicy, mcp, memory 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/controller/src/crd_validations.rs b/controller/src/crd_validations.rs index 8e040507c..807d38377 100644 --- a/controller/src/crd_validations.rs +++ b/controller/src/crd_validations.rs @@ -52,6 +52,7 @@ use crate::a2a_agent::A2AAgent; use crate::egress_approval::EgressApproval; use crate::inference_policy::InferencePolicy; use crate::kars_eval::KarsEval; +use crate::kars_approval::KarsApproval; use crate::kars_memory::KarsMemory; use crate::kars_receipt::KarsReceipt; use crate::kars_sre_action::KarsSREAction; @@ -588,6 +589,14 @@ pub fn kars_receipt_crd() -> CustomResourceDefinition { KarsReceipt::crd() } +/// `KarsApproval` CRD. The HITL approval primitive carries no admission CEL in +/// V0 — the controller is the sole writer of `status` (the binding, phase, and +/// immutable timestamps), and `spec.decision` is a human steer, not a gate. +#[must_use] +pub fn kars_approval_crd() -> CustomResourceDefinition { + KarsApproval::crd() +} + /// `TrustGraph.spec` CEL rules. Phase F1. /// /// 1. `vertices` must be non-empty (an empty graph yields a useless diff --git a/controller/src/helm_drift.rs b/controller/src/helm_drift.rs index 88f95c343..385990087 100644 --- a/controller/src/helm_drift.rs +++ b/controller/src/helm_drift.rs @@ -32,9 +32,9 @@ #[cfg(test)] use crate::crd_validations::{ - a2a_agent_crd, egress_approval_crd, inference_policy_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, + 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!( @@ -77,6 +77,11 @@ const KARSRECEIPT_HELM_CRD_PATH: &str = concat!( "/../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" @@ -315,6 +320,27 @@ mod tests { 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..3223debc6 --- /dev/null +++ b/controller/src/kars_approval.rs @@ -0,0 +1,360 @@ +// 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 human decision wins over expiry/staleness: if a person decided, that is +//! the governance truth and it is recorded. + +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")] + 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`. + pub kind: String, + + /// One-line, human-readable statement of what the agent wants to do. + pub summary: String, + + /// Optional longer detail (e.g. the exact tool args or egress host). + #[serde(default, skip_serializing_if = "Option::is_none")] + 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`. + pub verdict: String, + + /// Identity of the human (or delegated principal) who decided. Recorded + /// verbatim into status and, for granted approvals, into the receipt. + pub decider: String, + + /// Optional justification, surfaced to auditors. + #[serde(default, skip_serializing_if = "Option::is_none")] + 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 time the controller first reconciled the request. 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 envelope digest this approval is bound to. Set once by the + /// controller from the task's `status.envelopeDigest`; never changes. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bound_envelope_digest: 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. +/// +/// Precedence: +/// 1. A recorded human decision wins over everything (it is the governance +/// truth, even if the request later expired or went stale). +/// 2. Otherwise, an unbound approval is `Pending` (awaiting the task envelope). +/// 3. A bound approval whose task digest drifted (or whose task vanished) is +/// `Stale`. +/// 4. A bound, current approval past its TTL is `Expired`. +/// 5. Otherwise `Pending` (awaiting a decision). +pub fn evaluate( + decision: Option<&ApprovalDecision>, + bound_digest: Option<&str>, + live_task_digest: Option<&str>, + expired: bool, +) -> ApprovalOutcome { + if let Some(d) = decision { + 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 { + 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(_) if expired => ApprovalOutcome::Expired, + Some(_) => ApprovalOutcome::Pending("awaiting a human decision"), + } +} + +#[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 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 decision_wins_over_expiry_and_staleness() { + // Expired + drifted, but a human decided → the decision stands. + let out = evaluate(Some(&decision("approve")), Some("sha256:aa"), Some("sha256:bb"), true); + assert_eq!(out.phase(), PHASE_APPROVED); + } + + #[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..c5fceb5a9 --- /dev/null +++ b/controller/src/kars_approval_reconciler.rs @@ -0,0 +1,444 @@ +// 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}; +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": { "finalizers": drop_finalizer(&approval) }, + }); + approvals + .patch( + &name, + &PatchParams::apply(FIELD_MANAGER).force(), + &Patch::Apply(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": { "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(); + + // 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_digest = tasks + .get_opt(&approval.spec.task_ref.name) + .await? + .and_then(|t| t.status.and_then(|s| s.envelope_digest)); + + // 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 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)) + .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 = evaluate( + approval.spec.decision.as_ref(), + bound_digest.as_deref(), + live_task_digest.as_deref(), + expired, + ); + + let new_status = build_status( + &prior, + generation, + &outcome, + requested_at, + expires_at, + bound_digest, + now, + ); + + let terminal = outcome.is_terminal(); + + let status_patch = json!({ + "apiVersion": "kars.azure.com/v1alpha1", + "kind": "KarsApproval", + "status": new_status, + }); + approvals + .patch_status( + &name, + &PatchParams::apply(FIELD_MANAGER).force(), + &Patch::Apply(status_patch), + ) + .await?; + + tracing::debug!(karsapproval = %name, ns = %ns, phase = outcome.phase(), "KarsApproval reconciled"); + + Ok(Action::requeue(if terminal { + REQUEUE_TERMINAL + } else { + REQUEUE_PENDING + })) +} + +/// 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()), + 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 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 index d444df962..b43c93a23 100644 --- a/controller/src/kars_receipt.rs +++ b/controller/src/kars_receipt.rs @@ -176,11 +176,31 @@ pub struct Predicate { pub lineage: Vec, pub delegation: PredicateDelegation, pub execution: PredicateExecution, + /// The human decisions (HITL approvals) recorded for this task — every + /// steer is itself part of the signed record. Empty when none were taken. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub approvals: Vec, pub conformance: PredicateConformance, pub claims: Vec, pub issuer: PredicateIssuer, } +/// 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, +} + #[derive(Debug, Serialize, Clone)] pub struct PredicateTask { pub namespace: String, @@ -251,6 +271,7 @@ pub fn build_statement( task: &KarsTask, status: &KarsTaskStatus, key_id: &str, + approvals: &[PredicateApproval], ) -> Option { let digest = status.envelope_digest.clone()?; let namespace = task @@ -323,6 +344,7 @@ pub fn build_statement( phase: status.execution_phase.clone(), sandbox_ref: status.sandbox_ref.as_ref().map(|r| r.name.clone()), }, + approvals: approvals.to_vec(), conformance: PredicateConformance { envelope_valid: true, attenuates_parent, @@ -380,6 +402,39 @@ pub fn build_spec( } } +/// Convert decided `KarsApproval`s for a task into deterministic receipt +/// facts. Only **Approved** or **Denied** approvals (a real human decision) +/// are included; Pending/Expired/Stale ones are not part of the attested +/// human-decision record. Sorted by name so the signed payload is stable. +pub fn approval_facts(approvals: &[crate::kars_approval::KarsApproval]) -> Vec { + use crate::kars_approval::{PHASE_APPROVED, PHASE_DENIED}; + use kube::ResourceExt; + + let mut facts: Vec = approvals + .iter() + .filter_map(|a| { + let status = a.status.as_ref()?; + let phase = status.phase.as_deref()?; + let verdict = match phase { + PHASE_APPROVED => "approve", + PHASE_DENIED => "deny", + _ => return None, + }; + Some(PredicateApproval { + name: a.name_any(), + action_kind: a.spec.action.kind.clone(), + summary: a.spec.action.summary.clone(), + verdict: verdict.to_string(), + decider: status.decider.clone().unwrap_or_default(), + decided_at: status.decided_at.clone().unwrap_or_default(), + requested_tier: a.spec.action.requested_tier, + }) + }) + .collect(); + facts.sort_by(|a, b| a.name.cmp(&b.name)); + facts +} + #[cfg(test)] mod tests { use super::*; @@ -425,13 +480,13 @@ mod tests { fn no_receipt_without_digest() { let (task, mut status) = ready_task(false); status.envelope_digest = None; - assert!(build_statement(&task, &status, "kid").is_none()); + assert!(build_statement(&task, &status, "kid", &[]).is_none()); } #[test] fn root_statement_shape() { let (task, status) = ready_task(false); - let st = build_statement(&task, &status, "kid123").unwrap(); + let st = build_statement(&task, &status, "kid123", &[]).unwrap(); assert_eq!(st.typ, STATEMENT_TYPE); assert_eq!(st.predicate_type, PREDICATE_TYPE); assert_eq!(st.subject[0].name, "kars-system/demo"); @@ -445,7 +500,7 @@ mod tests { #[test] fn child_statement_records_attenuation_and_lineage() { let (task, status) = ready_task(true); - let st = build_statement(&task, &status, "kid").unwrap(); + let st = build_statement(&task, &status, "kid", &[]).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); @@ -456,7 +511,7 @@ mod tests { #[test] fn claim_matrix_is_honest() { let (task, status) = ready_task(false); - let st = build_statement(&task, &status, "kid").unwrap(); + let st = build_statement(&task, &status, "kid", &[]).unwrap(); let by = |c: &str| { st.predicate .claims @@ -475,8 +530,8 @@ mod tests { #[test] fn canonical_json_is_stable() { let (task, status) = ready_task(true); - let a = canonical_json(&build_statement(&task, &status, "kid").unwrap()); - let b = canonical_json(&build_statement(&task, &status, "kid").unwrap()); + let a = canonical_json(&build_statement(&task, &status, "kid", &[]).unwrap()); + let b = canonical_json(&build_statement(&task, &status, "kid", &[]).unwrap()); assert_eq!(a, b); // Sanity: it really is the in-toto envelope. let s = String::from_utf8(a).unwrap(); @@ -487,8 +542,74 @@ mod tests { #[test] fn launched_execution_is_recorded() { let (task, status) = ready_task(false); - let st = build_statement(&task, &status, "kid").unwrap(); + let st = build_statement(&task, &status, "kid", &[]).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).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)); + // The signed payload carries the human decision. + 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, 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: None, + }, + ); + a.status = Some(KarsApprovalStatus { + phase: phase.map(|s| s.to_string()), + decider: decider.map(|s| s.to_string()), + decided_at: decider.map(|_| "2026-06-26T10:00:00+00:00".to_string()), + ..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); + // Only the two decided ones, sorted by name. + 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"); + } } diff --git a/controller/src/kars_task_reconciler.rs b/controller/src/kars_task_reconciler.rs index 21df0ebeb..3e9f3fd40 100644 --- a/controller/src/kars_task_reconciler.rs +++ b/controller/src/kars_task_reconciler.rs @@ -426,12 +426,30 @@ async fn reconcile_receipt( status: &KarsTaskStatus, signer: &crate::providers::signing::ReceiptSigner, ) { - use crate::kars_receipt::{KarsReceipt, build_spec, build_statement, canonical_json}; + use crate::kars_approval::KarsApproval; + use crate::kars_receipt::{KarsReceipt, approval_facts, build_spec, build_statement, canonical_json}; let name = task.name_any(); let receipts: Api = Api::namespaced(client.clone(), ns); - let Some(statement) = build_statement(task, status, &signer.key_id) else { + // 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 approvals: Api = Api::namespaced(client.clone(), ns); + let task_approvals = match approvals.list(&ListParams::default()).await { + Ok(list) => list + .items + .into_iter() + .filter(|a| a.spec.task_ref.name == name) + .collect::>(), + Err(e) => { + tracing::debug!(karstask = %name, ns = %ns, error = %e, "could not list KarsApprovals for receipt"); + Vec::new() + } + }; + let facts = approval_facts(&task_approvals); + + let Some(statement) = build_statement(task, status, &signer.key_id, &facts) else { // No digest → no receipt. Retract any prior one. match receipts .delete(&name, &kube::api::DeleteParams::default()) diff --git a/controller/src/main.rs b/controller/src/main.rs index 572347355..28adecc3f 100644 --- a/controller/src/main.rs +++ b/controller/src/main.rs @@ -38,6 +38,8 @@ 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; @@ -252,6 +254,10 @@ async fn main() -> Result<()> { 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 }) @@ -407,6 +413,9 @@ async fn main() -> Result<()> { res = kars_task_handle => { res??; } + res = kars_approval_handle => { + res??; + } res = trust_graph_handle => { res??; } diff --git a/deploy/helm/kars/templates/crd-karsapproval.yaml b/deploy/helm/kars/templates/crd-karsapproval.yaml new file mode 100644 index 000000000..221f7239f --- /dev/null +++ b/deploy/helm/kars/templates/crd-karsapproval.yaml @@ -0,0 +1,201 @@ +--- +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). + 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`. + 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. + 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. + type: string + reason: + description: Optional justification, surfaced to auditors. + nullable: true + type: string + verdict: + description: '`approve` or `deny`.' + 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: + 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. + nullable: true + type: string + required: + - action + - taskRef + type: object + status: + description: '`KarsApproval.status` — the controller is the sole writer.' + nullable: true + properties: + boundEnvelopeDigest: + description: |- + The task envelope digest this approval is bound to. Set once by the + controller from the task's `status.envelopeDigest`; never changes. + 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 time the controller first reconciled the request. 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/rbac.yaml b/deploy/helm/kars/templates/rbac.yaml index 826765d3b..ee24faef8 100644 --- a/deploy/helm/kars/templates/rbac.yaml +++ b/deploy/helm/kars/templates/rbac.yaml @@ -62,6 +62,9 @@ rules: - "karsreceipts" - "karsreceipts/status" - "karsreceipts/finalizers" + - "karsapprovals" + - "karsapprovals/status" + - "karsapprovals/finalizers" verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] # Create and manage sandbox namespaces - apiGroups: [""] From acbdf43bab5087e52abee936b2de22fefdd65823 Mon Sep 17 00:00:00 2001 From: Pal Lakatos-Toth Date: Fri, 26 Jun 2026 21:15:23 +0200 Subject: [PATCH 07/23] feat(controller,router): completeness floor + receipt inclusion log + metering (Inc 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three parallel hardening deliverables that strengthen the Governance Receipt without overclaiming. Completeness floor: - New CREATE-time kars-task-namespace-floor ValidatingAdmissionPolicy denies hostNetwork/hostPID/hostIPC, privileged, allowPrivilegeEscalation, ephemeralContainers, and hostPath in isolated=strict namespaces — closing the gap the UPDATE-only posture-lock leaves, so the receipt's no-bypass claim holds against a compromised controller / direct kubectl apply. - The reconciler observes which floor controls are enforced (the new VAP, exec-ban VAP, posture-lock VAP, default-deny egress) and binds them into the receipt predicate. The completeness claim stays honestly PARTIAL (runtime iptables hash + token/cost = V1, eBPF witness = V2, all named), but its detail now reflects the concrete enforced controls. Receipt inclusion log (self-hosted-Rekor precursor): - Every emitted receipt is entered in a hash-chained kars-receipt-log ConfigMap. `kars receipt verify` now also checks chain integrity + inclusion; `kars receipt log` shows/verifies the chain. Gives cross-receipt tamper-evidence (deleting/altering/reordering breaks the chain). Labelled operator-controlled — does NOT give operator-non-repudiation (external witness + KMS-attested signing are V2); regulatory stays OMITTED. Metering attribution (efficiency pillar plumbing): - The task reconciler stamps task-id + lineage-root annotations on the materialized sandbox; the main reconciler forwards them as KARS_TASK_ID/KARS_TASK_ROOT; the router emits kars_task_tokens_total {task,root_task,model,direction} so cost rolls up per task branch. Bounded cardinality — only task sandboxes emit the series. Live token numbers need a Foundry run (the same honest boundary as V0.1b). Verified end-to-end on kind kars-dev: VAP denies each escape vector + honours break-glass; receipt carries the enforced-controls evidence; inclusion chain populates, a tampered chain fails verification and a restored one passes; non-task sandbox correctly omits the task metric labels. Wave-2 KMS-attested key-release (SKR/MAA), external witness, and the eBPF datapath witness remain hardware/partner-gated and seam-ready — not faked on kind, per the product's honesty discipline. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ci/no-custom-crypto.sh | 1 + cli/src/commands/receipt.test.ts | 40 +++ cli/src/commands/receipt.ts | 149 ++++++++- controller/src/kars_receipt.rs | 140 +++++++- controller/src/kars_receipt_log.rs | 310 ++++++++++++++++++ controller/src/kars_task_execution.rs | 17 + controller/src/kars_task_reconciler.rs | 81 ++++- controller/src/main.rs | 1 + controller/src/reconciler/mod.rs | 15 + .../admission-task-namespace-floor.yaml | 121 +++++++ .../helm/kars/templates/crd-karsreceipt.yaml | 13 + deploy/helm/kars/templates/rbac.yaml | 6 + deploy/helm/kars/values.yaml | 12 + inference-router/src/metrics.rs | 91 +++++ inference-router/src/proxy.rs | 34 +- 15 files changed, 990 insertions(+), 41 deletions(-) create mode 100644 controller/src/kars_receipt_log.rs create mode 100644 deploy/helm/kars/templates/admission-task-namespace-floor.yaml diff --git a/ci/no-custom-crypto.sh b/ci/no-custom-crypto.sh index 2d008127a..8e282611c 100755 --- a/ci/no-custom-crypto.sh +++ b/ci/no-custom-crypto.sh @@ -18,6 +18,7 @@ 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 diff --git a/cli/src/commands/receipt.test.ts b/cli/src/commands/receipt.test.ts index 92c6af4cf..92b0a3d0a 100644 --- a/cli/src/commands/receipt.test.ts +++ b/cli/src/commands/receipt.test.ts @@ -117,3 +117,43 @@ describe("receipt verify — verifyReceipt", () => { expect(res.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(); + }); +}); diff --git a/cli/src/commands/receipt.ts b/cli/src/commands/receipt.ts index a7b70100c..7280c7ecc 100644 --- a/cli/src/commands/receipt.ts +++ b/cli/src/commands/receipt.ts @@ -27,10 +27,11 @@ import { Command } from "commander"; import chalk from "chalk"; -import { createPublicKey, verify as cryptoVerify } from "node:crypto"; +import { createHash, createPublicKey, verify as cryptoVerify } from "node:crypto"; const ANCHOR_NAMESPACE = "kars-system"; const ANCHOR_CONFIGMAP = "kars-receipt-pubkey"; +const LOG_CONFIGMAP = "kars-receipt-log"; const DSSE_PAYLOAD_TYPE = "application/vnd.in-toto+json"; // 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 @@ -241,6 +242,96 @@ async function fetchAnchor(): Promise { }; } +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", + ANCHOR_NAMESPACE, + ])) as { data?: Record } | null; + const raw = cm?.data?.["chain.json"]; + if (!raw) return null; + try { + return JSON.parse(raw) as InclusionEntry[]; + } catch { + return null; + } +} + +/** + * 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": @@ -333,6 +424,14 @@ export function receiptCommand(): Command { } 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)); + result.ok = result.ok && result.checks.every((c) => c.ok); + } + if (options.format === "json") { console.log(JSON.stringify(result, null, 2)); } else { @@ -366,7 +465,53 @@ export function receiptCommand(): Command { 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 ${ANCHOR_NAMESPACE}). ` + + `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); + }); + return cmd; } -export const __test = { pae, verifyReceipt, importEd25519PublicKey }; +export const __test = { + pae, + verifyReceipt, + importEd25519PublicKey, + inclusionEntryHash, + verifyInclusionChain, +}; \ No newline at end of file diff --git a/controller/src/kars_receipt.rs b/controller/src/kars_receipt.rs index b43c93a23..9ece3622d 100644 --- a/controller/src/kars_receipt.rs +++ b/controller/src/kars_receipt.rs @@ -132,6 +132,16 @@ pub struct KarsReceiptStatus { /// 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, } // ───────────────────────────────────────────────────────────────────── @@ -181,10 +191,45 @@ pub struct Predicate { #[serde(skip_serializing_if = "Vec::is_empty")] pub approvals: 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, } +/// The enforced-controls evidence behind the `completeness` claim. Every field +/// is an observation the controller can verify from cluster state, so an +/// auditor can re-derive it. The *runtime* iptables-ruleset hash and the eBPF +/// kernel-datapath witness are deliberately absent in V0 (named V1/V2 in the +/// claim detail) — we never imply we captured them. +#[derive(Debug, Serialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct PredicateCompleteness { + /// The CREATE-time task-namespace floor VAP is installed. + pub task_namespace_floor_vap: bool, + /// The exec/attach ban VAP is installed. + pub exec_ban_vap: bool, + /// The posture-lock (UPDATE downgrade) VAP is installed. + pub posture_lock_vap: bool, + /// A cluster-default-deny egress NetworkPolicy is installed. + pub default_deny_egress: bool, + /// `true` once **all** of the above floor controls are present. + 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)] @@ -272,6 +317,7 @@ pub fn build_statement( status: &KarsTaskStatus, key_id: &str, approvals: &[PredicateApproval], + completeness: PredicateCompleteness, ) -> Option { let digest = status.envelope_digest.clone()?; let namespace = task @@ -300,6 +346,15 @@ pub fn build_statement( } else { "Trust envelope validated; root task with no delegation to attenuate." }; + // The completeness claim stays PARTIAL in V0 (the runtime iptables-ruleset + // hash, the token/cost audit chain, and the eBPF witness are not yet + // bound), but its detail now reflects *which* enforced floor controls the + // controller actually observed — concrete, re-derivable, never overstated. + 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", @@ -307,11 +362,7 @@ pub fn build_statement( "DSSE/Ed25519 signature binds this payload to the trust-envelope digest.", ), Claim::new("conformance", "PASS", conformance_detail), - Claim::new( - "completeness", - "PARTIAL", - "Covers governance facts (envelope, lineage, launch decision). The runtime token/cost audit chain emitted by the inference router is not yet bound into this receipt (V1).", - ), + Claim::new("completeness", "PARTIAL", completeness_detail), Claim::new( "regulatory", "OMITTED", @@ -349,6 +400,7 @@ pub fn build_statement( envelope_valid: true, attenuates_parent, }, + completeness, claims: claims.clone(), issuer: PredicateIssuer { component: "kars-controller".to_string(), @@ -480,13 +532,13 @@ mod tests { fn no_receipt_without_digest() { let (task, mut status) = ready_task(false); status.envelope_digest = None; - assert!(build_statement(&task, &status, "kid", &[]).is_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", &[]).unwrap(); + 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"); @@ -500,7 +552,7 @@ mod tests { #[test] fn child_statement_records_attenuation_and_lineage() { let (task, status) = ready_task(true); - let st = build_statement(&task, &status, "kid", &[]).unwrap(); + 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); @@ -511,7 +563,7 @@ mod tests { #[test] fn claim_matrix_is_honest() { let (task, status) = ready_task(false); - let st = build_statement(&task, &status, "kid", &[]).unwrap(); + let st = build_statement(&task, &status, "kid", &[], PredicateCompleteness::default().with_rollup()).unwrap(); let by = |c: &str| { st.predicate .claims @@ -530,8 +582,8 @@ mod tests { #[test] fn canonical_json_is_stable() { let (task, status) = ready_task(true); - let a = canonical_json(&build_statement(&task, &status, "kid", &[]).unwrap()); - let b = canonical_json(&build_statement(&task, &status, "kid", &[]).unwrap()); + 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(); @@ -542,7 +594,7 @@ mod tests { #[test] fn launched_execution_is_recorded() { let (task, status) = ready_task(false); - let st = build_statement(&task, &status, "kid", &[]).unwrap(); + 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")); } @@ -559,7 +611,7 @@ mod tests { decided_at: "2026-06-26T10:00:00+00:00".to_string(), requested_tier: Some(4), }]; - let st = build_statement(&task, &status, "kid", &approvals).unwrap(); + 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)); @@ -612,4 +664,66 @@ mod tests { assert_eq!(facts[1].name, "zebra"); assert_eq!(facts[1].verdict, "approve"); } + + #[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_log.rs b/controller/src/kars_receipt_log.rs new file mode 100644 index 000000000..b056f7a8c --- /dev/null +++ b/controller/src/kars_receipt_log.rs @@ -0,0 +1,310 @@ +// 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::mesh_peer::IDENTITY_NAMESPACE; + +/// ConfigMap holding the hash-chained inclusion log. +pub const LOG_CONFIGMAP_NAME: &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"; +/// 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(), IDENTITY_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": IDENTITY_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": IDENTITY_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)") +} + +#[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")); + } +} diff --git a/controller/src/kars_task_execution.rs b/controller/src/kars_task_execution.rs index 77b78b151..520ac73a8 100644 --- a/controller/src/kars_task_execution.rs +++ b/controller/src/kars_task_execution.rs @@ -116,6 +116,7 @@ pub async fn materialize( &inference_name, task, inference_spec, + None, ) .await?; @@ -134,6 +135,19 @@ pub async fn materialize( governance_block(envelope).inspect(|g| { sandbox_spec["governance"] = g.clone(); }); + // 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, @@ -141,6 +155,7 @@ pub async fn materialize( &task_name, task, sandbox_spec, + Some(attribution), ) .await?; @@ -233,6 +248,7 @@ async fn apply_dynamic( name: &str, task: &KarsTask, spec: serde_json::Value, + annotations: Option>, ) -> Result<(), kube::Error> { let api: Api = Api::namespaced_with(client.clone(), namespace, ar); let mut obj = DynamicObject::new(name, ar).within(namespace); @@ -247,6 +263,7 @@ async fn apply_dynamic( ), ("kars.azure.com/karstask".to_string(), task.name_any()), ])), + annotations, ..Default::default() }; obj.data = json!({ "spec": spec }); diff --git a/controller/src/kars_task_reconciler.rs b/controller/src/kars_task_reconciler.rs index 3e9f3fd40..fc1162c0b 100644 --- a/controller/src/kars_task_reconciler.rs +++ b/controller/src/kars_task_reconciler.rs @@ -449,7 +449,13 @@ async fn reconcile_receipt( }; let facts = approval_facts(&task_approvals); - let Some(statement) = build_statement(task, status, &signer.key_id, &facts) else { + // 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(client).await; + + let Some(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()) @@ -505,15 +511,33 @@ async fn reconcile_receipt( 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) => 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. + // 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": { - "issuedAt": chrono::Utc::now().to_rfc3339(), - "observedTaskGeneration": task.metadata.generation, - }, + "status": status_obj, }); if let Err(e) = receipts .patch_status( @@ -529,6 +553,51 @@ async fn reconcile_receipt( tracing::info!(karstask = %name, ns = %ns, key_id = %signer.key_id, "Governance Receipt emitted"); } +/// Observe which completeness-floor controls (design note §24b) are enforced +/// on the cluster, for binding into the receipt. Best-effort: any read error +/// yields a conservative `false` (we never claim a control is enforced unless +/// we positively observed it). The runtime egress-guard iptables hash and the +/// eBPF witness are intentionally NOT gathered here — they are V1/V2. +async fn gather_completeness(client: &kube::Client) -> crate::kars_receipt::PredicateCompleteness { + use k8s_openapi::api::admissionregistration::v1::ValidatingAdmissionPolicy; + use k8s_openapi::api::networking::v1::NetworkPolicy; + + let vaps: Api = Api::all(client.clone()); + let vap_present = |name: &str, list: &[ValidatingAdmissionPolicy]| -> bool { + list.iter().any(|p| p.metadata.name.as_deref() == Some(name)) + }; + let vap_list = vaps + .list(&ListParams::default()) + .await + .map(|l| l.items) + .unwrap_or_default(); + + // A cluster-wide default-deny egress NetworkPolicy is installed by the + // operator chart in kars-system; treat its presence there as the floor. + let nps: Api = Api::namespaced(client.clone(), "kars-system"); + let default_deny_egress = nps + .list(&ListParams::default()) + .await + .map(|l| { + l.items.iter().any(|np| { + np.spec + .as_ref() + .and_then(|s| s.policy_types.as_ref()) + .is_some_and(|t| t.iter().any(|pt| pt == "Egress")) + }) + }) + .unwrap_or(false); + + crate::kars_receipt::PredicateCompleteness { + task_namespace_floor_vap: vap_present("kars-task-namespace-floor", &vap_list), + exec_ban_vap: vap_present("kars-sandbox-exec-ban", &vap_list), + posture_lock_vap: vap_present("kars-sandbox-posture-lock", &vap_list), + default_deny_egress, + floor_enforced: false, + } + .with_rollup() +} + /// True iff the task carries our cleanup finalizer. fn has_finalizer(task: &KarsTask) -> bool { task.metadata diff --git a/controller/src/main.rs b/controller/src/main.rs index 28adecc3f..0c7884e6e 100644 --- a/controller/src/main.rs +++ b/controller/src/main.rs @@ -46,6 +46,7 @@ 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; 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). +*/}} +{{- if .Values.admission.taskNamespaceFloor.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-karsreceipt.yaml b/deploy/helm/kars/templates/crd-karsreceipt.yaml index 4646e552a..01508715a 100644 --- a/deploy/helm/kars/templates/crd-karsreceipt.yaml +++ b/deploy/helm/kars/templates/crd-karsreceipt.yaml @@ -136,6 +136,19 @@ spec: from its signature, not from this block. nullable: true properties: + 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 diff --git a/deploy/helm/kars/templates/rbac.yaml b/deploy/helm/kars/templates/rbac.yaml index ee24faef8..b2bc4380a 100644 --- a/deploy/helm/kars/templates/rbac.yaml +++ b/deploy/helm/kars/templates/rbac.yaml @@ -100,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 64e7c8c3e..44803804e 100644 --- a/deploy/helm/kars/values.yaml +++ b/deploy/helm/kars/values.yaml @@ -217,6 +217,18 @@ admission: # handled by the controller's own pod template). # Requires Kubernetes >= 1.30 (VAP GA). enabled: true + taskNamespaceFloor: + # 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/inference-router/src/metrics.rs b/inference-router/src/metrics.rs index faeb00f53..d6e22d7ae 100644 --- a/inference-router/src/metrics.rs +++ b/inference-router/src/metrics.rs @@ -51,6 +51,54 @@ 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 +417,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); } } } From a4df8f91d7bbb41201660db9af90179f40f4f0f8 Mon Sep 17 00:00:00 2001 From: Pal Lakatos-Toth Date: Fri, 26 Jun 2026 21:32:21 +0200 Subject: [PATCH 08/23] feat(controller,cli): signed checkpoint (signed tree head) for the receipt log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delivers the verifiable operator-side half of the V2 witness story for the receipt inclusion log. On each receipt emission the controller publishes a signed checkpoint — an Ed25519-signed note over (origin, tree_size, head_hash), Go-sumdb signed-note style — to the kars-receipt-checkpoint ConfigMap, via the allowlisted providers::signing::sign_note. The head hash of a hash chain already commits to the entire prefix, so a checkpoint over it is a sound signed tree head. - `kars receipt verify` now also validates the checkpoint (signature vs anchor + agreement with the live log). - `kars receipt checkpoint` verifies it standalone and prints the pinnable root. - The Bridge surfaces the checkpoint in the receipt panel. Why it matters: the bare chain gives tamper-evidence but the operator could rewrite the *whole* chain silently. A pinned signed checkpoint detects a silent history rewrite even when the rewritten chain is internally intact — verified live: truncating the log to a valid shorter chain passes `receipt log`, but the pinned checkpoint reports "size 6 diverges from live 5 — history may have been rewritten." Still V2 (genuinely partner/hardware-gated): an external party counter-signing/gossiping these checkpoints (non-equivocation) and hardware-attested signing. The checkpoint gives clients something concrete to pin; the external witness is the remaining step. regulatory stays OMITTED. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/src/commands/receipt.test.ts | 15 +++ cli/src/commands/receipt.ts | 151 ++++++++++++++++++++++++- controller/src/kars_receipt_log.rs | 143 +++++++++++++++++++++++ controller/src/kars_task_reconciler.rs | 19 +++- controller/src/providers/signing.rs | 10 ++ 5 files changed, 336 insertions(+), 2 deletions(-) diff --git a/cli/src/commands/receipt.test.ts b/cli/src/commands/receipt.test.ts index 92b0a3d0a..823e8d137 100644 --- a/cli/src/commands/receipt.test.ts +++ b/cli/src/commands/receipt.test.ts @@ -157,3 +157,18 @@ describe("receipt verify — inclusion chain", () => { 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 index 7280c7ecc..f3a551d61 100644 --- a/cli/src/commands/receipt.ts +++ b/cli/src/commands/receipt.ts @@ -32,6 +32,8 @@ import { createHash, createPublicKey, verify as cryptoVerify } from "node:crypto const ANCHOR_NAMESPACE = "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"; // 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 @@ -294,6 +296,94 @@ async function fetchInclusionChain(): Promise { } } +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", + ANCHOR_NAMESPACE, + ])) 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. @@ -429,9 +519,17 @@ export function receiptCommand(): Command { const chain = await fetchInclusionChain(); if (chain) { result.checks.push(checkInclusion(receipt, chain)); - result.ok = result.ok && result.checks.every((c) => c.ok); } + // 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 { @@ -505,6 +603,55 @@ export function receiptCommand(): Command { 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 ${ANCHOR_NAMESPACE}). ` + + `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 '${ANCHOR_NAMESPACE}'.\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; } @@ -514,4 +661,6 @@ export const __test = { importEd25519PublicKey, inclusionEntryHash, verifyInclusionChain, + checkpointNote, + chainRoot, }; \ No newline at end of file diff --git a/controller/src/kars_receipt_log.rs b/controller/src/kars_receipt_log.rs index b056f7a8c..623f30b7a 100644 --- a/controller/src/kars_receipt_log.rs +++ b/controller/src/kars_receipt_log.rs @@ -44,10 +44,18 @@ use crate::mesh_peer::IDENTITY_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; @@ -235,6 +243,103 @@ pub async fn append( 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(), IDENTITY_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(), IDENTITY_NAMESPACE); + let cm: ConfigMap = serde_json::from_value(serde_json::json!({ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": { + "name": CHECKPOINT_CONFIGMAP_NAME, + "namespace": IDENTITY_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::*; @@ -307,4 +412,42 @@ mod tests { 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_reconciler.rs b/controller/src/kars_task_reconciler.rs index fc1162c0b..1aab78758 100644 --- a/controller/src/kars_task_reconciler.rs +++ b/controller/src/kars_task_reconciler.rs @@ -517,7 +517,24 @@ async fn reconcile_receipt( 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) => Some(entry), + 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 diff --git a/controller/src/providers/signing.rs b/controller/src/providers/signing.rs index 9f3799ec2..997aa512a 100644 --- a/controller/src/providers/signing.rs +++ b/controller/src/providers/signing.rs @@ -128,6 +128,16 @@ impl ReceiptSigner { }], } } + + /// 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. From 08c247b734232260b7f61d5ec2284d5a34c0fc2c Mon Sep 17 00:00:00 2001 From: Pal Lakatos-Toth Date: Fri, 26 Jun 2026 22:41:09 +0200 Subject: [PATCH 09/23] fix(controller): task-materialized InferencePolicy must set a model (agents were degraded) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Launching a KarsTask materialized an InferencePolicy with no modelPreference.primary.deployment — but the sandbox reconciler REQUIRES a deployment (it's plumbed to AZURE_OPENAI_DEPLOYMENT / OPENCLAW_MODEL). Without it every task-launched sandbox degraded immediately at materialization, before inference was ever attempted. Only `kars dev`-created sandboxes (which carry a full modelPreference) ran. The task execution path now sets modelPreference.primary from a controller-env default — KARS_TASK_DEFAULT_MODEL → AZURE_OPENAI_DEPLOYMENT → DEFAULT_MODEL → gpt-4o-mini, with provider KARS_TASK_DEFAULT_PROVIDER (default azure-openai; the router routes by the configured endpoint URL). An operator points the controller at any OpenAI-compatible provider (GitHub Models, Azure OpenAI, Foundry) and launched agents do real governed inference. Verified end-to-end on kind kars-dev with GitHub Models as the dev provider: a launched task's sandbox reaches 2/2 Running (no longer Degraded), the router loads the model, and a chat completion through the secure router (with content safety filtering active) returns a real response + token usage. The whole 7-stage pipeline is now live, including agent execution. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- controller/src/kars_task_execution.rs | 65 ++++++++++++++++++++++++++- 1 file changed, 63 insertions(+), 2 deletions(-) diff --git a/controller/src/kars_task_execution.rs b/controller/src/kars_task_execution.rs index 520ac73a8..e8de68dfa 100644 --- a/controller/src/kars_task_execution.rs +++ b/controller/src/kars_task_execution.rs @@ -81,6 +81,31 @@ fn runtime_variant_key(kind: &str) -> &'static str { } } +/// Resolve the default `(deployment, provider)` a task-materialized +/// InferencePolicy should request. The deployment is required by the sandbox +/// reconciler — without it the pod degrades — so we derive a sane default from +/// the controller's own configured inference model and let an operator override +/// it for the task lane specifically. +/// +/// Resolution order for the deployment: +/// `KARS_TASK_DEFAULT_MODEL` → `AZURE_OPENAI_DEPLOYMENT` → `DEFAULT_MODEL` → +/// `gpt-4o-mini`. The provider tag is `KARS_TASK_DEFAULT_PROVIDER` → +/// `azure-openai` (the router routes by the configured endpoint URL, so this +/// tag only needs to be a valid non-empty value). +fn default_model() -> (String, String) { + let deployment = std::env::var("KARS_TASK_DEFAULT_MODEL") + .ok() + .filter(|s| !s.is_empty()) + .or_else(|| std::env::var("AZURE_OPENAI_DEPLOYMENT").ok().filter(|s| !s.is_empty())) + .or_else(|| std::env::var("DEFAULT_MODEL").ok().filter(|s| !s.is_empty())) + .unwrap_or_else(|| "gpt-4o-mini".to_string()); + let provider = std::env::var("KARS_TASK_DEFAULT_PROVIDER") + .ok() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "azure-openai".to_string()); + (deployment, provider) +} + /// Materialize (or re-apply) the InferencePolicy + KarsSandbox for a launched /// task, then read back the sandbox phase. Idempotent via server-side apply. pub async fn materialize( @@ -99,10 +124,18 @@ pub async fn materialize( .filter(|s| !s.trim().is_empty()) .unwrap_or_else(|| "OpenClaw".to_string()); - // 1. Minimal InferencePolicy scoped to this sandbox. Token budget mirrors - // the envelope when present (the router's TokenBudgetTracker enforces). + // 1. InferencePolicy scoped to this sandbox. Token budget mirrors the + // envelope when present (the router's TokenBudgetTracker enforces). + // A model preference is REQUIRED — without it the sandbox reconciler + // degrades the pod (no deployment to call). We default it from the + // controller's configured model so a launched task actually runs, and + // let an operator override the defaults via env. + let (model_deployment, model_provider) = default_model(); let mut inference_spec = json!({ "appliesTo": { "sandboxName": task_name }, + "modelPreference": { + "primary": { "provider": model_provider, "deployment": model_deployment }, + }, }); if let Some(tokens) = envelope.budget.as_ref().and_then(|b| b.tokens) && tokens > 0 @@ -281,6 +314,34 @@ mod tests { use super::*; use crate::kars_task::TaskBudget; + #[test] + fn default_model_resolution() { + // Single test (env is process-global; avoid cross-test races). + unsafe { + std::env::remove_var("KARS_TASK_DEFAULT_MODEL"); + std::env::remove_var("AZURE_OPENAI_DEPLOYMENT"); + std::env::remove_var("DEFAULT_MODEL"); + std::env::remove_var("KARS_TASK_DEFAULT_PROVIDER"); + } + // No knobs → safe builtin default + valid provider tag. + let (deployment, provider) = default_model(); + assert!(!deployment.is_empty()); + assert_eq!(provider, "azure-openai"); + + // Explicit task overrides win. + unsafe { + std::env::set_var("KARS_TASK_DEFAULT_MODEL", "openai/gpt-4o-mini"); + std::env::set_var("KARS_TASK_DEFAULT_PROVIDER", "github-models"); + } + let (deployment, provider) = default_model(); + assert_eq!(deployment, "openai/gpt-4o-mini"); + assert_eq!(provider, "github-models"); + unsafe { + std::env::remove_var("KARS_TASK_DEFAULT_MODEL"); + std::env::remove_var("KARS_TASK_DEFAULT_PROVIDER"); + } + } + #[test] fn runtime_variant_keys() { assert_eq!(runtime_variant_key("OpenClaw"), "openclaw"); From d53fb6409c97bfc1e8a85111cf51d6a4cd49188c Mon Sep 17 00:00:00 2001 From: Pal Lakatos-Toth Date: Sat, 27 Jun 2026 00:20:59 +0200 Subject: [PATCH 10/23] feat(controller): KarsTask blueprint composes existing CRDs into a real run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The launch package (design note §20) needs to compile a task-giver's intent into the substrate's existing CRDs rather than duplicate them. Extend KarsTask with a TaskBlueprint (runtime, model, instructions, toolPolicy, mcpServers, egress, isolation, memory) and have materialize() compose it: - model -> InferencePolicy.spec.modelPreference.primary - runtime -> KarsSandbox.spec.runtime.kind - isolation -> KarsSandbox.spec.sandbox.isolation - prompt -> KarsSandbox.spec.agent.instructions (objective + standing instructions) - tools -> KarsSandbox.spec.governance.toolPolicyRef (existing ToolPolicy, by ref) - MCP -> KarsSandbox.spec.governance.mcpServerRefs - egress -> KarsSandbox.spec.networkPolicy.allowedEndpoints (+ Strict mode) - memory -> KarsSandbox.spec.memoryRef (existing KarsMemory) Governance is composed cohesively: tools come from a single source (blueprint or envelope toolPolicyRef); MCP refs only attach when a tool policy bounds them, so without a policy governance is a valid 'enabled: false' rather than an invalid 'enabled: true' with no toolPolicyRef. CEL guards added: mcpServers requires toolPolicy; has() guards on optional vec fields. Verified live on kars-dev: a launched task materializes the correct InferencePolicy + KarsSandbox and reaches Running through the secure router. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- controller/src/crd_validations.rs | 36 ++++ controller/src/kars_task.rs | 93 +++++++- controller/src/kars_task_execution.rs | 213 +++++++++++++++---- controller/src/kars_task_reconciler.rs | 1 + deploy/helm/kars/templates/crd-karstask.yaml | 120 ++++++++++- 5 files changed, 423 insertions(+), 40 deletions(-) diff --git a/controller/src/crd_validations.rs b/controller/src/crd_validations.rs index 807d38377..092d688af 100644 --- a/controller/src/crd_validations.rs +++ b/controller/src/crd_validations.rs @@ -569,6 +569,42 @@ pub fn kars_task_validations() -> Vec { reason: Some("FieldValueInvalid".into()), ..ValidationRule::default() }, + ValidationRule { + rule: "!has(self.blueprint) || !has(self.blueprint.runtime) || self.blueprint.runtime in ['OpenClaw','OpenAIAgents','MAF','MicrosoftAgentFramework','Hermes','BYO']".into(), + message: Some("spec.blueprint.runtime must be one of OpenClaw, OpenAIAgents, MAF, MicrosoftAgentFramework, Hermes, BYO".into()), + reason: Some("FieldValueInvalid".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() + }, ] } diff --git a/controller/src/kars_task.rs b/controller/src/kars_task.rs index 7a26d8368..0f5edb122 100644 --- a/controller/src/kars_task.rs +++ b/controller/src/kars_task.rs @@ -95,11 +95,100 @@ pub struct KarsTaskSpec { #[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, } +/// 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 the agent runs on (`OpenClaw`, `OpenAIAgents`, `MAF`, + /// `Hermes`, `BYO`). Drives `KarsSandbox.spec.runtime.kind`. 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`. When non-empty the + /// sandbox runs in strict egress mode bounded to exactly these hosts. + #[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). @@ -112,7 +201,8 @@ pub struct TaskExecution { pub launch: bool, /// Runtime to launch the agent on. Defaults to `OpenClaw`. Must match the - /// controller's `RuntimeKind` enum. + /// controller's `RuntimeKind` enum. Superseded by `blueprint.runtime` when + /// both are set. #[serde(default, skip_serializing_if = "Option::is_none")] pub runtime: Option, } @@ -532,6 +622,7 @@ mod tests { 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"); diff --git a/controller/src/kars_task_execution.rs b/controller/src/kars_task_execution.rs index e8de68dfa..42730690b 100644 --- a/controller/src/kars_task_execution.rs +++ b/controller/src/kars_task_execution.rs @@ -24,7 +24,7 @@ use kube::core::ApiResource; use kube::{Client, ResourceExt}; use serde_json::json; -use crate::kars_task::{KarsTask, TaskEnvelope}; +use crate::kars_task::{KarsTask, TaskBlueprint, TaskEnvelope}; const FIELD_MANAGER: &str = crate::field_managers::CLAW_TASK; @@ -106,6 +106,17 @@ fn default_model() -> (String, String) { (deployment, provider) } +/// Build the agent's standing instructions (system prompt) from the task +/// objective plus any blueprint instructions. Pure + testable. +fn build_instructions(objective: &str, extra: Option<&str>) -> String { + let mut out = format!("Your objective:\n{}", objective.trim()); + if let Some(extra) = extra.map(str::trim).filter(|s| !s.is_empty()) { + out.push_str("\n\nAdditional instructions:\n"); + out.push_str(extra); + } + out +} + /// Materialize (or re-apply) the InferencePolicy + KarsSandbox for a launched /// task, then read back the sandbox phase. Idempotent via server-side apply. pub async fn materialize( @@ -116,21 +127,34 @@ pub async fn materialize( let task_name = task.name_any(); let inference_name = format!("{task_name}-inference"); let envelope = &task.spec.envelope; - let runtime_kind = task - .spec - .execution - .as_ref() - .and_then(|e| e.runtime.clone()) + let blueprint = task.spec.blueprint.clone().unwrap_or_default(); + // Runtime: blueprint wins, then execution.runtime, then OpenClaw. + let runtime_kind = blueprint + .runtime + .clone() .filter(|s| !s.trim().is_empty()) + .or_else(|| { + task.spec + .execution + .as_ref() + .and_then(|e| e.runtime.clone()) + .filter(|s| !s.trim().is_empty()) + }) .unwrap_or_else(|| "OpenClaw".to_string()); - // 1. InferencePolicy scoped to this sandbox. Token budget mirrors the - // envelope when present (the router's TokenBudgetTracker enforces). - // A model preference is REQUIRED — without it the sandbox reconciler - // degrades the pod (no deployment to call). We default it from the - // controller's configured model so a launched task actually runs, and - // let an operator override the defaults via env. - let (model_deployment, model_provider) = default_model(); + // 1. InferencePolicy scoped to this sandbox. Model: blueprint wins, else + // the controller default (required — without it the sandbox degrades). + let (model_deployment, model_provider) = match &blueprint.model { + Some(m) if !m.deployment.trim().is_empty() => { + let provider = if m.provider.trim().is_empty() { + "azure-openai".to_string() + } else { + m.provider.clone() + }; + (m.deployment.clone(), provider) + } + _ => default_model(), + }; let mut inference_spec = json!({ "appliesTo": { "sandboxName": task_name }, "modelPreference": { @@ -153,21 +177,63 @@ pub async fn materialize( ) .await?; - // 2. KarsSandbox bounded by the envelope. Tool policy from the envelope is - // wired into governance; egress allow-list (when present) rides the - // existing per-sandbox egress machinery via the same-named ref. + // 2. KarsSandbox bounded by the envelope + shaped by the blueprint. Each + // blueprint field drives a real sandbox field; unset → safe default. + let isolation = blueprint + .isolation + .clone() + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| "standard".to_string()); let mut sandbox_spec = json!({ "runtime": { "kind": runtime_kind, runtime_variant_key(&runtime_kind): {}, }, "inferenceRef": { "name": inference_name }, - "sandbox": { "isolation": "standard" }, + "sandbox": { "isolation": isolation }, "networkPolicy": { "defaultDeny": true }, }); - governance_block(envelope).inspect(|g| { - sandbox_spec["governance"] = g.clone(); - }); + + // Egress: when the blueprint names destinations, bound the sandbox to + // exactly those hosts in strict mode (the substance of "what it can reach"). + if !blueprint.egress.is_empty() { + let endpoints: Vec = blueprint + .egress + .iter() + .map(|e| match e.port { + Some(p) => json!({ "host": e.host, "port": p }), + None => json!({ "host": e.host }), + }) + .collect(); + sandbox_spec["networkPolicy"] = json!({ + "defaultDeny": true, + "egressMode": "Strict", + "allowedEndpoints": endpoints, + }); + } + + // 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. + let instructions = build_instructions(&task.spec.objective, blueprint.instructions.as_deref()); + sandbox_spec["agent"] = json!({ "instructions": 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 @@ -241,14 +307,35 @@ pub async fn teardown( Ok(()) } -/// Build the governance block from the envelope's tool-policy ref, if any. -fn governance_block(envelope: &TaskEnvelope) -> Option { - envelope.tool_policy_ref.as_ref().map(|r| { - json!({ - "enabled": true, - "toolPolicyRef": { "name": r.name }, - }) - }) +/// 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. @@ -312,7 +399,23 @@ async fn apply_dynamic( #[cfg(test)] mod tests { use super::*; - use crate::kars_task::TaskBudget; + + #[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 default_model_resolution() { @@ -351,24 +454,58 @@ mod tests { } #[test] - fn governance_block_present_only_with_tool_policy() { - let mut e = TaskEnvelope { + fn governance_disabled_without_tool_policy() { + let e = TaskEnvelope { tier: 3, authority_ceiling: 2, delegation_depth: 1, - budget: Some(TaskBudget { - tokens: Some(1000), - usd_micros: None, - }), + budget: None, tool_policy_ref: None, egress_allowlist_ref: None, }; - assert!(governance_block(&e).is_none()); - e.tool_policy_ref = Some(crate::mcp_server::LocalObjectRef { name: "tp".into() }); - let g = governance_block(&e).expect("present"); + 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"); diff --git a/controller/src/kars_task_reconciler.rs b/controller/src/kars_task_reconciler.rs index 1aab78758..ea7317a6a 100644 --- a/controller/src/kars_task_reconciler.rs +++ b/controller/src/kars_task_reconciler.rs @@ -709,6 +709,7 @@ mod tests { }, parent_ref: None, execution: None, + blueprint: None, display_name: None, }, ); diff --git a/deploy/helm/kars/templates/crd-karstask.yaml b/deploy/helm/kars/templates/crd-karstask.yaml index 9a0bbed16..999a438c1 100644 --- a/deploy/helm/kars/templates/crd-karstask.yaml +++ b/deploy/helm/kars/templates/crd-karstask.yaml @@ -44,6 +44,105 @@ spec: 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`. When non-empty the + sandbox runs in strict egress mode bounded to exactly these hosts. + 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 the agent runs on (`OpenClaw`, `OpenAIAgents`, `MAF`, + `Hermes`, `BYO`). Drives `KarsSandbox.spec.runtime.kind`. 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 @@ -136,7 +235,8 @@ spec: runtime: description: |- Runtime to launch the agent on. Defaults to `OpenClaw`. Must match the - controller's `RuntimeKind` enum. + controller's `RuntimeKind` enum. Superseded by `blueprint.runtime` when + both are set. nullable: true type: string type: object @@ -193,6 +293,24 @@ spec: - 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 one of OpenClaw, OpenAIAgents, MAF, MicrosoftAgentFramework, Hermes, BYO + reason: FieldValueInvalid + rule: '!has(self.blueprint) || !has(self.blueprint.runtime) || self.blueprint.runtime in [''OpenClaw'',''OpenAIAgents'',''MAF'',''MicrosoftAgentFramework'',''Hermes'',''BYO'']' + - 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 From ceb5308399c8342c19e07aef4247f56387e86fd0 Mon Sep 17 00:00:00 2001 From: Pal Lakatos-Toth Date: Sat, 27 Jun 2026 00:43:15 +0200 Subject: [PATCH 11/23] =?UTF-8?q?feat(controller):=20truthful=20delegation?= =?UTF-8?q?=20=E2=80=94=20attenuate=20effective=20authority=20+=20gate=20o?= =?UTF-8?q?n=20parent=20readiness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two correctness fixes that make capability-attenuating delegation (Pillar A / design note §12 'the org chart IS the security topology') honest rather than nominal: 1. Authority-model cohesion. Attenuation previously checked the envelope's toolPolicyRef/egressAllowlistRef, but materialize() composes the sandbox from the blueprint's toolPolicy + inline egress (and egressAllowlistRef was never materialized). A child could therefore pass delegation checks while changing its real tool/egress surface. New spec_attenuation_violations() checks the *effective* authority the sandbox actually enforces: effective_tool_policy (blueprint-or-envelope) and effective_egress (blueprint egress, subset of the parent's, with any-port parent entries covering child ports). The verified subset relation now matches execution. 2. Parent-readiness gating. resolve_delegation() no longer grants a child authority against an unvalidated parent: a child whose parent is not governance-Ready (no envelope digest) resolves to a transient Pending (DependencyMissing) state and requeues quickly, instead of going Ready under a degraded/in-flux parent. Verified live on kars-dev: a subset child → Ready; an egress-amplifying child → Degraded with the exact reason; a child of a degraded parent → Pending. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- controller/src/kars_task.rs | 191 +++++++++++++++++++++++++ controller/src/kars_task_reconciler.rs | 85 ++++++++++- 2 files changed, 270 insertions(+), 6 deletions(-) diff --git a/controller/src/kars_task.rs b/controller/src/kars_task.rs index 0f5edb122..41b3afccf 100644 --- a/controller/src/kars_task.rs +++ b/controller/src/kars_task.rs @@ -409,6 +409,14 @@ pub enum EnvelopeViolation { 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 { @@ -454,6 +462,16 @@ impl std::fmt::Display for EnvelopeViolation { "{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)" + ), + }, } } } @@ -503,6 +521,77 @@ fn attenuate_policy_axis( }); } } + +/// 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()) + .filter(|s| !s.is_empty()) + .or_else(|| spec.envelope.tool_policy_ref.as_ref().map(|r| r.name.as_str())) +} + +/// 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(&[]) +} + +/// 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 { @@ -820,4 +909,106 @@ mod tests { assert_eq!(e.authority_ceiling, TIER_MIN); assert!(e.budget.is_none()); } + + // ── Effective-authority attenuation (tools + egress the sandbox enforces) ── + + 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 eg(host: &str, port: Option) -> TaskEgress { + TaskEgress { host: host.into(), port } + } + + /// A child envelope that strictly attenuates `parent_envelope()` on every + /// numeric axis, so attenuation tests isolate the tool/egress axes. + 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() { + // Blueprint wins when set. + let s = spec_with(parent_envelope(), Some("bp-tools"), vec![]); + assert_eq!(effective_tool_policy(&s), Some("bp-tools")); + // Falls back to the envelope ref when the blueprint omits it. + let s2 = spec_with(parent_envelope(), None, vec![]); + assert_eq!(effective_tool_policy(&s2), Some("strict-tools")); + } + + #[test] + fn child_egress_must_be_subset_of_parent() { + let parent = spec_with( + parent_envelope(), + Some("strict-tools"), + vec![eg("api.github.com", Some(443)), eg("pkg.go.dev", None)], + ); + // Child within the parent's allow-list (exact + any-port host) → ok. + let ok = spec_with( + child_envelope(), + Some("strict-tools"), + vec![eg("api.github.com", Some(443)), eg("pkg.go.dev", Some(443))], + ); + assert!(spec_attenuation_violations(&ok, &parent).is_empty()); + // Child reaching a host the parent never allowed → rejected. + let bad = spec_with( + child_envelope(), + Some("strict-tools"), + vec![eg("evil.example.com", Some(443))], + ); + let v = spec_attenuation_violations(&bad, &parent); + assert!(matches!( + v.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 bad = spec_with( + child_envelope(), + Some("strict-tools"), + vec![eg("api.github.com", Some(443))], + ); + let v = spec_attenuation_violations(&bad, &parent); + assert!(v.iter().any(|x| matches!(x, EnvelopeViolation::EgressNotSubset { .. }))); + } + + #[test] + fn child_tool_policy_must_match_parent_effective() { + let parent = spec_with(parent_envelope(), Some("strict-tools"), vec![]); + // Different effective tool policy than the parent → rejected. + let bad = spec_with(child_envelope(), Some("loose-tools"), vec![]); + let v = spec_attenuation_violations(&bad, &parent); + assert!(v.iter().any(|x| matches!( + x, + EnvelopeViolation::PolicyMismatch { axis: PolicyAxis::ToolPolicy, .. } + ))); + } } diff --git a/controller/src/kars_task_reconciler.rs b/controller/src/kars_task_reconciler.rs index ea7317a6a..f62621d1d 100644 --- a/controller/src/kars_task_reconciler.rs +++ b/controller/src/kars_task_reconciler.rs @@ -30,7 +30,7 @@ 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_READY}; +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"; @@ -39,6 +39,10 @@ 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}")] @@ -182,6 +186,14 @@ async fn reconcile(task: Arc, ctx: Arc) -> Result { + 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, @@ -240,7 +252,13 @@ async fn reconcile(task: Arc, ctx: Arc) -> Result bool { + let Some(status) = task.status.as_ref() else { + return false; + }; + let digest_ok = status.envelope_digest.as_ref().is_some_and(|d| !d.is_empty()); + let ready_ok = status + .conditions + .iter() + .flatten() + .any(|c| c.type_ == TYPE_READY && c.status == cond_status::TRUE); + digest_ok && ready_ok +} + /// Build a `Ready` status with the given digest + lineage. fn ready_status( prior_ready: Option<&k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition>, @@ -330,6 +377,32 @@ fn ready_status( /// 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, From 8c904f1943d1c09f8babe3b90ad1d798287b120c Mon Sep 17 00:00:00 2001 From: Pal Lakatos-Toth Date: Sat, 27 Jun 2026 00:59:04 +0200 Subject: [PATCH 12/23] fix(controller): KarsTask deletion strands in Terminating, leaking sandboxes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The finalizer-drop used a server-side apply patch that set finalizers: []. The apiserver does not reliably remove a finalizer that way once it no longer attributes the field to this manager — it rejects with 'name must be provided based on URL' (HTTP 400). The reconcile then errors and requeues forever, so the KarsTask sits in Terminating with its cleanup finalizer and its materialized KarsSandbox (and per-sandbox namespace) is never reaped. This is exactly the 'sandboxes pile up' symptom seen in dogfood. Fix: drop the finalizer with a deterministic merge patch (the same operation that clears it by hand). Adding-the-finalizer keeps the apply path (which works) and now also carries metadata.name for correctness. Verified live on kars-dev: launching then deleting a task now reaps the task, its sandbox (owner-ref GC), and its namespace within the delete timeout — no stuck finalizer. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- controller/src/kars_task_reconciler.rs | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/controller/src/kars_task_reconciler.rs b/controller/src/kars_task_reconciler.rs index f62621d1d..c39b5e6cf 100644 --- a/controller/src/kars_task_reconciler.rs +++ b/controller/src/kars_task_reconciler.rs @@ -114,17 +114,15 @@ async fn reconcile(task: Arc, ctx: Arc) -> Result, ctx: Arc) -> Result Date: Thu, 3 Sep 2026 17:11:05 +0200 Subject: [PATCH 13/23] fix(governance): meet public conformance gates Separate large unit-test modules, add receipt and approval CEL/status metadata, and document the security review so the core governance slice passes LOC, CNCF, drift, and security-audit gates. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- controller/src/crd_validations.rs | 52 ++- controller/src/kars_approval.rs | 32 +- controller/src/kars_approval_reconciler.rs | 23 +- controller/src/kars_receipt.rs | 94 ++++- controller/src/kars_receipt_log.rs | 3 +- controller/src/kars_task.rs | 372 +--------------- controller/src/kars_task_execution.rs | 12 +- controller/src/kars_task_reconciler.rs | 77 +--- controller/src/kars_task_reconciler_tests.rs | 57 +++ controller/src/kars_task_tests.rs | 398 ++++++++++++++++++ .../helm/kars/templates/crd-karsapproval.yaml | 8 +- .../helm/kars/templates/crd-karsreceipt.yaml | 45 +- .../2026-09-03-core-governance-apis.md | 54 +++ inference-router/src/metrics.rs | 12 +- 14 files changed, 772 insertions(+), 467 deletions(-) create mode 100644 controller/src/kars_task_reconciler_tests.rs create mode 100644 controller/src/kars_task_tests.rs create mode 100644 docs/security-audits/2026-09-03-core-governance-apis.md diff --git a/controller/src/crd_validations.rs b/controller/src/crd_validations.rs index 092d688af..7a42de24b 100644 --- a/controller/src/crd_validations.rs +++ b/controller/src/crd_validations.rs @@ -51,8 +51,8 @@ use kube::CustomResourceExt; use crate::a2a_agent::A2AAgent; use crate::egress_approval::EgressApproval; use crate::inference_policy::InferencePolicy; -use crate::kars_eval::KarsEval; 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; @@ -617,20 +617,54 @@ pub fn kars_task_crd() -> CustomResourceDefinition { .expect("kube-rs derive must produce a spec property on KarsTask") } -/// `KarsReceipt` CRD. The Governance Receipt is written solely by the -/// controller (never by users), so it carries no admission CEL rules — its -/// integrity comes from the DSSE/Ed25519 signature, not from schema gates. +#[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 { - KarsReceipt::crd() + 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() + }, + ] } -/// `KarsApproval` CRD. The HITL approval primitive carries no admission CEL in -/// V0 — the controller is the sole writer of `status` (the binding, phase, and -/// immutable timestamps), and `spec.decision` is a human steer, not a gate. +/// `KarsApproval` CRD with immutable request-shape validation. #[must_use] pub fn kars_approval_crd() -> CustomResourceDefinition { - KarsApproval::crd() + inject_spec_validations(KarsApproval::crd(), kars_approval_validations()) + .expect("kube-rs derive must produce a spec property on KarsApproval") } /// `TrustGraph.spec` CEL rules. Phase F1. diff --git a/controller/src/kars_approval.rs b/controller/src/kars_approval.rs index 3223debc6..aa9016dd5 100644 --- a/controller/src/kars_approval.rs +++ b/controller/src/kars_approval.rs @@ -300,15 +300,27 @@ mod tests { #[test] fn approve_is_terminal_and_records_decider() { - let out = evaluate(Some(&decision("approve")), Some("sha256:aa"), Some("sha256:aa"), false); + 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")); + 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); + let out = evaluate( + Some(&decision("deny")), + Some("sha256:aa"), + Some("sha256:aa"), + false, + ); assert_eq!(out.phase(), PHASE_DENIED); assert!(out.is_terminal()); } @@ -316,13 +328,23 @@ mod tests { #[test] fn decision_wins_over_expiry_and_staleness() { // Expired + drifted, but a human decided → the decision stands. - let out = evaluate(Some(&decision("approve")), Some("sha256:aa"), Some("sha256:bb"), true); + let out = evaluate( + Some(&decision("approve")), + Some("sha256:aa"), + Some("sha256:bb"), + true, + ); assert_eq!(out.phase(), PHASE_APPROVED); } #[test] fn unknown_verdict_fails_closed_to_pending() { - let out = evaluate(Some(&decision("maybe")), Some("sha256:aa"), Some("sha256:aa"), false); + let out = evaluate( + Some(&decision("maybe")), + Some("sha256:aa"), + Some("sha256:aa"), + false, + ); assert_eq!(out.phase(), crate::status::phase::PHASE_PENDING); } diff --git a/controller/src/kars_approval_reconciler.rs b/controller/src/kars_approval_reconciler.rs index c5fceb5a9..7027a04be 100644 --- a/controller/src/kars_approval_reconciler.rs +++ b/controller/src/kars_approval_reconciler.rs @@ -221,16 +221,16 @@ fn build_status( ApprovalOutcome::Approved { decider } => { (cond_status::TRUE, format!("approved by {decider}")) } - ApprovalOutcome::Denied { decider } => { - (cond_status::TRUE, format!("denied 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::Approved { .. } | ApprovalOutcome::Denied { .. } => { + cond_reason::RECONCILED + } ApprovalOutcome::Expired => cond_reason::TIMED_OUT, ApprovalOutcome::Stale(_) => cond_reason::DEPENDENCY_MISSING, }; @@ -256,10 +256,7 @@ fn build_status( _ => prior.decider.clone(), }; let decided_at = if decided { - prior - .decided_at - .clone() - .or_else(|| Some(now.to_rfc3339())) + prior.decided_at.clone().or_else(|| Some(now.to_rfc3339())) } else { prior.decided_at.clone() }; @@ -389,7 +386,15 @@ mod tests { // 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); + 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)); } diff --git a/controller/src/kars_receipt.rs b/controller/src/kars_receipt.rs index 9ece3622d..fdf0b4864 100644 --- a/controller/src/kars_receipt.rs +++ b/controller/src/kars_receipt.rs @@ -39,6 +39,7 @@ //! [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}; @@ -66,6 +67,7 @@ pub const PREDICATE_TYPE: &str = "https://kars.azure.com/attestations/Governance 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")] @@ -125,6 +127,10 @@ impl Claim { #[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, @@ -532,18 +538,37 @@ mod tests { 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()); + 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(); + 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, "deadbeefdeadbeefdeadbeefdeadbeef"); + assert_eq!( + st.subject[0].digest.sha256, + "deadbeefdeadbeefdeadbeefdeadbeef" + ); assert!(!st.predicate.delegation.is_child); assert_eq!(st.predicate.conformance.attenuates_parent, None); assert_eq!(st.predicate.issuer.key_id, "kid123"); @@ -552,9 +577,19 @@ mod tests { #[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(); + 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.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"]); @@ -563,7 +598,14 @@ mod tests { #[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 st = build_statement( + &task, + &status, + "kid", + &[], + PredicateCompleteness::default().with_rollup(), + ) + .unwrap(); let by = |c: &str| { st.predicate .claims @@ -582,8 +624,26 @@ mod tests { #[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()); + 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(); @@ -594,7 +654,14 @@ mod tests { #[test] fn launched_execution_is_recorded() { let (task, status) = ready_task(false); - let st = build_statement(&task, &status, "kid", &[], PredicateCompleteness::default().with_rollup()).unwrap(); + 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")); } @@ -611,7 +678,14 @@ mod tests { 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(); + 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)); diff --git a/controller/src/kars_receipt_log.rs b/controller/src/kars_receipt_log.rs index 623f30b7a..d7413b758 100644 --- a/controller/src/kars_receipt_log.rs +++ b/controller/src/kars_receipt_log.rs @@ -446,8 +446,7 @@ mod tests { 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)); + 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 index 41b3afccf..bacda0f73 100644 --- a/controller/src/kars_task.rs +++ b/controller/src/kars_task.rs @@ -532,7 +532,12 @@ pub fn effective_tool_policy(spec: &KarsTaskSpec) -> Option<&str> { .as_ref() .and_then(|b| b.tool_policy.as_deref()) .filter(|s| !s.is_empty()) - .or_else(|| spec.envelope.tool_policy_ref.as_ref().map(|r| r.name.as_str())) + .or_else(|| { + spec.envelope + .tool_policy_ref + .as_ref() + .map(|r| r.name.as_str()) + }) } /// The *effective* egress allow-list a task runs under: the blueprint's egress @@ -550,9 +555,9 @@ pub fn effective_egress(spec: &KarsTaskSpec) -> &[TaskEgress] { /// 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) - }) + 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 + @@ -655,360 +660,5 @@ pub struct KarsTaskStatus { } #[cfg(test)] -mod tests { - 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 e = sample_envelope(); - assert_eq!(e.digest(), e.digest()); - } - - #[test] - fn envelope_digest_has_sha256_prefix_and_length() { - let d = sample_envelope().digest(); - assert!(d.starts_with("sha256:")); - // "sha256:" (7) + 16 bytes * 2 hex chars (32) = 39. - assert_eq!(d.len(), 39); - } - - #[test] - fn envelope_digest_changes_with_tier() { - let mut a = sample_envelope(); - let before = a.digest(); - a.tier = 4; - assert_ne!(before, a.digest()); - } - - #[test] - fn envelope_digest_changes_with_delegation_depth() { - let mut a = sample_envelope(); - let before = a.digest(); - a.delegation_depth += 1; - assert_ne!(before, a.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"); - // Envelope fields must be camelCase on the wire. - 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); - } - - // ── Capability-attenuating delegation lattice (Pillar A) ────────────── - - /// A permissive parent: tier 5, ceiling 4, depth 3, generous budget. - 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, // <= parent ceiling 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, // <= 3 - 1 - authority_ceiling: 3, // <= 4 - }; - assert!( - child.attenuation_violations(&parent).is_empty(), - "{:?}", - child.attenuation_violations(&parent) - ); - } - - #[test] - fn child_tier_above_parent_ceiling_is_amplification() { - let parent = parent_envelope(); - let child = TaskEnvelope { - tier: 5, // parent ceiling is only 4 - authority_ceiling: 4, - delegation_depth: 0, - ..parent_envelope() - }; - let v = child.attenuation_violations(&parent); - assert!( - v.iter() - .any(|x| matches!(x, 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; // exceeds parent ceiling 4 - child.delegation_depth = 0; - let v = child.attenuation_violations(&parent); - assert!( - v.iter() - .any(|x| matches!(x, EnvelopeViolation::CeilingExceedsParentCeiling { .. })) - ); - } - - #[test] - fn delegation_depth_must_decrement() { - let parent = parent_envelope(); // depth 3 - let mut child = parent_envelope(); - child.tier = 4; - child.authority_ceiling = 4; - child.delegation_depth = 3; // must be <= 2 - let v = child.attenuation_violations(&parent); - assert!( - v.iter() - .any(|x| matches!(x, EnvelopeViolation::DelegationDepthExceeded { .. })) - ); - } - - #[test] - fn exhausted_delegation_budget_rejects_any_child() { - let mut parent = parent_envelope(); - parent.delegation_depth = 0; // no hops left - let mut child = parent_envelope(); - child.tier = 1; - child.authority_ceiling = 1; - child.delegation_depth = 0; - let v = child.attenuation_violations(&parent); - assert!( - v.iter() - .any(|x| matches!(x, 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), // parent caps at 1M - usd_micros: Some(1_000_000), - }); - let v = child.attenuation_violations(&parent); - assert!(v.iter().any(|x| matches!( - x, - 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; // parent bounds tokens + usd - let v = child.attenuation_violations(&parent); - assert!( - v.iter() - .any(|x| matches!(x, EnvelopeViolation::BudgetUnbounded { .. })) - ); - } - - #[test] - fn child_must_match_parent_pinned_tool_policy() { - let parent = parent_envelope(); // pins strict-tools - 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(), - }); - let v = child.attenuation_violations(&parent); - assert!(v.iter().any(|x| matches!( - x, - EnvelopeViolation::PolicyMismatch { - axis: PolicyAxis::ToolPolicy, - .. - } - ))); - } - - #[test] - fn child_may_add_egress_bound_where_parent_has_none() { - let parent = parent_envelope(); // egress_allowlist_ref None - 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(), - }); - // Adding a bound where the parent had none is attenuation, not amplification. - let v = child.attenuation_violations(&parent); - assert!(!v.iter().any(|x| matches!( - x, - EnvelopeViolation::PolicyMismatch { - axis: PolicyAxis::EgressAllowlist, - .. - } - ))); - } - - #[test] - fn default_envelope_is_least_privilege() { - let e = TaskEnvelope::default(); - assert_eq!(e.tier, TIER_MIN); - assert_eq!(e.delegation_depth, 0); - assert_eq!(e.authority_ceiling, TIER_MIN); - assert!(e.budget.is_none()); - } - - // ── Effective-authority attenuation (tools + egress the sandbox enforces) ── - - 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 eg(host: &str, port: Option) -> TaskEgress { - TaskEgress { host: host.into(), port } - } - - /// A child envelope that strictly attenuates `parent_envelope()` on every - /// numeric axis, so attenuation tests isolate the tool/egress axes. - 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() { - // Blueprint wins when set. - let s = spec_with(parent_envelope(), Some("bp-tools"), vec![]); - assert_eq!(effective_tool_policy(&s), Some("bp-tools")); - // Falls back to the envelope ref when the blueprint omits it. - let s2 = spec_with(parent_envelope(), None, vec![]); - assert_eq!(effective_tool_policy(&s2), Some("strict-tools")); - } - - #[test] - fn child_egress_must_be_subset_of_parent() { - let parent = spec_with( - parent_envelope(), - Some("strict-tools"), - vec![eg("api.github.com", Some(443)), eg("pkg.go.dev", None)], - ); - // Child within the parent's allow-list (exact + any-port host) → ok. - let ok = spec_with( - child_envelope(), - Some("strict-tools"), - vec![eg("api.github.com", Some(443)), eg("pkg.go.dev", Some(443))], - ); - assert!(spec_attenuation_violations(&ok, &parent).is_empty()); - // Child reaching a host the parent never allowed → rejected. - let bad = spec_with( - child_envelope(), - Some("strict-tools"), - vec![eg("evil.example.com", Some(443))], - ); - let v = spec_attenuation_violations(&bad, &parent); - assert!(matches!( - v.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 bad = spec_with( - child_envelope(), - Some("strict-tools"), - vec![eg("api.github.com", Some(443))], - ); - let v = spec_attenuation_violations(&bad, &parent); - assert!(v.iter().any(|x| matches!(x, EnvelopeViolation::EgressNotSubset { .. }))); - } - - #[test] - fn child_tool_policy_must_match_parent_effective() { - let parent = spec_with(parent_envelope(), Some("strict-tools"), vec![]); - // Different effective tool policy than the parent → rejected. - let bad = spec_with(child_envelope(), Some("loose-tools"), vec![]); - let v = spec_attenuation_violations(&bad, &parent); - assert!(v.iter().any(|x| matches!( - x, - EnvelopeViolation::PolicyMismatch { axis: PolicyAxis::ToolPolicy, .. } - ))); - } -} +#[path = "kars_task_tests.rs"] +mod tests; diff --git a/controller/src/kars_task_execution.rs b/controller/src/kars_task_execution.rs index 42730690b..d0ad5cd4e 100644 --- a/controller/src/kars_task_execution.rs +++ b/controller/src/kars_task_execution.rs @@ -96,8 +96,16 @@ fn default_model() -> (String, String) { let deployment = std::env::var("KARS_TASK_DEFAULT_MODEL") .ok() .filter(|s| !s.is_empty()) - .or_else(|| std::env::var("AZURE_OPENAI_DEPLOYMENT").ok().filter(|s| !s.is_empty())) - .or_else(|| std::env::var("DEFAULT_MODEL").ok().filter(|s| !s.is_empty())) + .or_else(|| { + std::env::var("AZURE_OPENAI_DEPLOYMENT") + .ok() + .filter(|s| !s.is_empty()) + }) + .or_else(|| { + std::env::var("DEFAULT_MODEL") + .ok() + .filter(|s| !s.is_empty()) + }) .unwrap_or_else(|| "gpt-4o-mini".to_string()); let provider = std::env::var("KARS_TASK_DEFAULT_PROVIDER") .ok() diff --git a/controller/src/kars_task_reconciler.rs b/controller/src/kars_task_reconciler.rs index c39b5e6cf..d2dc896e1 100644 --- a/controller/src/kars_task_reconciler.rs +++ b/controller/src/kars_task_reconciler.rs @@ -339,7 +339,10 @@ fn task_is_ready(task: &KarsTask) -> bool { let Some(status) = task.status.as_ref() else { return false; }; - let digest_ok = status.envelope_digest.as_ref().is_some_and(|d| !d.is_empty()); + let digest_ok = status + .envelope_digest + .as_ref() + .is_some_and(|d| !d.is_empty()); let ready_ok = status .conditions .iter() @@ -498,7 +501,9 @@ async fn reconcile_receipt( signer: &crate::providers::signing::ReceiptSigner, ) { use crate::kars_approval::KarsApproval; - use crate::kars_receipt::{KarsReceipt, approval_facts, build_spec, build_statement, canonical_json}; + use crate::kars_receipt::{ + KarsReceipt, approval_facts, build_spec, build_statement, canonical_json, + }; let name = task.name_any(); let receipts: Api = Api::namespaced(client.clone(), ns); @@ -526,7 +531,8 @@ async fn reconcile_receipt( // concrete and re-derivable by an auditor. let completeness = gather_completeness(client).await; - let Some(statement) = build_statement(task, status, &signer.key_id, &facts, completeness) else { + let Some(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()) @@ -652,7 +658,8 @@ async fn gather_completeness(client: &kube::Client) -> crate::kars_receipt::Pred let vaps: Api = Api::all(client.clone()); let vap_present = |name: &str, list: &[ValidatingAdmissionPolicy]| -> bool { - list.iter().any(|p| p.metadata.name.as_deref() == Some(name)) + list.iter() + .any(|p| p.metadata.name.as_deref() == Some(name)) }; let vap_list = vaps .list(&ListParams::default()) @@ -758,64 +765,6 @@ pub async fn run(client: Client) -> Result<()> { Ok(()) } -// ───────────────────────────────────────────────────────────────────── -// Unit tests — pure helpers only. K8s-API-touching paths are exercised -// by the kind-based integration harness. -// ───────────────────────────────────────────────────────────────────── #[cfg(test)] -mod tests { - use super::*; - use crate::kars_task::{KarsTaskSpec, TaskEnvelope}; - - fn task_with(tier: i32, authority_ceiling: i32, delegation_depth: i32) -> KarsTask { - let mut t = 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, - }, - ); - t.metadata.namespace = Some("default".into()); - t - } - - #[test] - fn valid_envelope_passes() { - let t = task_with(3, 3, 2); - assert!(matches!(check_envelope(&t), EnvelopeCheck::Valid)); - } - - #[test] - fn authority_ceiling_above_tier_is_rejected() { - let t = task_with(2, 4, 1); - match check_envelope(&t) { - EnvelopeCheck::Invalid(why) => assert!(why.contains("authorityCeiling")), - EnvelopeCheck::Valid => panic!("expected rejection"), - } - } - - #[test] - fn tier_out_of_range_is_rejected() { - let t = task_with(9, 5, 0); - assert!(matches!(check_envelope(&t), EnvelopeCheck::Invalid(_))); - } - - #[test] - fn finalizer_roundtrip() { - let mut t = task_with(1, 1, 0); - assert!(!has_finalizer(&t)); - t.metadata.finalizers = Some(vec![FINALIZER.to_string(), "other/keep".to_string()]); - assert!(has_finalizer(&t)); - let dropped = drop_finalizer(&t); - assert_eq!(dropped, vec!["other/keep".to_string()]); - } -} +#[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..6170abf0a --- /dev/null +++ b/controller/src/kars_task_reconciler_tests.rs @@ -0,0 +1,57 @@ +// 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 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()]); +} diff --git a/controller/src/kars_task_tests.rs b/controller/src/kars_task_tests.rs new file mode 100644 index 000000000..58083d6c2 --- /dev/null +++ b/controller/src/kars_task_tests.rs @@ -0,0 +1,398 @@ +// 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 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/deploy/helm/kars/templates/crd-karsapproval.yaml b/deploy/helm/kars/templates/crd-karsapproval.yaml index 221f7239f..90a28d009 100644 --- a/deploy/helm/kars/templates/crd-karsapproval.yaml +++ b/deploy/helm/kars/templates/crd-karsapproval.yaml @@ -114,6 +114,13 @@ spec: - 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 status: description: '`KarsApproval.status` — the controller is the sole writer.' nullable: true @@ -198,4 +205,3 @@ spec: storage: true subresources: status: {} - diff --git a/deploy/helm/kars/templates/crd-karsreceipt.yaml b/deploy/helm/kars/templates/crd-karsreceipt.yaml index 01508715a..f0f815136 100644 --- a/deploy/helm/kars/templates/crd-karsreceipt.yaml +++ b/deploy/helm/kars/templates/crd-karsreceipt.yaml @@ -27,6 +27,9 @@ spec: - jsonPath: .spec.keyId name: KeyId type: string + - jsonPath: .status.conditions[-1:].type + name: State + type: string - jsonPath: .metadata.creationTimestamp name: Age type: date @@ -130,12 +133,53 @@ spec: - 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 @@ -167,4 +211,3 @@ spec: storage: true subresources: status: {} - 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..b061953ae --- /dev/null +++ b/docs/security-audits/2026-09-03-core-governance-apis.md @@ -0,0 +1,54 @@ +# 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. +- 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 d6e22d7ae..f5f6e54c3 100644 --- a/inference-router/src/metrics.rs +++ b/inference-router/src/metrics.rs @@ -72,8 +72,12 @@ pub static TASK_TOKENS_USED: LazyLock = LazyLock::new(|| { /// 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())); +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). @@ -82,7 +86,9 @@ pub fn parse_task_attribution( 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()); + let root = root + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| task.clone()); Some((task, root)) } From 8953d376a800e02ec73b0341fafb8b783565cf55 Mon Sep 17 00:00:00 2001 From: pallakatos Date: Mon, 7 Sep 2026 14:33:57 +0200 Subject: [PATCH 14/23] fix(core): close task ownership and governance binding gaps Use atomic creation and UID/resourceVersion guarded execution updates and cleanup. Keep empty task egress Strict, normalize runtimes, bind approvals immutably, and fail closed on unsupported subtree token/money launch ceilings. Report completeness as unverified and retry namespace-aware signer initialization. Add API-boundary and contract regressions; Rust execution awaits coordinated validation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- controller/src/crd_validations.rs | 83 +++++- controller/src/kars_approval.rs | 109 ++++++- controller/src/kars_approval_reconciler.rs | 192 ++++++++++-- controller/src/kars_receipt.rs | 29 +- controller/src/kars_receipt_log.rs | 14 +- controller/src/kars_task.rs | 73 ++++- controller/src/kars_task_execution.rs | 275 +++++++++++++----- controller/src/kars_task_execution_tests.rs | 192 ++++++++++++ controller/src/kars_task_reconciler.rs | 167 +++++------ controller/src/kars_task_reconciler_tests.rs | 41 +++ controller/src/kars_task_tests.rs | 106 +++++++ controller/src/providers/signing.rs | 40 ++- .../helm/kars/templates/crd-karsapproval.yaml | 34 ++- deploy/helm/kars/templates/crd-karstask.yaml | 39 ++- docs/api/crd-reference.md | 51 +++- 15 files changed, 1185 insertions(+), 260 deletions(-) create mode 100644 controller/src/kars_task_execution_tests.rs diff --git a/controller/src/crd_validations.rs b/controller/src/crd_validations.rs index 7a42de24b..983e9ef42 100644 --- a/controller/src/crd_validations.rs +++ b/controller/src/crd_validations.rs @@ -570,11 +570,35 @@ pub fn kars_task_validations() -> Vec { ..ValidationRule::default() }, ValidationRule { - rule: "!has(self.blueprint) || !has(self.blueprint.runtime) || self.blueprint.runtime in ['OpenClaw','OpenAIAgents','MAF','MicrosoftAgentFramework','Hermes','BYO']".into(), - message: Some("spec.blueprint.runtime must be one of OpenClaw, OpenAIAgents, MAF, MicrosoftAgentFramework, Hermes, BYO".into()), + 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()), @@ -657,14 +681,65 @@ pub fn kars_approval_validations() -> Vec { 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 { - inject_spec_validations(KarsApproval::crd(), kars_approval_validations()) - .expect("kube-rs derive must produce a spec property on KarsApproval") + 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. diff --git a/controller/src/kars_approval.rs b/controller/src/kars_approval.rs index aa9016dd5..80804e5ef 100644 --- a/controller/src/kars_approval.rs +++ b/controller/src/kars_approval.rs @@ -35,8 +35,8 @@ //! - `Stale` — the bound task envelope drifted (or the task vanished) before //! a decision; the request no longer applies to current authority. //! -//! A human decision wins over expiry/staleness: if a person decided, that is -//! the governance truth and it is recorded. +//! 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; @@ -96,6 +96,7 @@ pub struct KarsApprovalSpec { /// 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 @@ -111,13 +112,16 @@ pub struct KarsApprovalSpec { 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 @@ -142,14 +146,17 @@ impl Default for ApprovalAction { #[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, } @@ -169,8 +176,8 @@ pub struct KarsApprovalStatus { #[serde(default, skip_serializing_if = "Option::is_none")] pub observed_generation: Option, - /// RFC-3339 time the controller first reconciled the request. The TTL is - /// measured from here; re-reconciles never bump it. + /// 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, @@ -188,6 +195,15 @@ pub struct KarsApprovalStatus { #[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, @@ -236,21 +252,22 @@ impl ApprovalOutcome { /// and the bound digest (binding the latter on first observation) and supplies /// them here, so all decision logic is testable without a cluster. /// -/// Precedence: -/// 1. A recorded human decision wins over everything (it is the governance -/// truth, even if the request later expired or went stale). -/// 2. Otherwise, an unbound approval is `Pending` (awaiting the task envelope). -/// 3. A bound approval whose task digest drifted (or whose task vanished) is -/// `Stale`. -/// 4. A bound, current approval past its TTL is `Expired`. -/// 5. Otherwise `Pending` (awaiting a decision). +/// 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(), @@ -271,6 +288,9 @@ fn undecided_outcome( 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)"); }; @@ -281,11 +301,20 @@ fn undecided_outcome( Some(live) if live != bound => ApprovalOutcome::Stale(format!( "task envelope drifted since the request (bound {bound}, current {live})" )), - Some(_) if expired => ApprovalOutcome::Expired, 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() +} + #[cfg(test)] mod tests { use super::*; @@ -298,6 +327,46 @@ mod tests { } } + #[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( @@ -326,15 +395,23 @@ mod tests { } #[test] - fn decision_wins_over_expiry_and_staleness() { - // Expired + drifted, but a human decided → the decision stands. + 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_APPROVED); + 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] diff --git a/controller/src/kars_approval_reconciler.rs b/controller/src/kars_approval_reconciler.rs index 7027a04be..970f768df 100644 --- a/controller/src/kars_approval_reconciler.rs +++ b/controller/src/kars_approval_reconciler.rs @@ -34,7 +34,9 @@ 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}; +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}; @@ -121,14 +123,22 @@ async fn reconcile(approval: Arc, ctx: Arc) -> Result = Api::namespaced(ctx.client.clone(), &ns); - let live_task_digest = tasks - .get_opt(&approval.spec.task_ref.name) - .await? - .and_then(|t| t.status.and_then(|s| s.envelope_digest)); + 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. @@ -136,6 +146,11 @@ async fn reconcile(approval: Arc, ctx: Arc) -> Result, ctx: Arc) -> Result= expires_at; - let outcome = evaluate( - approval.spec.decision.as_ref(), - bound_digest.as_deref(), - live_task_digest.as_deref(), - expired, - ); + 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 new_status = build_status( + let mut new_status = build_status( &prior, generation, &outcome, @@ -165,20 +184,21 @@ async fn reconcile(approval: Arc, ctx: Arc) -> Result, ctx: Arc) -> Result 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 { @@ -281,6 +332,8 @@ fn build_status( .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]), } @@ -364,6 +417,111 @@ mod tests { 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.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"); + } + #[test] fn decided_at_is_set_once_and_preserved() { let now = Utc::now(); diff --git a/controller/src/kars_receipt.rs b/controller/src/kars_receipt.rs index fdf0b4864..01ee54aa6 100644 --- a/controller/src/kars_receipt.rs +++ b/controller/src/kars_receipt.rs @@ -205,23 +205,16 @@ pub struct Predicate { pub issuer: PredicateIssuer, } -/// The enforced-controls evidence behind the `completeness` claim. Every field -/// is an observation the controller can verify from cluster state, so an -/// auditor can re-derive it. The *runtime* iptables-ruleset hash and the eBPF -/// kernel-datapath witness are deliberately absent in V0 (named V1/V2 in the -/// claim detail) — we never imply we captured them. +/// 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 { - /// The CREATE-time task-namespace floor VAP is installed. pub task_namespace_floor_vap: bool, - /// The exec/attach ban VAP is installed. pub exec_ban_vap: bool, - /// The posture-lock (UPDATE downgrade) VAP is installed. pub posture_lock_vap: bool, - /// A cluster-default-deny egress NetworkPolicy is installed. pub default_deny_egress: bool, - /// `true` once **all** of the above floor controls are present. + /// `true` only when all effective controls above are verified. pub floor_enforced: bool, } @@ -460,10 +453,7 @@ pub fn build_spec( } } -/// Convert decided `KarsApproval`s for a task into deterministic receipt -/// facts. Only **Approved** or **Denied** approvals (a real human decision) -/// are included; Pending/Expired/Stale ones are not part of the attested -/// human-decision record. Sorted by name so the signed payload is stable. +/// Stable, sorted receipt facts from decided approvals with unchanged requests. pub fn approval_facts(approvals: &[crate::kars_approval::KarsApproval]) -> Vec { use crate::kars_approval::{PHASE_APPROVED, PHASE_DENIED}; use kube::ResourceExt; @@ -472,6 +462,11 @@ pub fn approval_facts(approvals: &[crate::kars_approval::KarsApproval]) -> Vec

"approve", @@ -689,7 +684,6 @@ mod tests { 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)); - // The signed payload carries the human decision. let json = String::from_utf8(canonical_json(&st)).unwrap(); assert!(json.contains("\"approvals\"")); assert!(json.contains("alice@example.com")); @@ -720,6 +714,7 @@ mod tests { phase: phase.map(|s| s.to_string()), decider: decider.map(|s| s.to_string()), 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 @@ -731,12 +726,14 @@ mod tests { mk("stale-one", Some("Stale"), None), ]; let facts = approval_facts(&approvals); - // Only the two decided ones, sorted by name. 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] diff --git a/controller/src/kars_receipt_log.rs b/controller/src/kars_receipt_log.rs index d7413b758..00295858c 100644 --- a/controller/src/kars_receipt_log.rs +++ b/controller/src/kars_receipt_log.rs @@ -40,7 +40,7 @@ use kube::{ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; -use crate::mesh_peer::IDENTITY_NAMESPACE; +use crate::providers::signing::receipt_namespace; /// ConfigMap holding the hash-chained inclusion log. pub const LOG_CONFIGMAP_NAME: &str = "kars-receipt-log"; @@ -165,7 +165,7 @@ pub async fn append( receipt: &str, payload_sha256: &str, ) -> Result { - let cms: Api = Api::namespaced(client.clone(), IDENTITY_NAMESPACE); + let cms: Api = Api::namespaced(client.clone(), &receipt_namespace()); for _ in 0..MAX_APPEND_RETRIES { let existing = cms.get_opt(LOG_CONFIGMAP_NAME).await?; @@ -204,7 +204,7 @@ pub async fn append( "kind": "ConfigMap", "metadata": { "name": LOG_CONFIGMAP_NAME, - "namespace": IDENTITY_NAMESPACE, + "namespace": receipt_namespace(), "labels": { "app.kubernetes.io/name": "kars", "app.kubernetes.io/component": "receipt-inclusion-log", @@ -220,7 +220,7 @@ pub async fn append( "kind": "ConfigMap", "metadata": { "name": LOG_CONFIGMAP_NAME, - "namespace": IDENTITY_NAMESPACE, + "namespace": receipt_namespace(), "resourceVersion": resource_version, }, "data": { CHAIN_KEY: chain_json }, @@ -245,7 +245,7 @@ pub async fn append( /// 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(), IDENTITY_NAMESPACE); + let cms: Api = Api::namespaced(client.clone(), &receipt_namespace()); let cm = cms.get_opt(LOG_CONFIGMAP_NAME).await?; Ok(cm .and_then(|c| { @@ -308,13 +308,13 @@ pub async fn publish_checkpoint( signature, }; - let cms: Api = Api::namespaced(client.clone(), IDENTITY_NAMESPACE); + 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": IDENTITY_NAMESPACE, + "namespace": receipt_namespace(), "labels": { "app.kubernetes.io/name": "kars", "app.kubernetes.io/component": "receipt-checkpoint", diff --git a/controller/src/kars_task.rs b/controller/src/kars_task.rs index bacda0f73..72a2c51fe 100644 --- a/controller/src/kars_task.rs +++ b/controller/src/kars_task.rs @@ -115,9 +115,9 @@ pub struct KarsTaskSpec { #[derive(Debug, Serialize, Deserialize, Default, Clone, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct TaskBlueprint { - /// Harness/runtime the agent runs on (`OpenClaw`, `OpenAIAgents`, `MAF`, - /// `Hermes`, `BYO`). Drives `KarsSandbox.spec.runtime.kind`. Defaults to - /// `OpenClaw`. + /// 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, @@ -149,8 +149,8 @@ pub struct TaskBlueprint { pub mcp_servers: Vec, /// Network destinations the mission may reach. Drives - /// `KarsSandbox.spec.networkPolicy.allowedEndpoints`. When non-empty the - /// sandbox runs in strict egress mode bounded to exactly these hosts. + /// `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, @@ -228,9 +228,9 @@ pub struct TaskEnvelope { #[serde(default, skip_serializing_if = "Option::is_none")] pub tool_policy_ref: Option, - /// Optional reference to a same-namespace `EgressAllowlist`-style CR that - /// bounds the network destinations this task (and its descendants) may - /// reach through the inference router. + /// 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, @@ -483,11 +483,11 @@ fn attenuate_budget_axis( axis: BudgetAxis, out: &mut Vec, ) { - let Some(parent_cap) = parent else { + 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 { + match child.filter(|cap| *cap > 0) { None => out.push(EnvelopeViolation::BudgetUnbounded { axis, parent: parent_cap, @@ -531,6 +531,7 @@ 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 @@ -551,6 +552,52 @@ pub fn effective_egress(spec: &KarsTaskSpec) -> &[TaskEgress] { .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 = 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"); + match runtime { + "OpenClaw" => Ok(RuntimeKind::OpenClaw), + "OpenAIAgents" => Ok(RuntimeKind::OpenAIAgents), + "MAF" | "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. @@ -601,12 +648,14 @@ pub fn spec_attenuation_violations( #[serde(rename_all = "camelCase")] pub struct TaskBudget { /// Maximum total tokens the task subtree may consume. `0`/absent means - /// "no token cap declared" (governance still applies at the router). + /// "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. - /// Integer micro-USD avoids floating-point in an audit-bound field. + /// `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, } diff --git a/controller/src/kars_task_execution.rs b/controller/src/kars_task_execution.rs index d0ad5cd4e..8ef08a2ea 100644 --- a/controller/src/kars_task_execution.rs +++ b/controller/src/kars_task_execution.rs @@ -19,7 +19,7 @@ //! sandbox materializes but degrades at the inference step — the controller //! surfaces that verbatim in `status.executionDetail` rather than hiding it. -use kube::api::{Api, DynamicObject, ObjectMeta, Patch, PatchParams}; +use kube::api::{Api, DeleteParams, DynamicObject, ObjectMeta, PostParams, Preconditions}; use kube::core::ApiResource; use kube::{Client, ResourceExt}; use serde_json::json; @@ -58,8 +58,7 @@ pub struct ExecutionOutcome { pub detail: String, } -/// The owner reference making materialized resources cascade-delete with the -/// task and be server-side-apply-owned by this controller. +/// Controller owner reference binding materialized resources to the task UID. fn owner_ref(task: &KarsTask) -> serde_json::Value { json!([{ "apiVersion": "kars.azure.com/v1alpha1", @@ -71,14 +70,41 @@ fn owner_ref(task: &KarsTask) -> serde_json::Value { }]) } -/// Runtime variant key for the sandbox spec discriminator. -fn runtime_variant_key(kind: &str) -> &'static str { +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 { - "Hermes" => "hermes", - "OpenAIAgents" => "openaiAgents", - "MAF" => "maf", - _ => "openclaw", + 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(kube::core::ErrorResponse { + status: "Failure".into(), + message, + reason: "Conflict".into(), + code: 409, + }) +} + +fn network_policy(blueprint: &TaskBlueprint) -> serde_json::Value { + json!({ + "defaultDeny": true, + "egressMode": "Strict", + "allowedEndpoints": blueprint.egress, + }) } /// Resolve the default `(deployment, provider)` a task-materialized @@ -125,30 +151,19 @@ fn build_instructions(objective: &str, extra: Option<&str>) -> String { out } -/// Materialize (or re-apply) the InferencePolicy + KarsSandbox for a launched -/// task, then read back the sandbox phase. Idempotent via server-side apply. +/// 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 = task.spec.blueprint.clone().unwrap_or_default(); - // Runtime: blueprint wins, then execution.runtime, then OpenClaw. - let runtime_kind = blueprint - .runtime - .clone() - .filter(|s| !s.trim().is_empty()) - .or_else(|| { - task.spec - .execution - .as_ref() - .and_then(|e| e.runtime.clone()) - .filter(|s| !s.trim().is_empty()) - }) - .unwrap_or_else(|| "OpenClaw".to_string()); + let runtime = runtime_spec(task)?; // 1. InferencePolicy scoped to this sandbox. Model: blueprint wins, else // the controller default (required — without it the sandbox degrades). @@ -163,17 +178,12 @@ pub async fn materialize( } _ => default_model(), }; - let mut inference_spec = json!({ + let inference_spec = json!({ "appliesTo": { "sandboxName": task_name }, "modelPreference": { "primary": { "provider": model_provider, "deployment": model_deployment }, }, }); - if let Some(tokens) = envelope.budget.as_ref().and_then(|b| b.tokens) - && tokens > 0 - { - inference_spec["tokenBudget"] = json!({ "dailyTokens": tokens }); - } apply_dynamic( client, namespace, @@ -193,33 +203,12 @@ pub async fn materialize( .filter(|s| !s.trim().is_empty()) .unwrap_or_else(|| "standard".to_string()); let mut sandbox_spec = json!({ - "runtime": { - "kind": runtime_kind, - runtime_variant_key(&runtime_kind): {}, - }, + "runtime": runtime, "inferenceRef": { "name": inference_name }, "sandbox": { "isolation": isolation }, - "networkPolicy": { "defaultDeny": true }, + "networkPolicy": network_policy(&blueprint), }); - // Egress: when the blueprint names destinations, bound the sandbox to - // exactly those hosts in strict mode (the substance of "what it can reach"). - if !blueprint.egress.is_empty() { - let endpoints: Vec = blueprint - .egress - .iter() - .map(|e| match e.port { - Some(p) => json!({ "host": e.host, "port": p }), - None => json!({ "host": e.host }), - }) - .collect(); - sandbox_spec["networkPolicy"] = json!({ - "defaultDeny": true, - "egressMode": "Strict", - "allowedEndpoints": endpoints, - }); - } - // 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. @@ -271,6 +260,11 @@ pub async fn materialize( 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") @@ -296,23 +290,90 @@ pub async fn materialize( /// 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<(), kube::Error> { - use kube::api::DeleteParams; +) -> 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()); - // Best-effort: ignore 404s. - let _ = sb_api.delete(&task_name, &DeleteParams::default()).await; - let _ = ip_api - .delete(&format!("{task_name}-inference"), &DeleteParams::default()) - .await; - Ok(()) + 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` @@ -367,8 +428,8 @@ fn map_sandbox_phase(sb_phase: &str) -> (String, String) { } } -/// Server-side-apply an owned dynamic object (spec only; status is the target -/// reconciler's). Idempotent — safe to call every reconcile. +/// 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, @@ -378,6 +439,9 @@ async fn apply_dynamic( 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 { @@ -395,12 +459,35 @@ async fn apply_dynamic( ..Default::default() }; obj.data = json!({ "spec": spec }); - api.patch( - name, - &PatchParams::apply(FIELD_MANAGER).force(), - &Patch::Apply(&obj), - ) - .await?; + 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(()) } @@ -454,13 +541,53 @@ mod tests { } #[test] - fn runtime_variant_keys() { - assert_eq!(runtime_variant_key("OpenClaw"), "openclaw"); - assert_eq!(runtime_variant_key("Hermes"), "hermes"); - assert_eq!(runtime_variant_key("OpenAIAgents"), "openaiAgents"); - assert_eq!(runtime_variant_key("anything-else"), "openclaw"); + 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" + ); + } + + #[cfg(test)] + #[path = "kars_task_execution_tests.rs"] + mod api_tests; + #[test] fn governance_disabled_without_tool_policy() { let e = TaskEnvelope { diff --git a/controller/src/kars_task_execution_tests.rs b/controller/src/kars_task_execution_tests.rs new file mode 100644 index 000000000..f905bcaed --- /dev/null +++ b/controller/src/kars_task_execution_tests.rs @@ -0,0 +1,192 @@ +// 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 { + ResponseTemplate::new(code).set_body_json(json!({ + "apiVersion": "v1", "kind": "Status", "status": "Failure", + "message": "test API failure", "reason": "Failure", "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); +} diff --git a/controller/src/kars_task_reconciler.rs b/controller/src/kars_task_reconciler.rs index d2dc896e1..46d1f8ece 100644 --- a/controller/src/kars_task_reconciler.rs +++ b/controller/src/kars_task_reconciler.rs @@ -12,10 +12,9 @@ //! condition, and `status.envelopeDigest`, preserving any `lineage` //! written by the delegation-minting path (next slice). //! -//! This reconciler is intentionally side-effect-free on the cluster for V0: -//! it materializes verifiable *status* (the digest a Governance Receipt binds -//! to), not yet a governed sandbox. Sandbox materialization and -//! capability-attenuating child minting build on this in the following slices. +//! 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; @@ -89,12 +88,15 @@ fn check_envelope(task: &KarsTask) -> EnvelopeCheck { e.authority_ceiling, e.tier )); } - if e.delegation_depth < 0 { + if !(0..=16).contains(&e.delegation_depth) { return EnvelopeCheck::Invalid(format!( - "delegationDepth {} must be >= 0", + "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 } @@ -110,17 +112,22 @@ async fn reconcile(task: Arc, ctx: Arc) -> Result = Api::namespaced(ctx.client.clone(), &ns); - // Deletion: drop the finalizer and let the API server reap the object. - // There is nothing cluster-side to clean up in V0. + // 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": { "finalizers": drop_finalizer(&task) } }); + let patch = json!({ "metadata": { + "uid": task.uid(), "resourceVersion": task.resource_version(), + "finalizers": drop_finalizer(&task), + } }); tasks .patch(&name, &PatchParams::default(), &Patch::Merge(patch)) .await?; @@ -251,7 +258,11 @@ async fn reconcile(task: Arc, ctx: Arc) -> Result bool { +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_ref() - .is_some_and(|d| !d.is_empty()); + let digest_ok = status.envelope_digest.as_deref() == Some(task.spec.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 + 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. @@ -463,23 +476,27 @@ async fn reconcile_execution( 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. - if task - .status - .as_ref() - .and_then(|s| s.sandbox_ref.as_ref()) - .is_some() - && let Err(e) = crate::kars_task_execution::teardown(client, ns, task).await - { - tracing::warn!(karstask = %task.name_any(), ns = %ns, error = %e, "KarsTask execution teardown failed"); + 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(), + }); + } } - status.execution_phase = Some("Idle".to_string()); - status.sandbox_ref = None; - status.execution_detail = None; } } @@ -516,7 +533,12 @@ async fn reconcile_receipt( Ok(list) => list .items .into_iter() - .filter(|a| a.spec.task_ref.name == name) + .filter(|a| { + a.spec.task_ref.name == name + && task.uid().is_some() + && a.status.as_ref().and_then(|s| s.bound_task_uid.as_ref()) + == task.metadata.uid.as_ref() + }) .collect::>(), Err(e) => { tracing::debug!(karstask = %name, ns = %ns, error = %e, "could not list KarsApprovals for receipt"); @@ -529,7 +551,7 @@ async fn reconcile_receipt( // 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(client).await; + let completeness = gather_completeness(); let Some(statement) = build_statement(task, status, &signer.key_id, &facts, completeness) else { @@ -647,50 +669,11 @@ async fn reconcile_receipt( tracing::info!(karstask = %name, ns = %ns, key_id = %signer.key_id, "Governance Receipt emitted"); } -/// Observe which completeness-floor controls (design note §24b) are enforced -/// on the cluster, for binding into the receipt. Best-effort: any read error -/// yields a conservative `false` (we never claim a control is enforced unless -/// we positively observed it). The runtime egress-guard iptables hash and the -/// eBPF witness are intentionally NOT gathered here — they are V1/V2. -async fn gather_completeness(client: &kube::Client) -> crate::kars_receipt::PredicateCompleteness { - use k8s_openapi::api::admissionregistration::v1::ValidatingAdmissionPolicy; - use k8s_openapi::api::networking::v1::NetworkPolicy; - - let vaps: Api = Api::all(client.clone()); - let vap_present = |name: &str, list: &[ValidatingAdmissionPolicy]| -> bool { - list.iter() - .any(|p| p.metadata.name.as_deref() == Some(name)) - }; - let vap_list = vaps - .list(&ListParams::default()) - .await - .map(|l| l.items) - .unwrap_or_default(); - - // A cluster-wide default-deny egress NetworkPolicy is installed by the - // operator chart in kars-system; treat its presence there as the floor. - let nps: Api = Api::namespaced(client.clone(), "kars-system"); - let default_deny_egress = nps - .list(&ListParams::default()) - .await - .map(|l| { - l.items.iter().any(|np| { - np.spec - .as_ref() - .and_then(|s| s.policy_types.as_ref()) - .is_some_and(|t| t.iter().any(|pt| pt == "Egress")) - }) - }) - .unwrap_or(false); - - crate::kars_receipt::PredicateCompleteness { - task_namespace_floor_vap: vap_present("kars-task-namespace-floor", &vap_list), - exec_ban_vap: vap_present("kars-sandbox-exec-ban", &vap_list), - posture_lock_vap: vap_present("kars-sandbox-posture-lock", &vap_list), - default_deny_egress, - floor_enforced: false, - } - .with_rollup() +/// 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. @@ -725,25 +708,29 @@ fn error_policy(task: Arc, error: &ReconcileError, _ctx: Arc) -> pub async fn run(client: Client) -> Result<()> { let tasks: Api = Api::all(client.clone()); - match tasks.list(&ListParams::default().limit(1)).await { - Ok(_) => tracing::info!("KarsTask CRD found — starting controller"), - Err(e) => { - tracing::warn!("KarsTask CRD not installed — reconciler disabled: {e}"); - std::future::pending::<()>().await; - #[allow(unreachable_code)] - return Ok(()); + 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 = match crate::providers::signing::load_or_create(&client).await { - Ok(s) => { - tracing::info!(key_id = %s.key_id, "Governance Receipt signer ready"); - s - } - Err(e) => { - tracing::error!(error = %e, "failed to initialise receipt signer — KarsTask reconciler disabled"); - std::future::pending::<()>().await; - #[allow(unreachable_code)] - return Ok(()); + 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 }); diff --git a/controller/src/kars_task_reconciler_tests.rs b/controller/src/kars_task_reconciler_tests.rs index 6170abf0a..6c64965ea 100644 --- a/controller/src/kars_task_reconciler_tests.rs +++ b/controller/src/kars_task_reconciler_tests.rs @@ -31,6 +31,47 @@ fn valid_envelope_passes() { 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.spec.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); +} + #[test] fn authority_ceiling_above_tier_is_rejected() { let task = task_with(2, 4, 1); diff --git a/controller/src/kars_task_tests.rs b/controller/src/kars_task_tests.rs index 58083d6c2..833e361de 100644 --- a/controller/src/kars_task_tests.rs +++ b/controller/src/kars_task_tests.rs @@ -25,6 +25,112 @@ fn envelope_digest_is_deterministic() { 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(); diff --git a/controller/src/providers/signing.rs b/controller/src/providers/signing.rs index 997aa512a..53d3e16fa 100644 --- a/controller/src/providers/signing.rs +++ b/controller/src/providers/signing.rs @@ -49,7 +49,22 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; -use crate::mesh_peer::IDENTITY_NAMESPACE; +/// 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"; @@ -174,7 +189,7 @@ pub fn pae(payload_type: &str, body: &[u8]) -> Vec { /// 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(), IDENTITY_NAMESPACE); + let secrets: Api = Api::namespaced(client.clone(), &receipt_namespace()); let signer = match secrets.get(IDENTITY_SECRET_NAME).await { Ok(secret) => { @@ -190,8 +205,9 @@ pub async fn load_or_create(client: &Client) -> Result { signer } None => { - tracing::warn!("Receipt identity Secret malformed — regenerating"); - create_identity(&secrets).await? + anyhow::bail!( + "Receipt identity Secret malformed; refusing to rotate the trust anchor" + ) } } } @@ -214,7 +230,7 @@ async fn create_identity(secrets: &Api) -> Result { "kind": "Secret", "metadata": { "name": IDENTITY_SECRET_NAME, - "namespace": IDENTITY_NAMESPACE, + "namespace": receipt_namespace(), }, "data": { "signing_key": BASE64.encode(signer.signing_key.to_bytes()), @@ -233,13 +249,13 @@ async fn create_identity(secrets: &Api) -> Result { /// 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(), IDENTITY_NAMESPACE); + 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": IDENTITY_NAMESPACE, + "namespace": receipt_namespace(), "labels": { "app.kubernetes.io/name": "kars", "app.kubernetes.io/component": "receipt-trust-anchor", @@ -268,6 +284,16 @@ 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 diff --git a/deploy/helm/kars/templates/crd-karsapproval.yaml b/deploy/helm/kars/templates/crd-karsapproval.yaml index 90a28d009..2e0fc95aa 100644 --- a/deploy/helm/kars/templates/crd-karsapproval.yaml +++ b/deploy/helm/kars/templates/crd-karsapproval.yaml @@ -49,12 +49,14 @@ spec: 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: |- @@ -65,6 +67,7 @@ spec: type: integer summary: description: One-line, human-readable statement of what the agent wants to do. + maxLength: 4096 type: string required: - kind @@ -81,13 +84,16 @@ spec: 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 @@ -99,6 +105,7 @@ spec: controller binds the approval to this task's envelope digest. properties: name: + maxLength: 253 type: string required: - name @@ -108,6 +115,7 @@ spec: 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: @@ -121,6 +129,18 @@ spec: - 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 @@ -131,6 +151,16 @@ spec: controller from the task's `status.envelopeDigest`; never changes. 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 @@ -192,8 +222,8 @@ spec: type: string requestedAt: description: |- - RFC-3339 time the controller first reconciled the request. The TTL is - measured from here; re-reconciles never bump it. + 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 diff --git a/deploy/helm/kars/templates/crd-karstask.yaml b/deploy/helm/kars/templates/crd-karstask.yaml index 999a438c1..177544fc8 100644 --- a/deploy/helm/kars/templates/crd-karstask.yaml +++ b/deploy/helm/kars/templates/crd-karstask.yaml @@ -58,8 +58,8 @@ spec: egress: description: |- Network destinations the mission may reach. Drives - `KarsSandbox.spec.networkPolicy.allowedEndpoints`. When non-empty the - sandbox runs in strict egress mode bounded to exactly these hosts. + `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: @@ -127,9 +127,9 @@ spec: type: object runtime: description: |- - Harness/runtime the agent runs on (`OpenClaw`, `OpenAIAgents`, `MAF`, - `Hermes`, `BYO`). Drives `KarsSandbox.spec.runtime.kind`. Defaults to - `OpenClaw`. + 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: @@ -164,14 +164,16 @@ spec: tokens: description: |- Maximum total tokens the task subtree may consume. `0`/absent means - "no token cap declared" (governance still applies at the router). + "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. - Integer micro-USD avoids floating-point in an audit-bound field. + `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 @@ -186,9 +188,9 @@ spec: type: integer egressAllowlistRef: description: |- - Optional reference to a same-namespace `EgressAllowlist`-style CR that - bounds the network destinations this task (and its descendants) may - reach through the inference router. + 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: @@ -293,9 +295,21 @@ spec: - 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 one of OpenClaw, OpenAIAgents, MAF, MicrosoftAgentFramework, Hermes, BYO + - 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'',''BYO'']' + 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'']' @@ -410,4 +424,3 @@ spec: storage: true subresources: status: {} - diff --git a/docs/api/crd-reference.md b/docs/api/crd-reference.md index f2009f861..2a5f8114e 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,53 @@ 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. + +`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 envelope, and checks expiry before accepting the first decision. +Terminal decisions remain stable; they cannot be replayed for a replacement task. +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 +75,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`). From 68d7da52566e010940ef08c421d20de4171eebc4 Mon Sep 17 00:00:00 2001 From: pallakatos Date: Mon, 7 Sep 2026 14:34:06 +0200 Subject: [PATCH 15/23] fix(cli): trust only signed governance receipt claims Derive claims from verified DSSE predicates and reject mismatched claim, identity, digest, issuer and scheme echoes. Resolve the verifier namespace consistently with the controller. Cover forged PASS echoes with a valid signature and related unsigned metadata tampering. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- cli/src/commands/receipt.test.ts | 42 ++++++++++++++ cli/src/commands/receipt.ts | 97 +++++++++++++++++++++++--------- 2 files changed, 113 insertions(+), 26 deletions(-) diff --git a/cli/src/commands/receipt.test.ts b/cli/src/commands/receipt.test.ts index 823e8d137..def39f9d0 100644 --- a/cli/src/commands/receipt.test.ts +++ b/cli/src/commands/receipt.test.ts @@ -26,6 +26,9 @@ function makeSignedReceipt(overrides?: { tamperPayload?: boolean; wrongKey?: boo ], 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" }, @@ -116,6 +119,45 @@ describe("receipt verify — verifyReceipt", () => { ); 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", () => { diff --git a/cli/src/commands/receipt.ts b/cli/src/commands/receipt.ts index f3a551d61..7ef544f35 100644 --- a/cli/src/commands/receipt.ts +++ b/cli/src/commands/receipt.ts @@ -28,13 +28,18 @@ import { Command } from "commander"; import chalk from "chalk"; import { createHash, createPublicKey, verify as cryptoVerify } from "node:crypto"; +import { isDeepStrictEqual } from "node:util"; -const ANCHOR_NAMESPACE = "kars-system"; +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. @@ -131,6 +136,7 @@ export function verifyReceipt(receipt: ReceiptCr, anchor: TrustAnchor): VerifyRe 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" }); @@ -183,25 +189,64 @@ export function verifyReceipt(receipt: ReceiptCr, anchor: TrustAnchor): VerifyRe sigDetail = `signature verification error: ${(e as Error).message}`; } checks.push({ name: "signature", ok: sigOk, detail: sigDetail }); + signatureVerified = sigOk && keyMatchesAnchor && declaredMatches && ptOk; } } - // Binding: the signed subject digest must match the receipt's claimed - // envelopeDigest (sans the `sha256:` prefix the in-toto field drops). - const claimedDigest = spec.envelopeDigest ?? null; - if (statement && claimedDigest) { - const subj = (statement as { subject?: Array<{ digest?: { sha256?: string } }> }).subject; - const signedDigest = subj?.[0]?.digest?.sha256 ?? null; - const want = claimedDigest.replace(/^sha256:/, ""); - const bound = signedDigest === want; - checks.push({ - name: "envelopeBinding", - ok: bound, - detail: bound - ? `subject bound to envelope ${claimedDigest}` - : `subject digest '${signedDigest}' != claimed '${want}'`, - }); - } + 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 { @@ -209,9 +254,9 @@ export function verifyReceipt(receipt: ReceiptCr, anchor: TrustAnchor): VerifyRe task, namespace, keyId: anchor.keyId, - envelopeDigest: claimedDigest, + envelopeDigest: signatureVerified && typeof signedDigest === "string" ? signedDigest : null, checks, - claims: spec.claims ?? [], + claims: signatureVerified && claimsValid ? signedClaims : [], statement, }; } @@ -232,7 +277,7 @@ async function fetchAnchor(): Promise { "configmap", ANCHOR_CONFIGMAP, "-n", - ANCHOR_NAMESPACE, + anchorNamespace(), ])) as { data?: Record } | null; const data = cm?.data; if (!data?.keyId || !data?.publicKey) return null; @@ -285,7 +330,7 @@ async function fetchInclusionChain(): Promise { "configmap", LOG_CONFIGMAP, "-n", - ANCHOR_NAMESPACE, + anchorNamespace(), ])) as { data?: Record } | null; const raw = cm?.data?.["chain.json"]; if (!raw) return null; @@ -321,7 +366,7 @@ async function fetchCheckpoint(): Promise { "configmap", CHECKPOINT_CONFIGMAP, "-n", - ANCHOR_NAMESPACE, + anchorNamespace(), ])) as { data?: Record } | null; const d = cm?.data; if (!d?.signature || !d?.rootHash || d?.treeSize === undefined) return null; @@ -505,7 +550,7 @@ export function receiptCommand(): Command { if (!anchor) { process.stderr.write( chalk.red( - `✗ trust anchor '${ANCHOR_CONFIGMAP}' not found in '${ANCHOR_NAMESPACE}'.\n` + + `✗ trust anchor '${ANCHOR_CONFIGMAP}' not found in '${anchorNamespace()}'.\n` + ` Cannot verify a receipt without the controller's published public key.\n`, ), ); @@ -575,7 +620,7 @@ export function receiptCommand(): Command { if (!chain) { process.stderr.write( chalk.yellow( - `No inclusion log found (${LOG_CONFIGMAP} in ${ANCHOR_NAMESPACE}). ` + + `No inclusion log found (${LOG_CONFIGMAP} in ${anchorNamespace()}). ` + `It is created when the first Governance Receipt is emitted.\n`, ), ); @@ -616,7 +661,7 @@ export function receiptCommand(): Command { if (!checkpoint) { process.stderr.write( chalk.yellow( - `No signed checkpoint found (${CHECKPOINT_CONFIGMAP} in ${ANCHOR_NAMESPACE}). ` + + `No signed checkpoint found (${CHECKPOINT_CONFIGMAP} in ${anchorNamespace()}). ` + `It is published when the first Governance Receipt is emitted.\n`, ), ); @@ -625,7 +670,7 @@ export function receiptCommand(): Command { const anchor = await fetchAnchor(); if (!anchor) { process.stderr.write( - chalk.red(`✗ trust anchor '${ANCHOR_CONFIGMAP}' not found in '${ANCHOR_NAMESPACE}'.\n`), + chalk.red(`✗ trust anchor '${ANCHOR_CONFIGMAP}' not found in '${anchorNamespace()}'.\n`), ); process.exit(5); return; From 23ed1ec9c1a67ff0995af4a3ae70f4a6a5cd88bb Mon Sep 17 00:00:00 2001 From: pallakatos Date: Mon, 7 Sep 2026 14:36:41 +0200 Subject: [PATCH 16/23] fix(core): guard governance status writes against entity replacement Carry task UID/resourceVersion through status writes, make approval finalizer updates correctly named and race-checked, and cover cleanup errors retaining Stopping until a successful retry. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- controller/src/kars_approval_reconciler.rs | 17 +++--- controller/src/kars_task_reconciler.rs | 4 ++ controller/src/kars_task_reconciler_tests.rs | 59 ++++++++++++++++++++ 3 files changed, 73 insertions(+), 7 deletions(-) diff --git a/controller/src/kars_approval_reconciler.rs b/controller/src/kars_approval_reconciler.rs index 970f768df..0c4ebdf65 100644 --- a/controller/src/kars_approval_reconciler.rs +++ b/controller/src/kars_approval_reconciler.rs @@ -90,14 +90,13 @@ async fn reconcile(approval: Arc, ctx: Arc) -> Result, ctx: Arc) -> Result, ctx: Arc) -> Result Date: Mon, 7 Sep 2026 15:18:11 +0200 Subject: [PATCH 17/23] fix(core): bind task authorization to the effective governed blueprint Share effective runtime, model/provider, instruction and capability-reference normalization between materialization and task authorization hashing. Use the full domain-separated digest for task status, approvals and receipt subjects while retaining the envelope-only lattice digest. Preserve terminal decisions as history but require current task UID, digest, readiness and immutable decision coherence before authorization. Add canonical golden vectors, blueprint drift, replacement, receipt and materialization regressions. Rust execution remains pending the coordinated build window. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- controller/src/kars_approval.rs | 51 ++- controller/src/kars_receipt.rs | 18 +- controller/src/kars_task.rs | 70 +++- .../src/kars_task_authorization_tests.rs | 380 ++++++++++++++++++ controller/src/kars_task_blueprint.rs | 135 +++++++ controller/src/kars_task_execution.rs | 98 +---- controller/src/kars_task_execution_tests.rs | 85 ++++ controller/src/kars_task_reconciler.rs | 27 +- controller/src/kars_task_reconciler_tests.rs | 7 +- .../helm/kars/templates/crd-karsapproval.yaml | 4 +- .../helm/kars/templates/crd-karsreceipt.yaml | 4 +- deploy/helm/kars/templates/crd-karstask.yaml | 4 +- docs/api/crd-reference.md | 32 +- .../2026-09-03-core-governance-apis.md | 4 + 14 files changed, 770 insertions(+), 149 deletions(-) create mode 100644 controller/src/kars_task_authorization_tests.rs create mode 100644 controller/src/kars_task_blueprint.rs diff --git a/controller/src/kars_approval.rs b/controller/src/kars_approval.rs index 80804e5ef..cba5f3ece 100644 --- a/controller/src/kars_approval.rs +++ b/controller/src/kars_approval.rs @@ -190,8 +190,8 @@ pub struct KarsApprovalStatus { #[serde(default, skip_serializing_if = "Option::is_none")] pub expires_at: Option, - /// The task envelope digest this approval is bound to. Set once by the - /// controller from the task's `status.envelopeDigest`; never changes. + /// 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, @@ -315,6 +315,53 @@ pub fn request_snapshot(spec: &KarsApprovalSpec) -> String { .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::*; diff --git a/controller/src/kars_receipt.rs b/controller/src/kars_receipt.rs index 01ee54aa6..6a6890211 100644 --- a/controller/src/kars_receipt.rs +++ b/controller/src/kars_receipt.rs @@ -75,8 +75,8 @@ pub struct KarsReceiptSpec { /// The `KarsTask` this receipt attests, in the same namespace. pub task_ref: LocalObjectRef, - /// `sha256:` digest of the trust envelope the task ran under. Mirrors the - /// task's `status.envelopeDigest` and is bound into the signed subject. + /// `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. @@ -318,7 +318,10 @@ pub fn build_statement( approvals: &[PredicateApproval], completeness: PredicateCompleteness, ) -> Option { - let digest = status.envelope_digest.clone()?; + let digest = status + .envelope_digest + .clone() + .filter(|digest| digest == &task.envelope_digest())?; let namespace = task .metadata .namespace @@ -345,10 +348,7 @@ pub fn build_statement( } else { "Trust envelope validated; root task with no delegation to attenuate." }; - // The completeness claim stays PARTIAL in V0 (the runtime iptables-ruleset - // hash, the token/cost audit chain, and the eBPF witness are not yet - // bound), but its detail now reflects *which* enforced floor controls the - // controller actually observed — concrete, re-derivable, never overstated. + // 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 { @@ -517,7 +517,7 @@ mod tests { task.metadata.namespace = Some("kars-system".to_string()); let status = KarsTaskStatus { phase: Some("Ready".to_string()), - envelope_digest: Some("sha256:deadbeefdeadbeefdeadbeefdeadbeef".to_string()), + envelope_digest: Some(task.envelope_digest()), lineage: if child { vec!["root".to_string(), "parent".to_string()] } else { @@ -562,7 +562,7 @@ mod tests { // sha256: prefix stripped for the in-toto digest field. assert_eq!( st.subject[0].digest.sha256, - "deadbeefdeadbeefdeadbeefdeadbeef" + task.envelope_digest().strip_prefix("sha256:").unwrap() ); assert!(!st.predicate.delegation.is_child); assert_eq!(st.predicate.conformance.attenuates_parent, None); diff --git a/controller/src/kars_task.rs b/controller/src/kars_task.rs index 72a2c51fe..7b184ecac 100644 --- a/controller/src/kars_task.rs +++ b/controller/src/kars_task.rs @@ -42,6 +42,9 @@ 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. @@ -110,6 +113,46 @@ pub struct KarsTaskSpec { 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 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(); + let bytes = serde_json::to_vec(&authority).expect("task authority always serializes"); + format!("sha256:{:x}", Sha256::digest(bytes)) + } +} + /// 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)] @@ -265,10 +308,9 @@ impl TaskEnvelope { /// /// The digest is a `sha256:`-prefixed hex string over the canonical JSON /// serialization of the envelope. serde serializes struct fields in - /// declaration order deterministically, so the same envelope always - /// produces the same digest across processes — the property the - /// Governance Receipt relies on to bind a task to the authority it ran - /// under. + /// 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"); @@ -537,7 +579,8 @@ pub fn effective_tool_policy(spec: &KarsTaskSpec) -> Option<&str> { spec.envelope .tool_policy_ref .as_ref() - .map(|r| r.name.as_str()) + .map(|r| r.name.trim()) + .filter(|name| !name.is_empty()) }) } @@ -555,16 +598,11 @@ pub fn effective_egress(spec: &KarsTaskSpec) -> &[TaskEgress] { /// Normalize the task's effective runtime to the existing sandbox contract. pub fn task_runtime(spec: &KarsTaskSpec) -> Result { use crate::crd::RuntimeKind; - let runtime = 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"); + let runtime = blueprint::effective_runtime_name(spec); match runtime { "OpenClaw" => Ok(RuntimeKind::OpenClaw), "OpenAIAgents" => Ok(RuntimeKind::OpenAIAgents), - "MAF" | "MicrosoftAgentFramework" => Ok(RuntimeKind::MicrosoftAgentFramework), + "MicrosoftAgentFramework" => Ok(RuntimeKind::MicrosoftAgentFramework), "Hermes" => Ok(RuntimeKind::Hermes), "BYO" => { Err("BYO task runtime requires configuration not supported by task blueprints".into()) @@ -678,8 +716,8 @@ pub struct KarsTaskStatus { #[serde(default, skip_serializing_if = "Option::is_none")] pub conditions: Option>, - /// `sha256:` digest of the validated trust envelope. Stable for a given - /// envelope; recomputed whenever the spec changes. + /// `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, @@ -711,3 +749,7 @@ pub struct KarsTaskStatus { #[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..9438d411b --- /dev/null +++ b/controller/src/kars_task_authorization_tests.rs @@ -0,0 +1,380 @@ +// 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() { + let original = spec(); + let changes: &[(&str, fn(&mut KarsTaskSpec))] = &[ + ("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 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 index 8ef08a2ea..3eaafcb3f 100644 --- a/controller/src/kars_task_execution.rs +++ b/controller/src/kars_task_execution.rs @@ -107,50 +107,6 @@ fn network_policy(blueprint: &TaskBlueprint) -> serde_json::Value { }) } -/// Resolve the default `(deployment, provider)` a task-materialized -/// InferencePolicy should request. The deployment is required by the sandbox -/// reconciler — without it the pod degrades — so we derive a sane default from -/// the controller's own configured inference model and let an operator override -/// it for the task lane specifically. -/// -/// Resolution order for the deployment: -/// `KARS_TASK_DEFAULT_MODEL` → `AZURE_OPENAI_DEPLOYMENT` → `DEFAULT_MODEL` → -/// `gpt-4o-mini`. The provider tag is `KARS_TASK_DEFAULT_PROVIDER` → -/// `azure-openai` (the router routes by the configured endpoint URL, so this -/// tag only needs to be a valid non-empty value). -fn default_model() -> (String, String) { - let deployment = std::env::var("KARS_TASK_DEFAULT_MODEL") - .ok() - .filter(|s| !s.is_empty()) - .or_else(|| { - std::env::var("AZURE_OPENAI_DEPLOYMENT") - .ok() - .filter(|s| !s.is_empty()) - }) - .or_else(|| { - std::env::var("DEFAULT_MODEL") - .ok() - .filter(|s| !s.is_empty()) - }) - .unwrap_or_else(|| "gpt-4o-mini".to_string()); - let provider = std::env::var("KARS_TASK_DEFAULT_PROVIDER") - .ok() - .filter(|s| !s.is_empty()) - .unwrap_or_else(|| "azure-openai".to_string()); - (deployment, provider) -} - -/// Build the agent's standing instructions (system prompt) from the task -/// objective plus any blueprint instructions. Pure + testable. -fn build_instructions(objective: &str, extra: Option<&str>) -> String { - let mut out = format!("Your objective:\n{}", objective.trim()); - if let Some(extra) = extra.map(str::trim).filter(|s| !s.is_empty()) { - out.push_str("\n\nAdditional instructions:\n"); - out.push_str(extra); - } - out -} - /// Materialize the InferencePolicy + KarsSandbox for a launched task using /// atomic creation or version-checked owned updates, then read sandbox status. pub async fn materialize( @@ -162,26 +118,15 @@ pub async fn materialize( let task_name = task.name_any(); let inference_name = format!("{task_name}-inference"); let envelope = &task.spec.envelope; - let blueprint = task.spec.blueprint.clone().unwrap_or_default(); + 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 (model_deployment, model_provider) = match &blueprint.model { - Some(m) if !m.deployment.trim().is_empty() => { - let provider = if m.provider.trim().is_empty() { - "azure-openai".to_string() - } else { - m.provider.clone() - }; - (m.deployment.clone(), provider) - } - _ => default_model(), - }; let inference_spec = json!({ "appliesTo": { "sandboxName": task_name }, "modelPreference": { - "primary": { "provider": model_provider, "deployment": model_deployment }, + "primary": blueprint.model, }, }); apply_dynamic( @@ -197,23 +142,17 @@ pub async fn materialize( // 2. KarsSandbox bounded by the envelope + shaped by the blueprint. Each // blueprint field drives a real sandbox field; unset → safe default. - let isolation = blueprint - .isolation - .clone() - .filter(|s| !s.trim().is_empty()) - .unwrap_or_else(|| "standard".to_string()); let mut sandbox_spec = json!({ "runtime": runtime, "inferenceRef": { "name": inference_name }, - "sandbox": { "isolation": isolation }, + "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. - let instructions = build_instructions(&task.spec.objective, blueprint.instructions.as_deref()); - sandbox_spec["agent"] = json!({ "instructions": instructions }); + 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 @@ -494,6 +433,7 @@ async fn apply_dynamic( #[cfg(test)] mod tests { use super::*; + use crate::kars_task::blueprint::build_instructions; #[test] fn build_instructions_includes_objective_and_extra() { @@ -512,34 +452,6 @@ mod tests { assert!(!blank.contains("Additional instructions")); } - #[test] - fn default_model_resolution() { - // Single test (env is process-global; avoid cross-test races). - unsafe { - std::env::remove_var("KARS_TASK_DEFAULT_MODEL"); - std::env::remove_var("AZURE_OPENAI_DEPLOYMENT"); - std::env::remove_var("DEFAULT_MODEL"); - std::env::remove_var("KARS_TASK_DEFAULT_PROVIDER"); - } - // No knobs → safe builtin default + valid provider tag. - let (deployment, provider) = default_model(); - assert!(!deployment.is_empty()); - assert_eq!(provider, "azure-openai"); - - // Explicit task overrides win. - unsafe { - std::env::set_var("KARS_TASK_DEFAULT_MODEL", "openai/gpt-4o-mini"); - std::env::set_var("KARS_TASK_DEFAULT_PROVIDER", "github-models"); - } - let (deployment, provider) = default_model(); - assert_eq!(deployment, "openai/gpt-4o-mini"); - assert_eq!(provider, "github-models"); - unsafe { - std::env::remove_var("KARS_TASK_DEFAULT_MODEL"); - std::env::remove_var("KARS_TASK_DEFAULT_PROVIDER"); - } - } - #[test] fn runtime_variants_follow_the_sandbox_contract() { for (input, canonical, key) in [ diff --git a/controller/src/kars_task_execution_tests.rs b/controller/src/kars_task_execution_tests.rs index f905bcaed..bd217042e 100644 --- a/controller/src/kars_task_execution_tests.rs +++ b/controller/src/kars_task_execution_tests.rs @@ -190,3 +190,88 @@ async fn deleting_an_already_absent_object_is_successful() { 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 index 13817047d..a0350becd 100644 --- a/controller/src/kars_task_reconciler.rs +++ b/controller/src/kars_task_reconciler.rs @@ -176,12 +176,9 @@ async fn reconcile(task: Arc, ctx: Arc) -> Result match delegation { - Delegation::Root => ready_status( - prior_ready, - generation, - task.spec.envelope.digest(), - Vec::new(), - ), + 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( @@ -204,12 +201,7 @@ async fn reconcile(task: Arc, ctx: Arc) -> Result { tracing::info!(karstask = %name, ns = %ns, depth = lineage.len(), "KarsTask delegated child ready"); - ready_status( - prior_ready, - generation, - task.spec.envelope.digest(), - lineage, - ) + ready_status(prior_ready, generation, task.envelope_digest(), lineage) } Delegation::Child { lineage, @@ -354,7 +346,7 @@ 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.spec.envelope.digest().as_str()); + let digest_ok = status.envelope_digest.as_deref() == Some(task.envelope_digest().as_str()); let ready_ok = status .conditions .iter() @@ -532,16 +524,17 @@ async fn reconcile_receipt( // 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 .into_iter() .filter(|a| { - a.spec.task_ref.name == name - && task.uid().is_some() - && a.status.as_ref().and_then(|s| s.bound_task_uid.as_ref()) - == task.metadata.uid.as_ref() + 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::>(), Err(e) => { diff --git a/controller/src/kars_task_reconciler_tests.rs b/controller/src/kars_task_reconciler_tests.rs index 0765c7fcf..e98fedb41 100644 --- a/controller/src/kars_task_reconciler_tests.rs +++ b/controller/src/kars_task_reconciler_tests.rs @@ -48,12 +48,7 @@ fn root_policy_conflict_never_becomes_ready() { 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.spec.envelope.digest(), - vec![], - )); + 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)); diff --git a/deploy/helm/kars/templates/crd-karsapproval.yaml b/deploy/helm/kars/templates/crd-karsapproval.yaml index 2e0fc95aa..e2e34a1bc 100644 --- a/deploy/helm/kars/templates/crd-karsapproval.yaml +++ b/deploy/helm/kars/templates/crd-karsapproval.yaml @@ -147,8 +147,8 @@ spec: properties: boundEnvelopeDigest: description: |- - The task envelope digest this approval is bound to. Set once by the - controller from the task's `status.envelopeDigest`; never changes. + The task authorization digest (envelope plus effective blueprint). + Copied once from `status.envelopeDigest`; never changes after binding. nullable: true type: string boundRequest: diff --git a/deploy/helm/kars/templates/crd-karsreceipt.yaml b/deploy/helm/kars/templates/crd-karsreceipt.yaml index f0f815136..d88f683f5 100644 --- a/deploy/helm/kars/templates/crd-karsreceipt.yaml +++ b/deploy/helm/kars/templates/crd-karsreceipt.yaml @@ -102,8 +102,8 @@ spec: type: object envelopeDigest: description: |- - `sha256:` digest of the trust envelope the task ran under. Mirrors the - task's `status.envelopeDigest` and is bound into the signed subject. + `sha256:` authorization digest of the envelope and effective blueprint. + Mirrors `status.envelopeDigest` and is bound into the signed subject. type: string keyId: description: |- diff --git a/deploy/helm/kars/templates/crd-karstask.yaml b/deploy/helm/kars/templates/crd-karstask.yaml index 177544fc8..35dbdd411 100644 --- a/deploy/helm/kars/templates/crd-karstask.yaml +++ b/deploy/helm/kars/templates/crd-karstask.yaml @@ -367,8 +367,8 @@ spec: type: array envelopeDigest: description: |- - `sha256:` digest of the validated trust envelope. Stable for a given - envelope; recomputed whenever the spec changes. + `sha256:` authorization digest of the validated envelope and effective + governed blueprint, including resolved model defaults and capability refs. nullable: true type: string executionDetail: diff --git a/docs/api/crd-reference.md b/docs/api/crd-reference.md index 2a5f8114e..5e4cbc4a0 100644 --- a/docs/api/crd-reference.md +++ b/docs/api/crd-reference.md @@ -48,10 +48,38 @@ governance `Ready`. `execution.launch` is a separate opt-in: 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`. +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 envelope, and checks expiry before accepting the first decision. -Terminal decisions remain stable; they cannot be replayed for a replacement task. +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. Current receipts exclude approvals bound to old authority and refuse +stale task status when constructing their signed subject. 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 diff --git a/docs/security-audits/2026-09-03-core-governance-apis.md b/docs/security-audits/2026-09-03-core-governance-apis.md index b061953ae..eba4a6100 100644 --- a/docs/security-audits/2026-09-03-core-governance-apis.md +++ b/docs/security-audits/2026-09-03-core-governance-apis.md @@ -26,6 +26,10 @@ submit or verify typed resources. 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. From 5158b6959b77b94051d09a378cfe87eb30b3b5b2 Mon Sep 17 00:00:00 2001 From: pallakatos Date: Mon, 7 Sep 2026 15:56:46 +0200 Subject: [PATCH 18/23] fix(core): qualify governance hardening against kube 3 Fix regression-test module linkage, boxed Kubernetes Status errors, and jiff-to-chrono approval creation timestamps. Use realistic Kubernetes error reasons in API tests, exercise creation-time expiry, and distinguish lattice and authorization digests in diagnostics without lint waivers. All 103 focused governance/signing/CRD-drift tests and controller all-targets clippy pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- controller/src/kars_approval_reconciler.rs | 15 ++++++++++++++- controller/src/kars_task_authorization_tests.rs | 3 ++- controller/src/kars_task_execution.rs | 14 +++++++------- controller/src/kars_task_execution_tests.rs | 9 ++++++++- controller/src/kars_task_reconciler.rs | 9 +++++++++ 5 files changed, 40 insertions(+), 10 deletions(-) diff --git a/controller/src/kars_approval_reconciler.rs b/controller/src/kars_approval_reconciler.rs index 0c4ebdf65..c65e0e530 100644 --- a/controller/src/kars_approval_reconciler.rs +++ b/controller/src/kars_approval_reconciler.rs @@ -161,7 +161,14 @@ async fn reconcile(approval: Arc, ctx: Arc) -> Result (KarsTask, KarsApproval) { #[test] fn every_effective_blueprint_axis_changes_task_authority() { + type SpecChange = fn(&mut KarsTaskSpec); let original = spec(); - let changes: &[(&str, fn(&mut KarsTaskSpec))] = &[ + let changes: &[(&str, SpecChange)] = &[ ("runtime", |s| { s.blueprint.as_mut().unwrap().runtime = Some("Hermes".into()) }), diff --git a/controller/src/kars_task_execution.rs b/controller/src/kars_task_execution.rs index 3eaafcb3f..6c83b5634 100644 --- a/controller/src/kars_task_execution.rs +++ b/controller/src/kars_task_execution.rs @@ -91,12 +91,12 @@ fn runtime_spec(task: &KarsTask) -> Result } fn contract_error(message: String) -> kube::Error { - kube::Error::Api(kube::core::ErrorResponse { - status: "Failure".into(), + kube::Error::Api(Box::new(kube::core::Status { message, reason: "Conflict".into(), code: 409, - }) + ..Default::default() + })) } fn network_policy(blueprint: &TaskBlueprint) -> serde_json::Value { @@ -430,6 +430,10 @@ async fn apply_dynamic( Ok(()) } +#[cfg(test)] +#[path = "kars_task_execution_tests.rs"] +mod api_tests; + #[cfg(test)] mod tests { use super::*; @@ -496,10 +500,6 @@ mod tests { ); } - #[cfg(test)] - #[path = "kars_task_execution_tests.rs"] - mod api_tests; - #[test] fn governance_disabled_without_tool_policy() { let e = TaskEnvelope { diff --git a/controller/src/kars_task_execution_tests.rs b/controller/src/kars_task_execution_tests.rs index bd217042e..08ce0c690 100644 --- a/controller/src/kars_task_execution_tests.rs +++ b/controller/src/kars_task_execution_tests.rs @@ -35,9 +35,16 @@ fn client(server: &MockServer) -> Client { } 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": "Failure", "code": code, + "message": "test API failure", "reason": reason, "code": code, })) } diff --git a/controller/src/kars_task_reconciler.rs b/controller/src/kars_task_reconciler.rs index a0350becd..aca9dfa27 100644 --- a/controller/src/kars_task_reconciler.rs +++ b/controller/src/kars_task_reconciler.rs @@ -223,6 +223,15 @@ async fn reconcile(task: Arc, ctx: Arc) -> Result Date: Mon, 7 Sep 2026 16:05:35 +0200 Subject: [PATCH 19/23] fix(helm): preserve task admission defaults with reused values Default absent admission/taskNamespaceFloor maps and missing enabled flags to the secure enabled state without resetting saved settings. Preserve the existing explicit boolean false switch and reject malformed flags. Add nine real-Helm compatibility tests using saved values as isolated chart defaults, an old-values fixture, and a Helm CI gate; no new dependencies or global values reset. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- .github/workflows/ci.yml | 2 + ci/helm-task-floor-compat.py | 105 ++++++++++++++++++ .../admission-task-namespace-floor.yaml | 12 +- deploy/helm/kars/values.yaml | 1 + .../fixtures/task-floor-old-values.yaml | 13 +++ 5 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 ci/helm-task-floor-compat.py create mode 100644 tests/compat/fixtures/task-floor-old-values.yaml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 59c22e664..40bb826f2 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..91dbb56b3 --- /dev/null +++ b/ci/helm-task-floor-compat.py @@ -0,0 +1,105 @@ +#!/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_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", "null"]: + 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/deploy/helm/kars/templates/admission-task-namespace-floor.yaml b/deploy/helm/kars/templates/admission-task-namespace-floor.yaml index 88e12a9a8..7a2e80eb3 100644 --- a/deploy/helm/kars/templates/admission-task-namespace-floor.yaml +++ b/deploy/helm/kars/templates/admission-task-namespace-floor.yaml @@ -44,7 +44,17 @@ Requires Kubernetes >= 1.30 (VAP GA). */}} -{{- if .Values.admission.taskNamespaceFloor.enabled -}} +{{- $admission := .Values.admission | default dict -}} +{{- $floor := $admission.taskNamespaceFloor | default dict -}} +{{- $enabled := true -}} +{{- /* `default true` would overwrite an existing explicit false. */ -}} +{{- if hasKey $floor "enabled" -}} +{{- $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: diff --git a/deploy/helm/kars/values.yaml b/deploy/helm/kars/values.yaml index 36a8f2056..0613eadfd 100644 --- a/deploy/helm/kars/values.yaml +++ b/deploy/helm/kars/values.yaml @@ -239,6 +239,7 @@ admission: # Requires Kubernetes >= 1.30 (VAP GA). enabled: true taskNamespaceFloor: + # Missing sections/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 / 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 From cd624fc3fcb68e4f9418575d33c088f71b63e52a Mon Sep 17 00:00:00 2001 From: pallakatos Date: Mon, 7 Sep 2026 16:27:33 +0200 Subject: [PATCH 20/23] fix(helm): retain secure task floor for null legacy flags Treat an explicitly null taskNamespaceFloor.enabled as unset and keep the security default active, matching envelope-write-lock upgrade semantics. Preserve explicit boolean false and continue rejecting non-boolean scalar flags. Ten isolated real-Helm compatibility tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- ci/helm-task-floor-compat.py | 5 ++++- .../helm/kars/templates/admission-task-namespace-floor.yaml | 2 +- deploy/helm/kars/values.yaml | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/ci/helm-task-floor-compat.py b/ci/helm-task-floor-compat.py index 91dbb56b3..522b46b8d 100644 --- a/ci/helm-task-floor-compat.py +++ b/ci/helm-task-floor-compat.py @@ -81,6 +81,9 @@ def test_null_floor_map_enables_floor(self): 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")) @@ -92,7 +95,7 @@ def test_existing_explicit_false_is_preserved(self): 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", "null"]: + 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) diff --git a/deploy/helm/kars/templates/admission-task-namespace-floor.yaml b/deploy/helm/kars/templates/admission-task-namespace-floor.yaml index 7a2e80eb3..dbdb0b29e 100644 --- a/deploy/helm/kars/templates/admission-task-namespace-floor.yaml +++ b/deploy/helm/kars/templates/admission-task-namespace-floor.yaml @@ -48,7 +48,7 @@ {{- $floor := $admission.taskNamespaceFloor | default dict -}} {{- $enabled := true -}} {{- /* `default true` would overwrite an existing explicit false. */ -}} -{{- if hasKey $floor "enabled" -}} +{{- if and (hasKey $floor "enabled") (ne $floor.enabled nil) -}} {{- $enabled = $floor.enabled -}} {{- end -}} {{- if not (kindIs "bool" $enabled) -}} diff --git a/deploy/helm/kars/values.yaml b/deploy/helm/kars/values.yaml index 0613eadfd..5d629ed84 100644 --- a/deploy/helm/kars/values.yaml +++ b/deploy/helm/kars/values.yaml @@ -239,7 +239,7 @@ admission: # Requires Kubernetes >= 1.30 (VAP GA). enabled: true taskNamespaceFloor: - # Missing sections/flags remain enabled on --reuse-values upgrades. + # 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 / From f31005081b9994ff6cccbc468813b449b17d56b9 Mon Sep 17 00:00:00 2001 From: pallakatos Date: Mon, 7 Sep 2026 16:52:05 +0200 Subject: [PATCH 21/23] refactor(core): expose the effective task authorization snapshot Expose authorization_configuration_with_model as the serializable canonical snapshot already hashed by task authorization. Keep one normalization implementation, preserve the existing digest bytes and golden vector, and document reuse by receipt producers with one resolved model-default snapshot. Add a configuration-shape regression; no wire fields or authorization semantics change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- controller/src/kars_task.rs | 16 ++++++++-- .../src/kars_task_authorization_tests.rs | 32 +++++++++++++++++++ docs/api/crd-reference.md | 5 +++ 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/controller/src/kars_task.rs b/controller/src/kars_task.rs index 7b184ecac..d18b485ee 100644 --- a/controller/src/kars_task.rs +++ b/controller/src/kars_task.rs @@ -132,6 +132,19 @@ impl KarsTaskSpec { #[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); @@ -148,8 +161,7 @@ impl KarsTaskSpec { "networkPolicy": { "defaultDeny": true, "egressMode": "Strict" }, }); authority.sort_all_objects(); - let bytes = serde_json::to_vec(&authority).expect("task authority always serializes"); - format!("sha256:{:x}", Sha256::digest(bytes)) + authority } } diff --git a/controller/src/kars_task_authorization_tests.rs b/controller/src/kars_task_authorization_tests.rs index c280e8e1c..56486f168 100644 --- a/controller/src/kars_task_authorization_tests.rs +++ b/controller/src/kars_task_authorization_tests.rs @@ -215,6 +215,38 @@ fn defaults_aliases_and_runtime_precedence_have_one_canonical_digest() { 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(); diff --git a/docs/api/crd-reference.md b/docs/api/crd-reference.md index 5e4cbc4a0..2d737b98d 100644 --- a/docs/api/crd-reference.md +++ b/docs/api/crd-reference.md @@ -54,6 +54,11 @@ envelope lattice identifier. `KarsTask::envelope_digest()` (or 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 From 4fc8f39a261a3a685c112a4dde393f12f2818c05 Mon Sep 17 00:00:00 2001 From: pallakatos Date: Mon, 7 Sep 2026 17:40:17 +0200 Subject: [PATCH 22/23] fix(core): retain historical approvals across authority transitions Collect coherent terminal decisions before filtering current authorization. Sign an explicit approvalHistory with original digest/request and immutable task/approval identities, validated decider/generation/timestamps, and non-grant/non-consumption markers. Preserve all current authority/readiness guards. Add D0-to-D1 collector/DSSE and invalid-history regressions; Rust execution awaits the parent's Cargo lease schedule. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- controller/src/kars_receipt.rs | 69 +++--- controller/src/kars_receipt_approvals.rs | 145 +++++++++++ .../src/kars_receipt_approvals_tests.rs | 227 ++++++++++++++++++ controller/src/kars_task_reconciler.rs | 27 ++- controller/src/kars_task_reconciler_tests.rs | 119 +++++++++ docs/api/crd-reference.md | 16 +- 6 files changed, 550 insertions(+), 53 deletions(-) create mode 100644 controller/src/kars_receipt_approvals.rs create mode 100644 controller/src/kars_receipt_approvals_tests.rs diff --git a/controller/src/kars_receipt.rs b/controller/src/kars_receipt.rs index 6a6890211..958483dee 100644 --- a/controller/src/kars_receipt.rs +++ b/controller/src/kars_receipt.rs @@ -174,11 +174,11 @@ pub struct Subject { pub digest: SubjectDigest, } -/// Subject digest. kars truncates the envelope SHA-256 to 16 bytes for -/// compact status; the verifier compares the same truncated form. +/// Subject digest of the current task authorization; historical receipts may +/// carry the former truncated envelope-only identifier. #[derive(Debug, Serialize, Clone)] pub struct SubjectDigest { - /// 32-hex-char (16-byte) truncated SHA-256 of the trust envelope. + /// Full 64-hex-character SHA-256 for newly emitted task authorization. pub sha256: String, } @@ -192,10 +192,12 @@ pub struct Predicate { pub lineage: Vec, pub delegation: PredicateDelegation, pub execution: PredicateExecution, - /// The human decisions (HITL approvals) recorded for this task — every - /// steer is itself part of the signed record. Empty when none were taken. + /// 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 @@ -245,6 +247,10 @@ pub struct PredicateApproval { 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, @@ -395,6 +401,7 @@ pub fn build_statement( 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, @@ -412,8 +419,7 @@ pub fn build_statement( typ: STATEMENT_TYPE.to_string(), subject: vec![Subject { name: format!("{namespace}/{name}"), - // Bind to the same truncated SHA-256 the envelope digest carries, - // stripping the `sha256:` algorithm prefix for the in-toto field. + // Strip the algorithm prefix from the current authorization digest. digest: SubjectDigest { sha256: digest .strip_prefix("sha256:") @@ -455,37 +461,7 @@ pub fn build_spec( /// Stable, sorted receipt facts from decided approvals with unchanged requests. pub fn approval_facts(approvals: &[crate::kars_approval::KarsApproval]) -> Vec { - use crate::kars_approval::{PHASE_APPROVED, PHASE_DENIED}; - use kube::ResourceExt; - - let mut facts: Vec = approvals - .iter() - .filter_map(|a| { - let status = a.status.as_ref()?; - if status.bound_request.as_deref() - != Some(crate::kars_approval::request_snapshot(&a.spec).as_str()) - { - return None; - } - let phase = status.phase.as_deref()?; - let verdict = match phase { - PHASE_APPROVED => "approve", - PHASE_DENIED => "deny", - _ => return None, - }; - Some(PredicateApproval { - name: a.name_any(), - action_kind: a.spec.action.kind.clone(), - summary: a.spec.action.summary.clone(), - verdict: verdict.to_string(), - decider: status.decider.clone().unwrap_or_default(), - decided_at: status.decided_at.clone().unwrap_or_default(), - requested_tier: a.spec.action.requested_tier, - }) - }) - .collect(); - facts.sort_by(|a, b| a.name.cmp(&b.name)); - facts + approval_evidence::decision_facts(approvals) } #[cfg(test)] @@ -692,7 +668,7 @@ mod tests { #[test] fn approval_facts_filters_to_decided_and_sorts() { use crate::kars_approval::{ - ApprovalAction, KarsApproval, KarsApprovalSpec, KarsApprovalStatus, + ApprovalAction, ApprovalDecision, KarsApproval, KarsApprovalSpec, KarsApprovalStatus, }; let mk = |name: &str, phase: Option<&str>, decider: Option<&str>| { let mut a = KarsApproval::new( @@ -707,12 +683,25 @@ mod tests { ..Default::default() }, ttl: None, - decision: 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() 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_task_reconciler.rs b/controller/src/kars_task_reconciler.rs index aca9dfa27..f926e62a2 100644 --- a/controller/src/kars_task_reconciler.rs +++ b/controller/src/kars_task_reconciler.rs @@ -525,6 +525,7 @@ async fn reconcile_receipt( 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(); @@ -537,21 +538,24 @@ async fn reconcile_receipt( 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 - .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::>(), + Ok(list) => list.items, Err(e) => { tracing::debug!(karstask = %name, ns = %ns, error = %e, "could not list KarsApprovals for receipt"); Vec::new() } }; - let facts = approval_facts(&task_approvals); + // 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 @@ -559,7 +563,7 @@ async fn reconcile_receipt( // concrete and re-derivable by an auditor. let completeness = gather_completeness(); - let Some(statement) = build_statement(task, status, &signer.key_id, &facts, 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 @@ -574,6 +578,7 @@ async fn reconcile_receipt( } return; }; + statement.predicate.approval_history = history; let digest = status .envelope_digest diff --git a/controller/src/kars_task_reconciler_tests.rs b/controller/src/kars_task_reconciler_tests.rs index e98fedb41..1e18e3773 100644 --- a/controller/src/kars_task_reconciler_tests.rs +++ b/controller/src/kars_task_reconciler_tests.rs @@ -150,3 +150,122 @@ fn finalizer_roundtrip() { 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/docs/api/crd-reference.md b/docs/api/crd-reference.md index 2d737b98d..32332d9a1 100644 --- a/docs/api/crd-reference.md +++ b/docs/api/crd-reference.md @@ -83,8 +83,20 @@ 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. Current receipts exclude approvals bound to old authority and refuse -stale task status when constructing their signed subject. +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 From 6a74289ecb21f0e3fcb1eb58459c3179a90428e0 Mon Sep 17 00:00:00 2001 From: pallakatos Date: Mon, 7 Sep 2026 18:32:05 +0200 Subject: [PATCH 23/23] fix(ci): honor phase taxonomy and bound Helm integration test timing Use the existing phase constant without changing behavior. Give the two archive-and-Helm integration cases a bounded timeout consistent with their subprocess budget, retaining all assertions and guards. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- cli/src/testing/helm-installation.test.ts | 6 ++++-- controller/src/kars_task_reconciler.rs | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) 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/kars_task_reconciler.rs b/controller/src/kars_task_reconciler.rs index f926e62a2..3a721f5d5 100644 --- a/controller/src/kars_task_reconciler.rs +++ b/controller/src/kars_task_reconciler.rs @@ -266,7 +266,7 @@ async fn reconcile(task: Arc, ctx: Arc) -> Result