feat(bascule-core): add ShellClass enum with posture-based derivation

Introduce ShellClass (Application | System) as a session-scoped
classification derived from PostureLevel at ceremony grant time.

- ShellClass::Application: default, software operations only
- ShellClass::System: host operations, requires Normal (5) posture
- derive_shell_class(): pure function, configurable threshold
- satisfies(): hierarchical check (System satisfies Application)
- No mid-session upgrade by design (immutable in SessionScope)

Added shell_class and posture_level_at_establishment to SessionScope
with #[serde(default)] for backward-compatible deserialization.

Signed-off-by: Tyler King <tking@guildhouse.dev>
Signed-off-by: Tyler J King <tking727@gmail.com>
This commit is contained in:
Tyler J King 2026-04-15 10:36:45 -04:00
parent 47a5484614
commit e28be3335d
5 changed files with 192 additions and 0 deletions

2
Cargo.lock generated
View file

@ -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",

View file

@ -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" }

View file

@ -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.

View file

@ -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<u32>,
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<u8>,
}
/// 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));

View file

@ -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<PostureLevel>,
) -> 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());
}
}