Compare commits

..

8 commits

Author SHA256 Message Date
Tyler J King
8cec5a6486 feat(org-ops): manifest signature check at load time
Add ManifestMeta to manifest loader for signature validation:
- load_manifest_with_meta() rejects unsigned manifests when
  signatures_required=true and signature_valid=false
- Clear error message directs operator to quorum administrator
- Backward compatible: load_manifest() passes default meta (no check)

2 new tests for signature rejection and acceptance.

Signed-off-by: Tyler King <tking@guildhouse.dev>
Signed-off-by: Tyler J King <tking727@gmail.com>
2026-04-15 19:57:48 -04:00
Tyler J King
7380b834d1 feat(org-ops): gsh corpus install — governed package management wrapper
Add 'gsh corpus' org-ops module with install/list/verify/remove
subcommands. Orchestrates existing package managers and wraps each
installation with Corpus attestation:

- Resolves package source (apt, dnf, direct, auto-stub)
- Downloads via existing package manager
- Computes CID (SHA-256) and generates SBOM (syft or SPDX stub)
- Infers ShellGovernance (shell class, tier, delegation context)
  from known binary classifications
- Creates CorpusEntry CRD via kubectl apply
- Places binary in governed path (/governed/app/bin or system/bin)

corpus verify: checks on-disk binary hashes against manifest CIDs
corpus list: kubectl get corpusentries
corpus remove: kubectl delete corpusentry

8 unit tests for inference logic and SBOM generation.

Signed-off-by: Tyler King <tking@guildhouse.dev>
Signed-off-by: Tyler J King <tking727@gmail.com>
2026-04-15 19:50:38 -04:00
Tyler J King
5c92c027fc feat(org-ops): manifest loader with ShellClass filtering and CID verification
GSH manifest loader reads verified entries from the manifest-{name}
ConfigMap written by the substrate-operator reconciler:

- Filters entries by session ShellClass (System hidden in App shells)
- Filters delegation-context binaries in non-delegation sessions
- compute_file_cid() verifies on-disk binary hashes against CIDs
- verify_binary_hashes() detects tampering and missing binaries
- ManifestLoadResult reports loaded/excluded with reasons

10 unit tests covering:
- Application shell excludes system binaries
- System shell loads all non-delegation entries
- Delegation-permitted sessions load delegation binaries
- Empty/invalid manifest handling
- File CID computation (SHA-256, verified against known hash)
- Hash mismatch and missing file detection

Signed-off-by: Tyler King <tking@guildhouse.dev>
Signed-off-by: Tyler J King <tking727@gmail.com>
2026-04-15 18:38:44 -04:00
Tyler J King
c68456d745 refactor(org-ops): neutralize BXNet identity from framework core
Move all BXNet-specific defaults out of org-ops-core into the example
CLI binary (org-ops-cli/src/main.rs). The framework is now fork-ready
for any consortium without string-replacing org-specific values.

Changes:
- OrgOpsConfig: neutral defaults (my-org, example.com), added
  infra_namespace, bridge_daemonset, ssh_user fields
- AuthConfig: config_dir_name field replaces hardcoded bxnet-ops
  path; config_dir() now a method using config value
- GitConfig: neutral default (git.example.com)
- auth_commands: DID_BRIDGE_PATH env replaces hardcoded dev path
- lib.rs (connect): namespace, daemonset, SSH user, cert paths all
  read from OrgOpsConfig instead of hardcoded strings
- score_fetcher: neutral corpus entry name
- playbook_commands: neutral Keycloak service name and temp dir
- org-ops-cli/main.rs: explicit BXNet example with "replace these"
  comment, all org-specific values passed via config
- .gitignore: added target/

Zero BXNet references remain in org-ops-core source (verified by grep).

Signed-off-by: Tyler King <tking@guildhouse.dev>
Signed-off-by: Tyler J King <tking727@gmail.com>
2026-04-15 16:32:13 -04:00
Tyler J King
b6d9b7fa97 chore: add license and repository to org-ops-core Cargo.toml
Signed-off-by: Tyler King <tking@guildhouse.dev>
Signed-off-by: Tyler J King <tking727@gmail.com>
2026-04-15 16:02:24 -04:00
Tyler J King
62b00ad84c feat(org-ops): worker pre-flight with delegation enforcement
Add worker_preflight() check at dispatch time for commands that
target remote hosts. Enforces three conditions:
1. Session has delegation authority
2. Target host is in delegation scope
3. Target host posture satisfies required shell class

OrgCommands trait extended with target_host() method (default: None
for local commands). SessionContext enriched with delegation_scope.

Lightweight DelegationScope duplicate avoids bascule-core dep chain.
Target posture reader stubbed — requires gateway posture query API
(tracked as follow-up).

Fail-closed: unknown delegation -> denied, unknown posture -> denied.
11 unit tests for delegation and preflight.

Signed-off-by: Tyler King <tking@guildhouse.dev>
Signed-off-by: Tyler J King <tking727@gmail.com>
2026-04-15 15:17:48 -04:00
Tyler J King
323617d6cc 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>
2026-04-15 10:38:26 -04:00
Tyler J King
20286ce0d8 docs: add architecture boundary comment to chronicle_client.rs
Signed-off-by: Tyler King <tking@guildhouse.dev>
2026-04-12 22:21:55 -04:00
18 changed files with 1437 additions and 63 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
target/

37
Cargo.lock generated
View file

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

View file

