diff --git a/Cargo.lock b/Cargo.lock index 35deb42..243ecaa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -424,6 +424,7 @@ dependencies = [ "async-trait", "ceremony-engine", "chrono", + "governance-types", "hex", "registry-protocol", "serde", @@ -461,6 +462,7 @@ dependencies = [ "chrono", "config", "dashmap", + "governance-types", "hex", "jsonwebtoken", "k8s-openapi", diff --git a/bascule-core/Cargo.toml b/bascule-core/Cargo.toml index 7873e9c..5627ead 100644 --- a/bascule-core/Cargo.toml +++ b/bascule-core/Cargo.toml @@ -21,3 +21,6 @@ ceremony-engine = { workspace = true } # Cross-workspace path deps — Guildhouse governance primitives. accord-core = { path = "../../guildhouse/services/accord-core" } registry-protocol = { path = "../../guildhouse/services/registry-protocol" } + +# Cross-workspace path dep — substrate governance types (for PostureLevel). +governance-types = { path = "../../substrate/crates/governance-types" } diff --git a/bascule-core/src/lib.rs b/bascule-core/src/lib.rs index 79d8b57..d69a97e 100644 --- a/bascule-core/src/lib.rs +++ b/bascule-core/src/lib.rs @@ -3,6 +3,9 @@ pub mod ceremony; pub mod command; pub mod scope; pub mod session; +pub mod shell_class; + +pub use shell_class::{derive_shell_class, ShellClass}; // Governance ceremony engine — extracted to ceremony-engine crate. // Re-exported here for backward compatibility while consumers migrate. diff --git a/bascule-core/src/scope.rs b/bascule-core/src/scope.rs index db237f1..9f0bd0b 100644 --- a/bascule-core/src/scope.rs +++ b/bascule-core/src/scope.rs @@ -1,5 +1,7 @@ use serde::{Deserialize, Serialize}; +use crate::shell_class::ShellClass; + /// Defines what an operator can do within a session. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SessionScope { @@ -9,6 +11,17 @@ pub struct SessionScope { /// Maximum mutations before session requires a new ceremony. None = unlimited. pub mutation_budget: Option, pub can_delegate: bool, + + /// Shell class for this session, derived from posture at establishment. + /// Immutable after creation — downgrade creates a new scope, upgrade + /// requires a new ceremony. + #[serde(default)] + pub shell_class: ShellClass, + + /// Posture level (wire value 1-5) at time of session establishment. + /// Stored for audit (Chronicle) and for posture breach comparison. + #[serde(default)] + pub posture_level_at_establishment: Option, } /// Per-namespace access rules. @@ -134,6 +147,8 @@ mod tests { pathways: vec![ChangePathway::DryRunOnly], mutation_budget: None, can_delegate: false, + shell_class: ShellClass::default(), + posture_level_at_establishment: None, }; assert!(scope.permits("default", "", "pods", Verb::Get)); @@ -160,6 +175,8 @@ mod tests { pathways: vec![], mutation_budget: None, can_delegate: false, + shell_class: ShellClass::default(), + posture_level_at_establishment: None, }; assert!(scope.permits("default", "", "pods", Verb::Get)); diff --git a/bascule-core/src/shell_class.rs b/bascule-core/src/shell_class.rs new file mode 100644 index 0000000..a0a91ad --- /dev/null +++ b/bascule-core/src/shell_class.rs @@ -0,0 +1,167 @@ +// Copyright 2026 Guildhouse Dev +// SPDX-License-Identifier: Apache-2.0 + +//! Shell class — session-scoped classification derived from posture. +//! +//! Determines what class of operations a governed shell session permits. +//! Derived from [`PostureLevel`] at ceremony grant time and immutable for +//! the session lifetime. Upgrade requires a new ceremony; downgrade can +//! occur on posture breach. + +use governance_types::PostureLevel; +use serde::{Deserialize, Serialize}; +use std::fmt; + +/// Classification of a governed shell session. +/// +/// - `Application` — software operations (deploy, query, playbooks). +/// - `System` — host operations (kernel modules, firmware, storage). +/// +/// This is a Bascule concept (session-scoped), distinct from the +/// substrate-wide `PostureLevel` (host-scoped, continuous). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ShellClass { + /// Software operations only. The host's kernel and firmware + /// integrity is not verified. Default for all sessions unless + /// attestation elevates to System. + #[default] + Application, + + /// Host operations. Kernel modules, firmware updates, network + /// config, storage management, security policy changes. Requires + /// the host to be in an attested posture (TPM + IMA verified). + System, +} + +impl fmt::Display for ShellClass { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ShellClass::Application => write!(f, "application"), + ShellClass::System => write!(f, "system"), + } + } +} + +impl ShellClass { + /// Returns true if this class permits system-level operations. + pub fn is_system(&self) -> bool { + matches!(self, ShellClass::System) + } + + /// Returns true if `required` is satisfied by `self`. + /// + /// System satisfies both System and Application requirements. + /// Application satisfies only Application requirements. + pub fn satisfies(&self, required: ShellClass) -> bool { + match (self, required) { + (ShellClass::System, _) => true, + (ShellClass::Application, ShellClass::Application) => true, + (ShellClass::Application, ShellClass::System) => false, + } + } +} + +/// Derive `ShellClass` from a `PostureLevel` and an optional threshold. +/// +/// Uses the **operational** PostureLevel semantic (Lockdown=1 → Normal=5). +/// Default threshold: `PostureLevel::Normal` — the host must be in normal +/// operational posture for System access. Any DEFCON escalation restricts +/// to Application shells. +/// +/// The Accord's WitnessConfig can override the threshold via a +/// `PostureCondition` with kind `"system_shell_minimum"`. +/// +/// # Dual PostureLevel semantic note +/// +/// governance-types defines PostureLevel on the operational scale +/// (Lockdown < Critical < Restricted < Elevated < Normal). The +/// session/attestation scale (None < Local < Verified < Governed < +/// Attested) lives in the proto layer. This function uses the +/// operational scale because that's what the posture-current ConfigMap +/// provides. When the two semantics are disambiguated into separate +/// types, this function should migrate to the attestation type. +pub fn derive_shell_class( + posture_level: PostureLevel, + system_threshold: Option, +) -> ShellClass { + let threshold = system_threshold.unwrap_or(PostureLevel::Normal); + + // PostureLevel derives Ord: Lockdown(1) < Normal(5). + // posture_level >= threshold means "at least as permissive as the threshold." + if posture_level >= threshold { + ShellClass::System + } else { + ShellClass::Application + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_is_application() { + assert_eq!(ShellClass::default(), ShellClass::Application); + } + + #[test] + fn derive_normal_posture_is_system() { + let class = derive_shell_class(PostureLevel::Normal, None); + assert_eq!(class, ShellClass::System); + } + + #[test] + fn derive_elevated_posture_is_application() { + let class = derive_shell_class(PostureLevel::Elevated, None); + assert_eq!(class, ShellClass::Application); + } + + #[test] + fn derive_lockdown_is_application() { + let class = derive_shell_class(PostureLevel::Lockdown, None); + assert_eq!(class, ShellClass::Application); + } + + #[test] + fn derive_with_custom_threshold() { + // Lower threshold: Elevated (4) or above gets System + let class = derive_shell_class(PostureLevel::Elevated, Some(PostureLevel::Elevated)); + assert_eq!(class, ShellClass::System); + + let class = derive_shell_class(PostureLevel::Restricted, Some(PostureLevel::Elevated)); + assert_eq!(class, ShellClass::Application); + } + + #[test] + fn satisfies_system_satisfies_both() { + assert!(ShellClass::System.satisfies(ShellClass::System)); + assert!(ShellClass::System.satisfies(ShellClass::Application)); + } + + #[test] + fn satisfies_application_only_satisfies_application() { + assert!(ShellClass::Application.satisfies(ShellClass::Application)); + assert!(!ShellClass::Application.satisfies(ShellClass::System)); + } + + #[test] + fn display_formatting() { + assert_eq!(format!("{}", ShellClass::Application), "application"); + assert_eq!(format!("{}", ShellClass::System), "system"); + } + + #[test] + fn serde_round_trip() { + let json = serde_json::to_string(&ShellClass::System).unwrap(); + assert_eq!(json, "\"system\""); + let parsed: ShellClass = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, ShellClass::System); + } + + #[test] + fn is_system() { + assert!(ShellClass::System.is_system()); + assert!(!ShellClass::Application.is_system()); + } +}