feat(org-ops): enforce ShellClass at command dispatch

Add required_shell_class() to OrgCommands trait with Application
default (backward compatible). GSH dispatch checks session ShellClass
against command requirement before execution.

- ShellClass enum (local, lightweight — avoids bascule-core dep)
- SessionContext enriched with shell_class and posture_level
- Clear error on insufficient shell class directs operator to
  reconnect via attested host for System access
- All existing commands work unchanged (Application default)

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:38:26 -04:00
parent 20286ce0d8
commit 323617d6cc
5 changed files with 148 additions and 3 deletions

37
Cargo.lock generated
View file

@ -91,6 +91,14 @@ version = "3.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
[[package]]
name = "bxnet-ops"
version = "0.1.0"
dependencies = [
"anyhow",
"org-ops-core",
]
[[package]]
name = "bytes"
version = "1.11.1"
@ -382,11 +390,14 @@ dependencies = [
]
[[package]]
name = "guildhouse-ops"
name = "governance-types"
version = "0.1.0"
dependencies = [
"anyhow",
"org-ops-core",
"hex",
"serde",
"serde_json",
"sha1",
"sha2",
]
[[package]]
@ -429,6 +440,12 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hex"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "http"
version = "1.4.0"
@ -838,11 +855,14 @@ dependencies = [
"anyhow",
"base64",
"clap",
"governance-types",
"hex",
"rand",
"reqwest",
"serde",
"serde_json",
"sha2",
"tempfile",
"urlencoding",
"uuid",
]
@ -1160,6 +1180,17 @@ dependencies = [
"serde",
]
[[package]]
name = "sha1"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "sha2"
version = "0.10.9"

View file

@ -19,6 +19,7 @@ pub mod pkce;
pub mod playbook_commands;
pub mod score_fetcher;
pub mod session;
pub mod shell_class;
pub mod traits;
pub mod gsap_client;
@ -27,6 +28,7 @@ pub use config::OrgOpsConfig;
pub use playbook_commands::PlaybookCommands;
pub use display::SessionBanner;
pub use git_commands::{GitConfig, GovernedGitCommands};
pub use shell_class::ShellClass;
pub use traits::{OrgCommands, RiskScorer};
/// The main entry point. Build with your config and commands, then call run().
@ -88,9 +90,22 @@ impl OrgOps {
org_name: self.config.org_name.clone(),
trust_domain: self.config.trust_domain.clone(),
bascule_endpoint: self.config.bascule_endpoint.clone(),
shell_class: shell_class::ShellClass::default(),
posture_level: 5, // Normal — default for local/unconnected
};
for cmd in &self.commands {
if cmd.handles(name) {
let required = cmd.required_shell_class();
if !ctx.shell_class.satisfies(required) {
anyhow::bail!(
"Command '{}' requires {} shell, but session is {}. \
Reconnect to a host with attested posture \
(TPM + IMA verified) to get a System session.",
name,
required,
ctx.shell_class
);
}
return cmd.handle(name, sub, &ctx);
}
}

View file

@ -1,6 +1,18 @@
// Copyright 2026 Guildhouse Dev
// SPDX-License-Identifier: Apache-2.0
use crate::shell_class::ShellClass;
#[derive(Debug, Clone)]
pub struct SessionContext {
pub org_name: String,
pub trust_domain: String,
pub bascule_endpoint: String,
/// Shell class for this session (Application or System).
/// Populated from Bascule SessionScope when connected via gsh.
/// Defaults to Application for local/unconnected usage.
pub shell_class: ShellClass,
/// Posture level wire value (1-5) at session establishment.
/// Defaults to 5 (Normal) for local/unconnected usage.
pub posture_level: u8,
}

View file

@ -0,0 +1,77 @@
// Copyright 2026 Guildhouse Dev
// SPDX-License-Identifier: Apache-2.0
//! Shell class for org-ops dispatch enforcement.
//!
//! Mirrors `bascule_core::ShellClass` without pulling in the full
//! bascule-core dependency tree. The canonical definition lives in
//! bascule-core; this is a lightweight duplicate for the CLI layer.
use std::fmt;
/// Classification of a governed shell session.
///
/// - `Application` — software operations (deploy, query, playbooks).
/// - `System` — host operations (kernel, firmware, network config).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ShellClass {
Application,
System,
}
impl Default for ShellClass {
fn default() -> Self {
ShellClass::Application
}
}
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 `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,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_is_application() {
assert_eq!(ShellClass::default(), ShellClass::Application);
}
#[test]
fn system_satisfies_both() {
assert!(ShellClass::System.satisfies(ShellClass::System));
assert!(ShellClass::System.satisfies(ShellClass::Application));
}
#[test]
fn application_only_satisfies_application() {
assert!(ShellClass::Application.satisfies(ShellClass::Application));
assert!(!ShellClass::Application.satisfies(ShellClass::System));
}
#[test]
fn display() {
assert_eq!(format!("{}", ShellClass::Application), "application");
assert_eq!(format!("{}", ShellClass::System), "system");
}
}

View file

@ -1,4 +1,5 @@
use crate::session::SessionContext;
use crate::shell_class::ShellClass;
/// Implement this to add org-specific subcommands.
pub trait OrgCommands: Send + Sync {
@ -10,6 +11,15 @@ pub trait OrgCommands: Send + Sync {
matches: &clap::ArgMatches,
ctx: &SessionContext,
) -> anyhow::Result<()>;
/// Minimum shell class required to execute commands from this module.
///
/// Default: `Application` — backward compatible, all existing modules
/// work without changes. Override to `System` for modules that perform
/// host-level operations (kernel, firmware, network config).
fn required_shell_class(&self) -> ShellClass {
ShellClass::Application
}
}
/// Implement this for custom risk scoring.