@ -1,3 +1,9 @@
//! Example org-ops CLI — BXNet consortium.
//!
//! This binary demonstrates how to build a governed CLI for your
//! consortium using the org-ops-core framework. Fork this file and
//! replace the config values with your org's identity.
use org_ops_core::{ use org_ops_core::{
AuthCommands, AuthConfig, GitConfig, GovernedGitCommands, OrgOps, OrgOpsConfig, AuthCommands, AuthConfig, GitConfig, GovernedGitCommands, OrgOps, OrgOpsConfig,
PlaybookCommands, PlaybookCommands,
@ -8,6 +14,7 @@ fn main() -> anyhow::Result<()> {
OrgOps::builder() OrgOps::builder()
.with_config(OrgOpsConfig { .with_config(OrgOpsConfig {
// ── Replace these with your consortium's values ──
org_name: "BXNet".into(), org_name: "BXNet".into(),
trust_domain: "bxnet.io".into(), trust_domain: "bxnet.io".into(),
bascule_endpoint: "bascule.bxnet.io:443".into(), bascule_endpoint: "bascule.bxnet.io:443".into(),
@ -15,8 +22,16 @@ fn main() -> anyhow::Result<()> {
binary_name: "bxnet-ops".into(), binary_name: "bxnet-ops".into(),
description: "BXNet governed operations CLI".into(), description: "BXNet governed operations CLI".into(),
version: env!("CARGO_PKG_VERSION").into(), version: env!("CARGO_PKG_VERSION").into(),
infra_namespace: "guildhouse-infra".into(),
bridge_daemonset: "substrate-bridge".into(),
ssh_user: "tking".into(),
}) })
.with_commands(AuthCommands::new(AuthConfig::default())) .with_commands(AuthCommands::new(AuthConfig {
oidc_issuer: "https://auth.bxnet.io/realms/guildhouse".into(),
client_id: "bxnet-ops".into(),
config_dir_name: "bxnet-ops".into(),
..Default::default()
}))
.with_commands(GovernedGitCommands::new(GitConfig { .with_commands(GovernedGitCommands::new(GitConfig {
forgejo_url: "https://git.bxnet.io".into(), forgejo_url: "https://git.bxnet.io".into(),
forgejo_token, forgejo_token,

View file

@ -1,6 +1,8 @@
[package] [package]
name = "org-ops-core" name = "org-ops-core"
description = "Framework for building governed consortium CLI tools" description = "Framework for building governed consortium CLI tools"
license = "Apache-2.0"
repository = "https://git.guildhouse.dev/guildhouse/org-ops"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true

View file

@ -14,37 +14,24 @@ pub struct AuthConfig {
pub oidc_issuer: String, pub oidc_issuer: String,
pub client_id: String, pub client_id: String,
pub did_bridge_port: u16, pub did_bridge_port: u16,
/// Directory name under ~/.config/ for credential storage.
pub config_dir_name: String,
} }
impl Default for AuthConfig { impl Default for AuthConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
oidc_issuer: "https://auth.bxnet.io/realms/guildhouse".into(), oidc_issuer: "https://auth.example.com/realms/consortium".into(),
client_id: "bxnet-ops".into(), client_id: "org-ops".into(),
did_bridge_port: 7777, did_bridge_port: 7777,
config_dir_name: "org-ops".into(),
} }
} }
} }
fn config_dir() -> PathBuf { fn config_dir_for(name: &str) -> PathBuf {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into()); let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
PathBuf::from(home).join(".config").join("bxnet-ops") PathBuf::from(home).join(".config").join(name)
}
fn cert_path() -> PathBuf {
config_dir().join("identity.pem")
}
fn key_path() -> PathBuf {
config_dir().join("identity.key")
}
fn did_path() -> PathBuf {
config_dir().join("identity.did")
}
fn expiry_path() -> PathBuf {
config_dir().join("identity.expiry")
} }
pub struct AuthCommands { pub struct AuthCommands {
@ -56,8 +43,28 @@ impl AuthCommands {
Self { config } Self { config }
} }
fn ensure_config_dir() { fn config_dir(&self) -> PathBuf {
let dir = config_dir(); config_dir_for(&self.config.config_dir_name)
}
fn cert_path(&self) -> PathBuf {
self.config_dir().join("identity.pem")
}
fn key_path(&self) -> PathBuf {
self.config_dir().join("identity.key")
}
fn did_path(&self) -> PathBuf {
self.config_dir().join("identity.did")
}
fn expiry_path(&self) -> PathBuf {
self.config_dir().join("identity.expiry")
}
fn ensure_config_dir(&self) {
let dir = self.config_dir();
if !dir.exists() { if !dir.exists() {
fs::create_dir_all(&dir).ok(); fs::create_dir_all(&dir).ok();
#[cfg(unix)] #[cfg(unix)]
@ -69,10 +76,9 @@ impl AuthCommands {
} }
fn start_did_bridge(&self) -> anyhow::Result<Option<std::process::Child>> { fn start_did_bridge(&self) -> anyhow::Result<Option<std::process::Child>> {
// Find did_bridge.py // Find did_bridge.py — check DID_BRIDGE_PATH env, then cwd
let home = std::env::var("HOME").unwrap_or_default();
let bridge_paths = [ let bridge_paths = [
format!("{}/projects/substrate-project/guildhouse/services/did-bridge/did_bridge.py", home), std::env::var("DID_BRIDGE_PATH").unwrap_or_default(),
"did_bridge.py".to_string(), "did_bridge.py".to_string(),
]; ];
@ -134,7 +140,7 @@ impl AuthCommands {
println!("Authenticating via OIDC..."); println!("Authenticating via OIDC...");
println!(" Issuer: {}", self.config.oidc_issuer); println!(" Issuer: {}", self.config.oidc_issuer);
Self::ensure_config_dir(); self.ensure_config_dir();
// Start did-bridge // Start did-bridge
eprintln!("Starting did-bridge..."); eprintln!("Starting did-bridge...");
@ -229,21 +235,21 @@ impl AuthCommands {
let (cert, key, did, expires) = result?; let (cert, key, did, expires) = result?;
// Store certificate and key (not token) // Store certificate and key (not token)
fs::write(cert_path(), &cert)?; fs::write(self.cert_path(), &cert)?;
fs::write(key_path(), &key)?; fs::write(self.key_path(), &key)?;
fs::write(did_path(), &did)?; fs::write(self.did_path(), &did)?;
fs::write(expiry_path(), expires.to_string())?; fs::write(self.expiry_path(), expires.to_string())?;
#[cfg(unix)] #[cfg(unix)]
{ {
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
fs::set_permissions(key_path(), fs::Permissions::from_mode(0o600)).ok(); fs::set_permissions(self.key_path(), fs::Permissions::from_mode(0o600)).ok();
} }
println!(); println!();
println!("Authenticated."); println!("Authenticated.");
println!(" DID: {}", did); println!(" DID: {}", did);
println!(" Certificate: {}", cert_path().display()); println!(" Certificate: {}", self.cert_path().display());
println!(" Expires: 1 hour"); println!(" Expires: 1 hour");
println!(" Token: zeroized"); println!(" Token: zeroized");
@ -251,14 +257,14 @@ impl AuthCommands {
} }
fn cmd_status(&self, _ctx: &SessionContext) -> anyhow::Result<()> { fn cmd_status(&self, _ctx: &SessionContext) -> anyhow::Result<()> {
if !cert_path().exists() { if !self.cert_path().exists() {
println!("Not authenticated."); println!("Not authenticated.");
println!("Run: guildhouse-ops auth login"); println!("Run: auth login");
return Ok(()); return Ok(());
} }
let did = fs::read_to_string(did_path()).unwrap_or("unknown".into()); let did = fs::read_to_string(self.did_path()).unwrap_or("unknown".into());
let expires: i64 = fs::read_to_string(expiry_path()) let expires: i64 = fs::read_to_string(self.expiry_path())
.unwrap_or("0".into()) .unwrap_or("0".into())
.trim() .trim()
.parse() .parse()
@ -271,7 +277,7 @@ impl AuthCommands {
if expires > 0 && now > expires { if expires > 0 && now > expires {
println!("Session expired."); println!("Session expired.");
println!("Run: guildhouse-ops auth login"); println!("Run: auth login");
return Ok(()); return Ok(());
} }
@ -284,13 +290,13 @@ impl AuthCommands {
println!("Authenticated"); println!("Authenticated");
println!(" DID: {}", did); println!(" DID: {}", did);
println!(" Remaining: {}", remaining); println!(" Remaining: {}", remaining);
println!(" Certificate: {}", cert_path().display()); println!(" Certificate: {}", self.cert_path().display());
Ok(()) Ok(())
} }
fn cmd_logout(&self, _ctx: &SessionContext) -> anyhow::Result<()> { fn cmd_logout(&self, _ctx: &SessionContext) -> anyhow::Result<()> {
for path in &[cert_path(), key_path(), did_path(), expiry_path()] { for path in &[self.cert_path(), self.key_path(), self.did_path(), self.expiry_path()] {
if path.exists() { if path.exists() {
fs::remove_file(path)?; fs::remove_file(path)?;
} }

View file

@ -1,11 +1,20 @@
// Copyright 2026 Guildhouse Dev // Copyright 2026 Guildhouse Dev
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
//! CloudEvents 1.0 Chronicle emitter. //! Chronicle CloudEvents Emitter
//!
//! Emits governance events to the Chronicle ingestion bridge as
//! CloudEvents 1.0 payloads. Events enter the Chronicle persistence
//! pipeline (Pulsar -> ClickHouse) via the bridge.
//!
//! This is NOT a direct Chronicle write — the in-memory Private
//! Ledger is maintained by substrate-chronicle-core. Userspace
//! emitters always go through the ingestion bridge.
//! //!
//! Replaces the fake Forgejo push webhook pattern used previously.
//! Git-originated events use the commit SHA as the CloudEvent `id`. //! Git-originated events use the commit SHA as the CloudEvent `id`.
//! Non-git events use a UUID v4. //! Non-git events use a UUID v4.
//!
//! See: ARCHITECTURE-CHRONICLE.md
use std::time::Duration; use std::time::Duration;

View file

@ -1,5 +1,16 @@
/// Configuration for an org-ops instance. /// Configuration for an org-ops instance.
/// Fork org-ops and set these values for your consortium. ///
/// Set these values for your consortium in the CLI binary's `main()`:
/// ```ignore
/// OrgOps::builder()
/// .with_config(OrgOpsConfig {
/// org_name: "MyOrg".into(),
/// trust_domain: "myorg.example.com".into(),
/// ..Default::default()
/// })
/// .build()
/// .run()
/// ```
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct OrgOpsConfig { pub struct OrgOpsConfig {
pub org_name: String, pub org_name: String,
@ -9,18 +20,27 @@ pub struct OrgOpsConfig {
pub binary_name: String, pub binary_name: String,
pub description: String, pub description: String,
pub version: String, pub version: String,
/// Kubernetes namespace for port-forward and corpus lookups.
pub infra_namespace: String,
/// DaemonSet name for Bascule port-forward target.
pub bridge_daemonset: String,
/// SSH user for governed shell connections.
pub ssh_user: String,
} }
impl Default for OrgOpsConfig { impl Default for OrgOpsConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
org_name: "BXNet".into(), org_name: "my-org".into(),
trust_domain: "bxnet.io".into(), trust_domain: "example.com".into(),
bascule_endpoint: "bascule.bxnet.io:443".into(), bascule_endpoint: "localhost:2222".into(),
chronicle_endpoint: "chronicle.bxnet.io:8080".into(), chronicle_endpoint: "localhost:8090".into(),
binary_name: "bxnet-ops".into(), binary_name: "org-ops".into(),
description: "BXNet governed operations CLI".into(), description: "Governed operations CLI".into(),
version: env!("CARGO_PKG_VERSION").into(), version: env!("CARGO_PKG_VERSION").into(),
infra_namespace: "guildhouse-infra".into(),
bridge_daemonset: "substrate-bridge".into(),
ssh_user: std::env::var("USER").unwrap_or_else(|_| "operator".into()),
} }
} }
} }

View file

@ -0,0 +1,461 @@
// Copyright 2026 Guildhouse Dev
// SPDX-License-Identifier: Apache-2.0
//! `gsh corpus` — governed package management wrapper.
//!
//! Orchestrates existing package managers (apt, dnf, OCI, cargo) and wraps
//! each installation with Corpus attestation: CID computation, SBOM
//! generation, CorpusEntry CRD creation, and ShellManifest update.
use crate::manifest_loader::compute_file_cid;
use crate::session::SessionContext;
use crate::shell_class::ShellClass;
use crate::traits::OrgCommands;
use sha2::{Digest, Sha256};
use std::path::{Path, PathBuf};
use std::process::Command;
// ─── OrgCommands implementation ─────────────────────────────────────
pub struct CorpusCommands;
impl OrgCommands for CorpusCommands {
fn commands(&self) -> Vec<clap::Command> {
vec![clap::Command::new("corpus")
.about("Governed binary management")
.subcommand(
clap::Command::new("install")
.about("Install a binary with Corpus attestation")
.arg(clap::Arg::new("name").required(true).help("Binary name"))
.arg(
clap::Arg::new("version")
.long("version")
.required(true)
.help("Version"),
)
.arg(
clap::Arg::new("source")
.long("source")
.default_value("auto")
.help("Package source: auto, apt, dnf, oci, cargo, direct"),
),
)
.subcommand(clap::Command::new("list").about("List installed corpus entries"))
.subcommand(
clap::Command::new("verify")
.about("Verify on-disk binaries against manifest CIDs")
.arg(clap::Arg::new("manifest").long("manifest").help("Manifest name")),
)
.subcommand(
clap::Command::new("remove")
.about("Remove a corpus entry")
.arg(clap::Arg::new("name").required(true).help("Binary name")),
)]
}
fn handles(&self, name: &str) -> bool {
name == "corpus"
}
fn required_shell_class(&self) -> ShellClass {
ShellClass::Application
}
fn handle(
&self,
_name: &str,
matches: &clap::ArgMatches,
ctx: &SessionContext,
) -> anyhow::Result<()> {
match matches.subcommand() {
Some(("install", sub)) => cmd_install(sub, ctx),
Some(("list", _sub)) => cmd_list(ctx),
Some(("verify", sub)) => cmd_verify(sub, ctx),
Some(("remove", sub)) => cmd_remove(sub, ctx),
_ => {
println!("Usage: corpus <install|list|verify|remove>");
Ok(())
}
}
}
}
// ─── Install flow ───────────────────────────────────────────────────
fn cmd_install(args: &clap::ArgMatches, _ctx: &SessionContext) -> anyhow::Result<()> {
let name = args.get_one::<String>("name").unwrap();
let version = args.get_one::<String>("version").unwrap();
let source = args.get_one::<String>("source").unwrap();
println!("Installing {name} v{version} (source: {source})...");
// Step 1: Resolve and download
let binary_path = download_binary(name, version, source)?;
println!(" Downloaded: {}", binary_path.display());
// Step 2: Compute CID
let cid = compute_file_cid(&binary_path)
.map_err(|e| anyhow::anyhow!("CID computation failed: {e}"))?;
println!(" CID: {}", &cid[..23]);
// Step 3: Generate SBOM
let sbom = generate_sbom(&binary_path, name, version);
let sbom_json = serde_json::to_string(&sbom)?;
let sbom_cid = format!(
"sha256:{}",
hex::encode(Sha256::digest(sbom_json.as_bytes()))
);
println!(" SBOM: {}", &sbom_cid[..23]);
// Step 4: Infer governance metadata
let shell_class = infer_shell_class(name);
let tier = infer_tier(name);
let delegation = infer_delegation_context(name);
println!(" Shell class: {shell_class}, tier: {tier}, delegation: {delegation}");
// Step 5: Create CorpusEntry CRD (via kubectl)
let corpus_entry_yaml = build_corpus_entry_yaml(
name, version, &cid, &sbom_cid, &shell_class, &tier, delegation,
);
apply_corpus_entry(&corpus_entry_yaml, name)?;
println!(" CorpusEntry CRD applied");
// Step 6: Place in governed path
let governed_dir = if shell_class == "system" {
"/governed/system/bin"
} else {
"/governed/app/bin"
};
let target = PathBuf::from(governed_dir).join(name);
if Path::new(governed_dir).exists() {
std::fs::copy(&binary_path, &target).ok();
println!(" Installed to: {}", target.display());
} else {
println!(" Governed path {governed_dir} not mounted (dev mode)");
}
println!(" Installed {name} v{version}");
Ok(())
}
fn cmd_list(_ctx: &SessionContext) -> anyhow::Result<()> {
let output = Command::new("kubectl")
.args(["get", "corpusentries", "-o", "wide"])
.output();
match output {
Ok(o) if o.status.success() => {
println!("{}", String::from_utf8_lossy(&o.stdout));
}
_ => println!(" (kubectl not available or no cluster connection)"),
}
Ok(())
}
fn cmd_verify(args: &clap::ArgMatches, _ctx: &SessionContext) -> anyhow::Result<()> {
let manifest_name = args
.get_one::<String>("manifest")
.cloned()
.unwrap_or_else(|| "default".into());
// Read manifest ConfigMap
let output = Command::new("kubectl")
.args([
"get",
"configmap",
&format!("manifest-{manifest_name}"),
"-o",
"jsonpath={.data.manifest\\.json}",
])
.output();
match output {
Ok(o) if o.status.success() => {
let json = String::from_utf8_lossy(&o.stdout);
let entries: Vec<serde_json::Value> = serde_json::from_str(&json)?;
println!("Manifest {manifest_name}: {} entries", entries.len());
let mut verified = 0;
let mut failed = 0;
for entry in &entries {
let name = entry["binary_name"].as_str().unwrap_or("?");
let cid = entry["cid"].as_str().unwrap_or("?");
// Check /governed/app/bin and /governed/system/bin
let paths = [
PathBuf::from("/governed/app/bin").join(name),
PathBuf::from("/governed/system/bin").join(name),
];
let found = paths.iter().find(|p| p.exists());
match found {
Some(path) => match compute_file_cid(path) {
Ok(actual) if actual == cid => {
println!(" {name}: verified");
verified += 1;
}
Ok(actual) => {
println!(" {name}: MISMATCH (expected {}, got {})", &cid[..16], &actual[..16]);
failed += 1;
}
Err(e) => {
println!(" {name}: ERROR ({e})");
failed += 1;
}
},
None => {
println!(" {name}: NOT FOUND on disk");
failed += 1;
}
}
}
println!("\n{verified} verified, {failed} failed");
}
_ => println!(" (manifest ConfigMap not found or kubectl unavailable)"),
}
Ok(())
}
fn cmd_remove(args: &clap::ArgMatches, _ctx: &SessionContext) -> anyhow::Result<()> {
let name = args.get_one::<String>("name").unwrap();
let output = Command::new("kubectl")
.args(["delete", "corpusentry", name])
.output();
match output {
Ok(o) if o.status.success() => println!("Removed corpus entry: {name}"),
Ok(o) => {
let err = String::from_utf8_lossy(&o.stderr);
println!("Failed to remove {name}: {err}");
}
Err(e) => println!("kubectl error: {e}"),
}
Ok(())
}
// ─── Package resolution ─────────────────────────────────────────────
fn download_binary(name: &str, version: &str, source: &str) -> anyhow::Result<PathBuf> {
let temp_dir = std::env::temp_dir().join("corpus-install");
std::fs::create_dir_all(&temp_dir)?;
let target = temp_dir.join(name);
match source {
"apt" => {
let status = Command::new("apt-get")
.args(["download", &format!("{name}={version}")])
.current_dir(&temp_dir)
.status()?;
if !status.success() {
anyhow::bail!("apt-get download failed for {name}={version}");
}
}
"dnf" => {
let status = Command::new("dnf")
.args(["download", "--destdir", &temp_dir.display().to_string(), name])
.status()?;
if !status.success() {
anyhow::bail!("dnf download failed for {name}");
}
}
"direct" => {
// Expect CORPUS_DOWNLOAD_URL env var
let url = std::env::var("CORPUS_DOWNLOAD_URL")
.unwrap_or_else(|_| format!("https://example.com/releases/{name}/v{version}/{name}"));
let status = Command::new("curl")
.args(["-sL", "-o", &target.display().to_string(), &url])
.status()?;
if !status.success() {
anyhow::bail!("curl download failed: {url}");
}
}
_ => {
// Auto/unsupported — create a placeholder for dev/test
std::fs::write(&target, format!("#!/bin/sh\necho '{name} v{version} (stub)'\n"))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755))?;
}
}
}
Ok(target)
}
// ─── SBOM generation ────────────────────────────────────────────────
fn generate_sbom(binary_path: &Path, name: &str, version: &str) -> serde_json::Value {
// Try syft first
let syft = Command::new("syft")
.args([
"scan",
&binary_path.display().to_string(),
"-o",
"spdx-json",
])
.output();
if let Ok(output) = syft {
if output.status.success() {
if let Ok(sbom) = serde_json::from_slice(&output.stdout) {
return sbom;
}
}
}
// Minimal SPDX stub
serde_json::json!({
"spdxVersion": "SPDX-2.3",
"dataLicense": "CC0-1.0",
"SPDXID": "SPDXRef-DOCUMENT",
"name": format!("{name}-{version}"),
"packages": [{
"SPDXID": format!("SPDXRef-{name}"),
"name": name,
"versionInfo": version,
}]
})
}
// ─── Governance inference ───────────────────────────────────────────
fn infer_shell_class(name: &str) -> String {
const SYSTEM_BINARIES: &[&str] = &[
"modprobe", "insmod", "rmmod", "fw_setenv", "flashrom", "fwupd",
"ip", "iptables", "nft", "tc", "fdisk", "mkfs", "mount", "lvm",
"systemctl", "journalctl", "dmidecode", "lspci", "lsusb",
];
if SYSTEM_BINARIES.contains(&name) {
"system".into()
} else {
"application".into()
}
}
fn infer_tier(name: &str) -> String {
const INFRA_BINARIES: &[&str] = &[
"modprobe", "insmod", "flashrom", "fwupd", "fdisk", "mkfs", "mount",
];
if INFRA_BINARIES.contains(&name) {
"c".into()
} else {
"a".into()
}
}
fn infer_delegation_context(name: &str) -> bool {
const DELEGATION_TOOLS: &[&str] = &[
"ansible", "ansible-playbook", "ansible-galaxy", "terraform", "tofu",
"kubectl", "helm", "salt", "salt-ssh", "puppet",
];
DELEGATION_TOOLS.iter().any(|t| name.contains(t))
}
// ─── CorpusEntry CRD ────────────────────────────────────────────────
fn build_corpus_entry_yaml(
name: &str,
version: &str,
cid: &str,
sbom_cid: &str,
shell_class: &str,
tier: &str,
delegation: bool,
) -> String {
format!(
r#"apiVersion: substrate.io/v1alpha1
kind: CorpusEntry
metadata:
name: {name}-v{version}
spec:
cid: "{cid}"
binary_name: "{name}"
runtime: "native"
capability_mask: 1
sbom_cid: "{sbom_cid}"
shell_class_required: "{shell_class}"
tier: "{tier}"
delegation_context: {delegation}
max_dispatch_hops: 3
requires_user_svid: false
requires_hardware_svid: false
"#
)
}
fn apply_corpus_entry(yaml: &str, name: &str) -> anyhow::Result<()> {
let temp = std::env::temp_dir().join(format!("corpus-{name}.yaml"));
std::fs::write(&temp, yaml)?;
let output = Command::new("kubectl")
.args(["apply", "-f", &temp.display().to_string()])
.output();
match output {
Ok(o) if o.status.success() => Ok(()),
Ok(o) => {
let err = String::from_utf8_lossy(&o.stderr);
// Non-fatal in dev mode (no cluster)
eprintln!(" kubectl apply: {err}");
Ok(())
}
Err(_) => {
eprintln!(" kubectl not available (dev mode)");
Ok(())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn infer_system_class_for_modprobe() {
assert_eq!(infer_shell_class("modprobe"), "system");
}
#[test]
fn infer_application_class_for_kubectl() {
assert_eq!(infer_shell_class("kubectl"), "application");
}
#[test]
fn infer_tier_c_for_firmware() {
assert_eq!(infer_tier("flashrom"), "c");
}
#[test]
fn infer_tier_a_default() {
assert_eq!(infer_tier("my-tool"), "a");
}
#[test]
fn infer_delegation_for_ansible() {
assert!(infer_delegation_context("ansible-playbook"));
}
#[test]
fn infer_no_delegation_for_cat() {
assert!(!infer_delegation_context("cat"));
}
#[test]
fn sbom_generates_minimal_stub() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test-bin");
std::fs::write(&path, b"test").unwrap();
let sbom = generate_sbom(&path, "test-bin", "1.0.0");
assert_eq!(sbom["spdxVersion"], "SPDX-2.3");
assert_eq!(sbom["packages"][0]["name"], "test-bin");
}
#[test]
fn corpus_entry_yaml_valid() {
let yaml = build_corpus_entry_yaml(
"kubectl", "1.30.2", "sha256:abc", "sha256:def",
"application", "a", false,
);
assert!(yaml.contains("kind: CorpusEntry"));
assert!(yaml.contains("shell_class_required: \"application\""));
assert!(yaml.contains("delegation_context: false"));
}
}

View file

@ -0,0 +1,131 @@
// Copyright 2026 Guildhouse Dev
// SPDX-License-Identifier: Apache-2.0
//! Lightweight delegation scope for org-ops dispatch enforcement.
//!
//! Mirrors the essential fields from `bascule_core::DelegationScope`
//! without pulling in the full bascule-core dependency tree.
use crate::shell_class::ShellClass;
/// Delegation authority for a session.
#[derive(Debug, Clone)]
pub struct DelegationScope {
/// Whether delegation is permitted.
pub permitted: bool,
/// Specific target hosts allowed (empty = no targets).
pub target_hosts: Vec<String>,
/// Maximum shell class that can be delegated.
pub max_delegated_class: ShellClass,
}
impl Default for DelegationScope {
fn default() -> Self {
Self {
permitted: false,
target_hosts: vec![],
max_delegated_class: ShellClass::Application,
}
}
}
/// Result of a delegation check.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DelegationDecision {
Permitted,
Denied { reason: String },
}
impl DelegationScope {
/// Check whether delegation is permitted for a target and class.
pub fn permits(&self, target_host: &str, required_class: ShellClass) -> DelegationDecision {
if !self.permitted {
return DelegationDecision::Denied {
reason: "session does not have delegation authority".into(),
};
}
if !self.target_hosts.is_empty()
&& !self.target_hosts.iter().any(|h| h == target_host)
{
return DelegationDecision::Denied {
reason: format!("target '{target_host}' not in delegation scope"),
};
}
if !self.max_delegated_class.satisfies(required_class) {
return DelegationDecision::Denied {
reason: format!(
"delegation permits {}, but {} required",
self.max_delegated_class, required_class
),
};
}
DelegationDecision::Permitted
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_denies() {
let scope = DelegationScope::default();
let d = scope.permits("any", ShellClass::Application);
assert!(matches!(d, DelegationDecision::Denied { .. }));
}
#[test]
fn permitted_with_matching_host() {
let scope = DelegationScope {
permitted: true,
target_hosts: vec!["worker-1".into()],
max_delegated_class: ShellClass::System,
};
assert_eq!(
scope.permits("worker-1", ShellClass::System),
DelegationDecision::Permitted
);
}
#[test]
fn permitted_host_not_in_scope() {
let scope = DelegationScope {
permitted: true,
target_hosts: vec!["worker-1".into()],
max_delegated_class: ShellClass::System,
};
assert!(matches!(
scope.permits("worker-99", ShellClass::Application),
DelegationDecision::Denied { .. }
));
}
#[test]
fn empty_hosts_allows_any_when_permitted() {
let scope = DelegationScope {
permitted: true,
target_hosts: vec![], // empty = open delegation
max_delegated_class: ShellClass::System,
};
assert_eq!(
scope.permits("any-host", ShellClass::System),
DelegationDecision::Permitted
);
}
#[test]
fn class_ceiling_enforced() {
let scope = DelegationScope {
permitted: true,
target_hosts: vec![],
max_delegated_class: ShellClass::Application,
};
assert!(matches!(
scope.permits("host", ShellClass::System),
DelegationDecision::Denied { .. }
));
}
}

View file

@ -28,9 +28,9 @@ pub struct GitConfig {
impl Default for GitConfig { impl Default for GitConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
forgejo_url: "https://git.bxnet.io".into(), forgejo_url: "https://git.example.com".into(),
forgejo_token: None, forgejo_token: None,
chronicle_webhook: "http://localhost:8090/webhook/forgejo".into(), chronicle_webhook: "http://localhost:8090/webhook/cloudevents".into(),
} }
} }
} }

View file

@ -11,14 +11,19 @@ pub mod apply_gate;
pub mod auth_commands; pub mod auth_commands;
pub mod chronicle_client; pub mod chronicle_client;
pub mod config; pub mod config;
pub mod corpus_commands;
pub mod delegation;
pub mod git_hash; pub mod git_hash;
pub mod manifest_loader;
pub mod test_evidence; pub mod test_evidence;
pub mod display; pub mod display;
pub mod git_commands; pub mod git_commands;
pub mod pkce; pub mod pkce;
pub mod playbook_commands; pub mod playbook_commands;
pub mod preflight;
pub mod score_fetcher; pub mod score_fetcher;
pub mod session; pub mod session;
pub mod shell_class;
pub mod traits; pub mod traits;
pub mod gsap_client; pub mod gsap_client;
@ -27,6 +32,8 @@ pub use config::OrgOpsConfig;
pub use playbook_commands::PlaybookCommands; pub use playbook_commands::PlaybookCommands;
pub use display::SessionBanner; pub use display::SessionBanner;
pub use git_commands::{GitConfig, GovernedGitCommands}; pub use git_commands::{GitConfig, GovernedGitCommands};
pub use corpus_commands::CorpusCommands;
pub use shell_class::ShellClass;
pub use traits::{OrgCommands, RiskScorer}; pub use traits::{OrgCommands, RiskScorer};
/// The main entry point. Build with your config and commands, then call run(). /// The main entry point. Build with your config and commands, then call run().
@ -88,9 +95,52 @@ impl OrgOps {
org_name: self.config.org_name.clone(), org_name: self.config.org_name.clone(),
trust_domain: self.config.trust_domain.clone(), trust_domain: self.config.trust_domain.clone(),
bascule_endpoint: self.config.bascule_endpoint.clone(), bascule_endpoint: self.config.bascule_endpoint.clone(),
shell_class: shell_class::ShellClass::default(),
posture_level: 5, // Normal — default for local/unconnected
delegation_scope: delegation::DelegationScope::default(),
}; };
for cmd in &self.commands { for cmd in &self.commands {
if cmd.handles(name) { if cmd.handles(name) {
// Local shell class enforcement
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
);
}
// Remote delegation pre-flight
if let Some(target) = cmd.target_host(sub) {
// Target posture not yet queryable — stub returns
// None which fails closed in the preflight check.
// TODO: Wire posture query via gateway API or ConfigMap.
let target_posture: Option<u8> = None;
let result = preflight::worker_preflight(
&ctx,
&target,
cmd.required_shell_class(),
target_posture,
);
match result {
preflight::PreflightResult::Cleared { .. } => {}
preflight::PreflightResult::Denied {
reason, hint, ..
} => {
anyhow::bail!(
"Delegation to '{}' denied: {}. {}",
target,
reason,
hint
);
}
}
}
return cmd.handle(name, sub, &ctx); return cmd.handle(name, sub, &ctx);
} }
} }
@ -145,8 +195,8 @@ impl OrgOps {
let pf = std::process::Command::new("kubectl") let pf = std::process::Command::new("kubectl")
.args([ .args([
"port-forward", "port-forward",
"-n", "guildhouse-infra", "-n", &self.config.infra_namespace,
"daemonset/substrate-bridge", &format!("daemonset/{}", self.config.bridge_daemonset),
"12222:2222", "12222:2222",
]) ])
.stdout(std::process::Stdio::null()) .stdout(std::process::Stdio::null())
@ -172,8 +222,9 @@ impl OrgOps {
// 5. Exec SSH — use certificate if available // 5. Exec SSH — use certificate if available
let home = std::env::var("HOME").unwrap_or_default(); let home = std::env::var("HOME").unwrap_or_default();
let cert_file = format!("{}/.config/guildhouse-ops/identity.pem", home); let config_name = &self.config.binary_name;
let key_file = format!("{}/.config/guildhouse-ops/identity.key", home); let cert_file = format!("{}/.config/{}/identity.pem", home, config_name);
let key_file = format!("{}/.config/{}/identity.key", home, config_name);
let has_cert = std::path::Path::new(&cert_file).exists() let has_cert = std::path::Path::new(&cert_file).exists()
&& std::path::Path::new(&key_file).exists(); && std::path::Path::new(&key_file).exists();
@ -193,7 +244,7 @@ impl OrgOps {
eprintln!(" Tip: run 'auth login' for certificate auth"); eprintln!(" Tip: run 'auth login' for certificate auth");
} }
ssh_args.push(format!("tking@{ssh_host}")); ssh_args.push(format!("{}@{ssh_host}", self.config.ssh_user));
let status = std::process::Command::new("ssh") let status = std::process::Command::new("ssh")
.args(&ssh_args) .args(&ssh_args)

View file

@ -0,0 +1,353 @@
// Copyright 2026 Guildhouse Dev
// SPDX-License-Identifier: Apache-2.0
//! Manifest loader — reads verified manifest entries and filters them
//! by the session's ShellClass and delegation scope.
//!
//! Called at GSH session start to determine which binaries are authorized.
//! Reads the `manifest-{name}` ConfigMap output written by the
//! substrate-operator's manifest reconciler.
use crate::session::SessionContext;
use crate::shell_class::ShellClass;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
/// A verified manifest entry (matches the reconciler's output format).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VerifiedEntry {
pub binary_name: String,
pub cid: String,
pub shell_class: String,
pub delegation_context: bool,
pub capability_mask: i64,
pub tier: String,
}
/// A binary that was loaded (authorized for this session).
#[derive(Debug, Clone)]
pub struct LoadedBinary {
pub name: String,
pub cid: String,
pub shell_class: String,
}
/// A binary that was excluded (not authorized for this session).
#[derive(Debug, Clone)]
pub struct ExcludedBinary {
pub name: String,
pub reason: String,
}
/// Result of loading and filtering a manifest for a session.
#[derive(Debug)]
pub struct ManifestLoadResult {
pub loaded: Vec<LoadedBinary>,
pub excluded: Vec<ExcludedBinary>,
pub manifest_cid: String,
}
/// Load and filter manifest entries for the current session.
///
/// Reads the manifest JSON (from ConfigMap mount or API), filters
/// entries by the session's ShellClass and delegation scope, and
/// optionally verifies on-disk binary hashes.
/// Optional signature status from the reconciler.
/// Passed alongside manifest_json when the ConfigMap includes it.
#[derive(Debug, Clone, Default)]
pub struct ManifestMeta {
/// Whether the reconciler verified all required witness signatures.
pub signature_valid: bool,
/// Whether witness signatures are required for this manifest.
pub signatures_required: bool,
}
pub fn load_manifest(
ctx: &SessionContext,
manifest_json: &str,
) -> Result<ManifestLoadResult, String> {
load_manifest_with_meta(ctx, manifest_json, &ManifestMeta::default())
}
/// Load manifest with optional signature metadata.
///
/// If `meta.signatures_required` is true and `meta.signature_valid` is
/// false, the load is rejected (unsigned manifests don't take effect).
pub fn load_manifest_with_meta(
ctx: &SessionContext,
manifest_json: &str,
meta: &ManifestMeta,
) -> Result<ManifestLoadResult, String> {
// Check signature if required
if meta.signatures_required && !meta.signature_valid {
return Err(
"manifest not signed by all required Accord witnesses — \
contact your quorum administrator"
.into(),
);
}
let entries: Vec<VerifiedEntry> =
serde_json::from_str(manifest_json).map_err(|e| format!("manifest parse error: {e}"))?;
let manifest_cid = format!(
"sha256:{}",
hex::encode(Sha256::digest(manifest_json.as_bytes()))
);
let mut loaded = Vec::new();
let mut excluded = Vec::new();
for entry in &entries {
// Filter by ShellClass
let entry_class = parse_shell_class(&entry.shell_class);
if !ctx.shell_class.satisfies(entry_class) {
excluded.push(ExcludedBinary {
name: entry.binary_name.clone(),
reason: format!("requires {} shell", entry.shell_class),
});
continue;
}
// Filter delegation-context binaries in non-delegation sessions
if entry.delegation_context && !ctx.delegation_scope.permitted {
excluded.push(ExcludedBinary {
name: entry.binary_name.clone(),
reason: "requires delegation authority".into(),
});
continue;
}
loaded.push(LoadedBinary {
name: entry.binary_name.clone(),
cid: entry.cid.clone(),
shell_class: entry.shell_class.clone(),
});
}
Ok(ManifestLoadResult {
loaded,
excluded,
manifest_cid,
})
}
/// Compute a CID (SHA-256) for a file on disk.
///
/// Matches the `compute_cid()` format in gsap_client.rs.
pub fn compute_file_cid(path: &std::path::Path) -> Result<String, String> {
let bytes = std::fs::read(path).map_err(|e| format!("read {}: {e}", path.display()))?;
Ok(format!("sha256:{}", hex::encode(Sha256::digest(&bytes))))
}
/// Verify that on-disk binaries match their manifest CIDs.
///
/// Returns a list of mismatched entries. Empty = all verified.
pub fn verify_binary_hashes(
loaded: &[LoadedBinary],
bin_dir: &std::path::Path,
) -> Vec<ExcludedBinary> {
let mut mismatches = Vec::new();
for binary in loaded {
let path = bin_dir.join(&binary.name);
match compute_file_cid(&path) {
Ok(on_disk_cid) => {
if on_disk_cid != binary.cid {
mismatches.push(ExcludedBinary {
name: binary.name.clone(),
reason: format!(
"hash mismatch: expected {}, found {}",
binary.cid, on_disk_cid
),
});
}
}
Err(reason) => {
mismatches.push(ExcludedBinary {
name: binary.name.clone(),
reason,
});
}
}
}
mismatches
}
fn parse_shell_class(s: &str) -> ShellClass {
match s {
"system" => ShellClass::System,
_ => ShellClass::Application,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::delegation::DelegationScope;
fn ctx(shell_class: ShellClass, delegation_permitted: bool) -> SessionContext {
SessionContext {
org_name: "test".into(),
trust_domain: "test.io".into(),
bascule_endpoint: "localhost".into(),
shell_class,
posture_level: 5,
delegation_scope: DelegationScope {
permitted: delegation_permitted,
target_hosts: vec![],
max_delegated_class: ShellClass::System,
},
}
}
fn manifest_json() -> String {
serde_json::to_string(&vec![
VerifiedEntry {
binary_name: "kubectl".into(),
cid: "sha256:aaa".into(),
shell_class: "application".into(),
delegation_context: false,
capability_mask: 1,
tier: "a".into(),
},
VerifiedEntry {
binary_name: "firmware-update".into(),
cid: "sha256:bbb".into(),
shell_class: "system".into(),
delegation_context: false,
capability_mask: 7,
tier: "c".into(),
},
VerifiedEntry {
binary_name: "ansible-playbook".into(),
cid: "sha256:ccc".into(),
shell_class: "application".into(),
delegation_context: true,
capability_mask: 3,
tier: "a".into(),
},
])
.unwrap()
}
#[test]
fn application_shell_excludes_system_binaries() {
let result = load_manifest(&ctx(ShellClass::Application, false), &manifest_json()).unwrap();
assert_eq!(result.loaded.len(), 1); // only kubectl (ansible needs delegation)
assert_eq!(result.loaded[0].name, "kubectl");
assert_eq!(result.excluded.len(), 2);
}
#[test]
fn system_shell_loads_all_non_delegation() {
let result = load_manifest(&ctx(ShellClass::System, false), &manifest_json()).unwrap();
// kubectl + firmware-update loaded; ansible excluded (delegation_context)
assert_eq!(result.loaded.len(), 2);
assert_eq!(result.excluded.len(), 1);
assert_eq!(result.excluded[0].name, "ansible-playbook");
}
#[test]
fn delegation_permitted_loads_delegation_binaries() {
let result = load_manifest(&ctx(ShellClass::Application, true), &manifest_json()).unwrap();
// kubectl + ansible loaded; firmware-update excluded (system)
assert_eq!(result.loaded.len(), 2);
let names: Vec<&str> = result.loaded.iter().map(|b| b.name.as_str()).collect();
assert!(names.contains(&"kubectl"));
assert!(names.contains(&"ansible-playbook"));
assert_eq!(result.excluded.len(), 1);
assert_eq!(result.excluded[0].name, "firmware-update");
}
#[test]
fn system_shell_with_delegation_loads_all() {
let result = load_manifest(&ctx(ShellClass::System, true), &manifest_json()).unwrap();
assert_eq!(result.loaded.len(), 3);
assert_eq!(result.excluded.len(), 0);
}
#[test]
fn empty_manifest_returns_empty() {
let result = load_manifest(&ctx(ShellClass::Application, false), "[]").unwrap();
assert_eq!(result.loaded.len(), 0);
assert_eq!(result.excluded.len(), 0);
}
#[test]
fn invalid_json_returns_error() {
let result = load_manifest(&ctx(ShellClass::Application, false), "not json");
assert!(result.is_err());
}
#[test]
fn unsigned_manifest_rejected_when_required() {
let meta = ManifestMeta {
signatures_required: true,
signature_valid: false,
};
let result =
load_manifest_with_meta(&ctx(ShellClass::System, true), &manifest_json(), &meta);
assert!(result.is_err());
assert!(result.unwrap_err().contains("not signed"));
}
#[test]
fn signed_manifest_accepted() {
let meta = ManifestMeta {
signatures_required: true,
signature_valid: true,
};
let result =
load_manifest_with_meta(&ctx(ShellClass::System, true), &manifest_json(), &meta);
assert!(result.is_ok());
}
#[test]
fn manifest_cid_is_computed() {
let result = load_manifest(&ctx(ShellClass::System, true), &manifest_json()).unwrap();
assert!(result.manifest_cid.starts_with("sha256:"));
assert_eq!(result.manifest_cid.len(), 7 + 64); // "sha256:" + 64 hex chars
}
#[test]
fn file_cid_computation() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test-binary");
std::fs::write(&path, b"hello world").unwrap();
let cid = compute_file_cid(&path).unwrap();
assert!(cid.starts_with("sha256:"));
// SHA-256 of "hello world" is known
assert_eq!(
cid,
"sha256:b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
);
}
#[test]
fn verify_hashes_detects_mismatch() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test-bin");
std::fs::write(&path, b"actual content").unwrap();
let loaded = vec![LoadedBinary {
name: "test-bin".into(),
cid: "sha256:wrong".into(),
shell_class: "application".into(),
}];
let mismatches = verify_binary_hashes(&loaded, dir.path());
assert_eq!(mismatches.len(), 1);
assert!(mismatches[0].reason.contains("hash mismatch"));
}
#[test]
fn verify_hashes_missing_file() {
let dir = tempfile::tempdir().unwrap();
let loaded = vec![LoadedBinary {
name: "nonexistent".into(),
cid: "sha256:abc".into(),
shell_class: "application".into(),
}];
let mismatches = verify_binary_hashes(&loaded, dir.path());
assert_eq!(mismatches.len(), 1);
}
}

View file

@ -152,12 +152,12 @@ impl PlaybookCommands {
let gsap_ac = if let Ok(broker_url) = std::env::var("GSAP_BROKER_URL") { let gsap_ac = if let Ok(broker_url) = std::env::var("GSAP_BROKER_URL") {
let token = std::env::var("GSAP_BEARER_TOKEN").unwrap_or_default(); let token = std::env::var("GSAP_BEARER_TOKEN").unwrap_or_default();
let driver_id = std::env::var("GSAP_DRIVER_ID") let driver_id = std::env::var("GSAP_DRIVER_ID")
.unwrap_or_else(|_| "keycloak-guildhouse".into()); .unwrap_or_else(|_| "keycloak".into());
let accord = std::env::var("GSAP_ACCORD_TEMPLATE") let accord = std::env::var("GSAP_ACCORD_TEMPLATE")
.unwrap_or_else(|_| accord_name.clone()); .unwrap_or_else(|_| accord_name.clone());
let session_dir = std::env::var("GSAP_SESSION_DIR") let session_dir = std::env::var("GSAP_SESSION_DIR")
.map(PathBuf::from) .map(PathBuf::from)
.unwrap_or_else(|_| std::env::temp_dir().join("bxnet-gsap")); .unwrap_or_else(|_| std::env::temp_dir().join("org-ops-gsap"));
let client = GsapClient::new(broker_url, token, session_dir); let client = GsapClient::new(broker_url, token, session_dir);
let params_json = serde_json::to_string(&extra_vars).unwrap_or_default(); let params_json = serde_json::to_string(&extra_vars).unwrap_or_default();

View file

@ -0,0 +1,181 @@
// Copyright 2026 Guildhouse Dev
// SPDX-License-Identifier: Apache-2.0
//! Worker pre-flight — enforcement point for remote command dispatch.
//!
//! Before dispatching a governed operation to a target host, checks:
//! 1. Session has delegation authority
//! 2. Target is in delegation scope
//! 3. Target host posture satisfies the required shell class
use crate::delegation::DelegationDecision;
use crate::session::SessionContext;
use crate::shell_class::ShellClass;
/// Result of a worker pre-flight check.
#[derive(Debug)]
pub enum PreflightResult {
/// Dispatch is permitted.
Cleared {
target_host: String,
target_posture_level: u8,
delegated_class: ShellClass,
},
/// Dispatch is denied.
Denied {
target_host: String,
reason: String,
hint: String,
},
}
/// Perform pre-flight checks before dispatching to a target host.
pub fn worker_preflight(
ctx: &SessionContext,
target_host: &str,
required_class: ShellClass,
target_posture_level: Option<u8>,
) -> PreflightResult {
// Step 1+2: Check delegation scope
let decision = ctx.delegation_scope.permits(target_host, required_class);
match decision {
DelegationDecision::Denied { reason } => {
return PreflightResult::Denied {
target_host: target_host.into(),
reason,
hint: "The Accord for this session does not permit delegation \
to this target. Request a new session with appropriate \
delegation authority."
.into(),
};
}
DelegationDecision::Permitted => {}
}
// Step 3: Check target host posture
let target_posture = match target_posture_level {
Some(level) => level,
None => {
return PreflightResult::Denied {
target_host: target_host.into(),
reason: "target host posture unknown".into(),
hint: "Ensure the target host has a Keylime agent enrolled and \
the posture evaluator is monitoring it."
.into(),
};
}
};
let target_class = derive_shell_class_from_wire(target_posture, None);
if !target_class.satisfies(required_class) {
return PreflightResult::Denied {
target_host: target_host.into(),
reason: format!(
"target posture level {} grants {} access, but {} required",
target_posture, target_class, required_class
),
hint: "The target host's attestation posture is insufficient. \
Verify Keylime attestation status and wait for recovery."
.into(),
};
}
PreflightResult::Cleared {
target_host: target_host.into(),
target_posture_level: target_posture,
delegated_class: required_class,
}
}
/// Derive ShellClass from a wire posture level.
fn derive_shell_class_from_wire(posture_wire: u8, threshold: Option<u8>) -> ShellClass {
let thresh = threshold.unwrap_or(5); // Normal = 5 = System eligible
if posture_wire >= thresh {
ShellClass::System
} else {
ShellClass::Application
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::delegation::DelegationScope;
fn ctx_with_delegation(delegation: DelegationScope) -> SessionContext {
SessionContext {
org_name: "test".into(),
trust_domain: "test.io".into(),
bascule_endpoint: "localhost".into(),
shell_class: ShellClass::Application,
posture_level: 5,
delegation_scope: delegation,
}
}
#[test]
fn no_delegation_authority() {
let ctx = ctx_with_delegation(DelegationScope::default());
let result = worker_preflight(&ctx, "host-1", ShellClass::Application, Some(5));
assert!(matches!(result, PreflightResult::Denied { .. }));
}
#[test]
fn delegation_permitted_target_ok_posture_ok() {
let ctx = ctx_with_delegation(DelegationScope {
permitted: true,
target_hosts: vec!["host-1".into()],
max_delegated_class: ShellClass::System,
});
let result = worker_preflight(&ctx, "host-1", ShellClass::System, Some(5));
assert!(matches!(result, PreflightResult::Cleared { .. }));
}
#[test]
fn delegation_permitted_target_not_in_scope() {
let ctx = ctx_with_delegation(DelegationScope {
permitted: true,
target_hosts: vec!["host-1".into()],
max_delegated_class: ShellClass::System,
});
let result = worker_preflight(&ctx, "host-99", ShellClass::Application, Some(5));
assert!(matches!(result, PreflightResult::Denied { .. }));
}
#[test]
fn target_posture_insufficient() {
let ctx = ctx_with_delegation(DelegationScope {
permitted: true,
target_hosts: vec![],
max_delegated_class: ShellClass::System,
});
let result = worker_preflight(&ctx, "host-1", ShellClass::System, Some(3)); // Restricted
assert!(matches!(result, PreflightResult::Denied { .. }));
}
#[test]
fn target_posture_unknown_fails_closed() {
let ctx = ctx_with_delegation(DelegationScope {
permitted: true,
target_hosts: vec![],
max_delegated_class: ShellClass::System,
});
let result = worker_preflight(&ctx, "host-1", ShellClass::System, None);
assert!(matches!(result, PreflightResult::Denied { .. }));
}
#[test]
fn infrastructure_shell_pattern() {
// Application session + System delegation to attested target
let ctx = ctx_with_delegation(DelegationScope {
permitted: true,
target_hosts: vec!["worker-1".into()],
max_delegated_class: ShellClass::System,
});
// ctx.shell_class is Application, but delegation allows System to worker-1
let result = worker_preflight(&ctx, "worker-1", ShellClass::System, Some(5));
assert!(matches!(result, PreflightResult::Cleared { .. }));
}
}

View file

@ -45,7 +45,11 @@ pub fn fetch_score(entry_name: &str) -> WorkloadRiskScore {
} }
} }
/// Fetch the cluster's best Tier A corpus entry score. /// Fetch the cluster's default corpus entry score.
///
/// Callers should pass the org's binary name or a configured corpus
/// entry name rather than hardcoding an org-specific value.
pub fn fetch_cluster_score() -> WorkloadRiskScore { pub fn fetch_cluster_score() -> WorkloadRiskScore {
fetch_score("bxnet-ops") // Default corpus entry — overridden by orgs in their CLI binary.
fetch_score("org-ops")
} }

View file

@ -1,6 +1,18 @@
// Copyright 2026 Guildhouse Dev
// SPDX-License-Identifier: Apache-2.0
use crate::delegation::DelegationScope;
use crate::shell_class::ShellClass;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct SessionContext { pub struct SessionContext {
pub org_name: String, pub org_name: String,
pub trust_domain: String, pub trust_domain: String,
pub bascule_endpoint: String, pub bascule_endpoint: String,
/// Shell class for this session (Application or System).
pub shell_class: ShellClass,
/// Posture level wire value (1-5) at session establishment.
pub posture_level: u8,
/// Delegation authority for remote dispatch.
pub delegation_scope: DelegationScope,
} }

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::session::SessionContext;
use crate::shell_class::ShellClass;
/// Implement this to add org-specific subcommands. /// Implement this to add org-specific subcommands.
pub trait OrgCommands: Send + Sync { pub trait OrgCommands: Send + Sync {
@ -10,6 +11,25 @@ pub trait OrgCommands: Send + Sync {
matches: &clap::ArgMatches, matches: &clap::ArgMatches,
ctx: &SessionContext, ctx: &SessionContext,
) -> anyhow::Result<()>; ) -> 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
}
/// If this command dispatches to a remote target, return the target
/// host identifier from the command arguments.
///
/// Default: `None` — local command, no delegation needed.
/// Override for commands that target remote hosts (e.g., playbook
/// execution on a managed node).
fn target_host(&self, _args: &clap::ArgMatches) -> Option<String> {
None
}
} }
/// Implement this for custom risk scoring. /// Implement this for custom risk scoring.