Compare commits
No commits in common. "8cec5a648658ba1c41243fcaaa99a8be7679e88bb115b2a921ed5c9c1f0da27a" and "8ed9bf6413c7e8654121da5e898b67420ff702a58ede91b905a7496038ef769b" have entirely different histories.
8cec5a6486
...
8ed9bf6413
19 changed files with 140 additions and 1600 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1 +0,0 @@
|
|||
target/
|
||||
37
Cargo.lock
generated
37
Cargo.lock
generated
|
|
@ -91,14 +91,6 @@ 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"
|
||||
|
|
@ -390,14 +382,11 @@ dependencies = [
|
|||
]
|
||||
|
||||
[[package]]
|
||||
name = "governance-types"
|
||||
name = "guildhouse-ops"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"hex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha1",
|
||||
"sha2",
|
||||
"anyhow",
|
||||
"org-ops-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -440,12 +429,6 @@ 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"
|
||||
|
|
@ -855,14 +838,11 @@ dependencies = [
|
|||
"anyhow",
|
||||
"base64",
|
||||
"clap",
|
||||
"governance-types",
|
||||
"hex",
|
||||
"rand",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tempfile",
|
||||
"urlencoding",
|
||||
"uuid",
|
||||
]
|
||||
|
|
@ -1180,17 +1160,6 @@ 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"
|
||||
|
|
|
|||
|
|
@ -1,9 +1,3 @@
|
|||
//! 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::{
|
||||
AuthCommands, AuthConfig, GitConfig, GovernedGitCommands, OrgOps, OrgOpsConfig,
|
||||
PlaybookCommands,
|
||||
|
|
@ -14,7 +8,6 @@ fn main() -> anyhow::Result<()> {
|
|||
|
||||
OrgOps::builder()
|
||||
.with_config(OrgOpsConfig {
|
||||
// ── Replace these with your consortium's values ──
|
||||
org_name: "BXNet".into(),
|
||||
trust_domain: "bxnet.io".into(),
|
||||
bascule_endpoint: "bascule.bxnet.io:443".into(),
|
||||
|
|
@ -22,16 +15,8 @@ fn main() -> anyhow::Result<()> {
|
|||
binary_name: "bxnet-ops".into(),
|
||||
description: "BXNet governed operations CLI".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 {
|
||||
oidc_issuer: "https://auth.bxnet.io/realms/guildhouse".into(),
|
||||
client_id: "bxnet-ops".into(),
|
||||
config_dir_name: "bxnet-ops".into(),
|
||||
..Default::default()
|
||||
}))
|
||||
.with_commands(AuthCommands::new(AuthConfig::default()))
|
||||
.with_commands(GovernedGitCommands::new(GitConfig {
|
||||
forgejo_url: "https://git.bxnet.io".into(),
|
||||
forgejo_token,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
[package]
|
||||
name = "org-ops-core"
|
||||
description = "Framework for building governed consortium CLI tools"
|
||||
license = "Apache-2.0"
|
||||
repository = "https://git.guildhouse.dev/guildhouse/org-ops"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
|
|
@ -13,11 +11,11 @@ serde_json = "1"
|
|||
anyhow = "1"
|
||||
reqwest = { version = "0.12", features = ["json", "blocking"] }
|
||||
rand = "0.8"
|
||||
sha1 = "0.10"
|
||||
sha2 = "0.10"
|
||||
base64 = "0.22"
|
||||
urlencoding = "2"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
governance-types = { path = "../../substrate/crates/governance-types" }
|
||||
hex = "0.4"
|
||||
|
||||
[dev-dependencies]
|
||||
|
|
|
|||
|
|
@ -14,24 +14,37 @@ pub struct AuthConfig {
|
|||
pub oidc_issuer: String,
|
||||
pub client_id: String,
|
||||
pub did_bridge_port: u16,
|
||||
/// Directory name under ~/.config/ for credential storage.
|
||||
pub config_dir_name: String,
|
||||
}
|
||||
|
||||
impl Default for AuthConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
oidc_issuer: "https://auth.example.com/realms/consortium".into(),
|
||||
client_id: "org-ops".into(),
|
||||
oidc_issuer: "https://auth.bxnet.io/realms/guildhouse".into(),
|
||||
client_id: "bxnet-ops".into(),
|
||||
did_bridge_port: 7777,
|
||||
config_dir_name: "org-ops".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn config_dir_for(name: &str) -> PathBuf {
|
||||
fn config_dir() -> PathBuf {
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
|
||||
PathBuf::from(home).join(".config").join(name)
|
||||
PathBuf::from(home).join(".config").join("bxnet-ops")
|
||||
}
|
||||
|
||||
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 {
|
||||
|
|
@ -43,28 +56,8 @@ impl AuthCommands {
|
|||
Self { config }
|
||||
}
|
||||
|
||||
fn config_dir(&self) -> PathBuf {
|
||||
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();
|
||||
fn ensure_config_dir() {
|
||||
let dir = config_dir();
|
||||
if !dir.exists() {
|
||||
fs::create_dir_all(&dir).ok();
|
||||
#[cfg(unix)]
|
||||
|
|
@ -76,9 +69,10 @@ impl AuthCommands {
|
|||
}
|
||||
|
||||
fn start_did_bridge(&self) -> anyhow::Result<Option<std::process::Child>> {
|
||||
// Find did_bridge.py — check DID_BRIDGE_PATH env, then cwd
|
||||
// Find did_bridge.py
|
||||
let home = std::env::var("HOME").unwrap_or_default();
|
||||
let bridge_paths = [
|
||||
std::env::var("DID_BRIDGE_PATH").unwrap_or_default(),
|
||||
format!("{}/projects/substrate-project/guildhouse/services/did-bridge/did_bridge.py", home),
|
||||
"did_bridge.py".to_string(),
|
||||
];
|
||||
|
||||
|
|
@ -140,7 +134,7 @@ impl AuthCommands {
|
|||
println!("Authenticating via OIDC...");
|
||||
println!(" Issuer: {}", self.config.oidc_issuer);
|
||||
|
||||
self.ensure_config_dir();
|
||||
Self::ensure_config_dir();
|
||||
|
||||
// Start did-bridge
|
||||
eprintln!("Starting did-bridge...");
|
||||
|
|
@ -235,21 +229,21 @@ impl AuthCommands {
|
|||
let (cert, key, did, expires) = result?;
|
||||
|
||||
// Store certificate and key (not token)
|
||||
fs::write(self.cert_path(), &cert)?;
|
||||
fs::write(self.key_path(), &key)?;
|
||||
fs::write(self.did_path(), &did)?;
|
||||
fs::write(self.expiry_path(), expires.to_string())?;
|
||||
fs::write(cert_path(), &cert)?;
|
||||
fs::write(key_path(), &key)?;
|
||||
fs::write(did_path(), &did)?;
|
||||
fs::write(expiry_path(), expires.to_string())?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(self.key_path(), fs::Permissions::from_mode(0o600)).ok();
|
||||
fs::set_permissions(key_path(), fs::Permissions::from_mode(0o600)).ok();
|
||||
}
|
||||
|
||||
println!();
|
||||
println!("Authenticated.");
|
||||
println!(" DID: {}", did);
|
||||
println!(" Certificate: {}", self.cert_path().display());
|
||||
println!(" Certificate: {}", cert_path().display());
|
||||
println!(" Expires: 1 hour");
|
||||
println!(" Token: zeroized");
|
||||
|
||||
|
|
@ -257,14 +251,14 @@ impl AuthCommands {
|
|||
}
|
||||
|
||||
fn cmd_status(&self, _ctx: &SessionContext) -> anyhow::Result<()> {
|
||||
if !self.cert_path().exists() {
|
||||
if !cert_path().exists() {
|
||||
println!("Not authenticated.");
|
||||
println!("Run: auth login");
|
||||
println!("Run: guildhouse-ops auth login");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let did = fs::read_to_string(self.did_path()).unwrap_or("unknown".into());
|
||||
let expires: i64 = fs::read_to_string(self.expiry_path())
|
||||
let did = fs::read_to_string(did_path()).unwrap_or("unknown".into());
|
||||
let expires: i64 = fs::read_to_string(expiry_path())
|
||||
.unwrap_or("0".into())
|
||||
.trim()
|
||||
.parse()
|
||||
|
|
@ -277,7 +271,7 @@ impl AuthCommands {
|
|||
|
||||
if expires > 0 && now > expires {
|
||||
println!("Session expired.");
|
||||
println!("Run: auth login");
|
||||
println!("Run: guildhouse-ops auth login");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
|
|
@ -290,13 +284,13 @@ impl AuthCommands {
|
|||
println!("Authenticated");
|
||||
println!(" DID: {}", did);
|
||||
println!(" Remaining: {}", remaining);
|
||||
println!(" Certificate: {}", self.cert_path().display());
|
||||
println!(" Certificate: {}", cert_path().display());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_logout(&self, _ctx: &SessionContext) -> anyhow::Result<()> {
|
||||
for path in &[self.cert_path(), self.key_path(), self.did_path(), self.expiry_path()] {
|
||||
for path in &[cert_path(), key_path(), did_path(), expiry_path()] {
|
||||
if path.exists() {
|
||||
fs::remove_file(path)?;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,25 +1,14 @@
|
|||
// Copyright 2026 Guildhouse Dev
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! 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.
|
||||
//! CloudEvents 1.0 Chronicle emitter.
|
||||
//!
|
||||
//! Replaces the fake Forgejo push webhook pattern used previously.
|
||||
//! Git-originated events use the commit SHA as the CloudEvent `id`.
|
||||
//! Non-git events use a UUID v4.
|
||||
//!
|
||||
//! See: ARCHITECTURE-CHRONICLE.md
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use governance_types::GovernanceEnvelope;
|
||||
|
||||
/// CloudEvents type prefix for Guildhouse Chronicle events.
|
||||
const CE_TYPE_PREFIX: &str = "dev.guildhouse.chronicle.";
|
||||
|
||||
|
|
@ -103,47 +92,6 @@ impl ChronicleClient {
|
|||
pub fn generate_id() -> String {
|
||||
uuid::Uuid::new_v4().to_string()
|
||||
}
|
||||
|
||||
/// Emit a git-originated governance event with a full envelope.
|
||||
///
|
||||
/// The CloudEvent `id` is set to the hex-encoded `git_ref` from the
|
||||
/// envelope, so Chronicle's entry_id matches the git SHA.
|
||||
pub fn emit_git_event(
|
||||
&self,
|
||||
kind: &str,
|
||||
envelope: &GovernanceEnvelope,
|
||||
extra_data: serde_json::Value,
|
||||
) -> bool {
|
||||
let git_ref_hex = hex::encode(envelope.git_ref);
|
||||
|
||||
// Merge envelope + extra_data into a single data payload
|
||||
let mut data = extra_data;
|
||||
if let serde_json::Value::Object(ref mut map) = data {
|
||||
map.insert("kind".to_string(), serde_json::json!(kind));
|
||||
map.insert(
|
||||
"envelope".to_string(),
|
||||
serde_json::to_value(envelope).unwrap_or_default(),
|
||||
);
|
||||
}
|
||||
|
||||
let ce = serde_json::json!({
|
||||
"specversion": "1.0",
|
||||
"type": format!("{}{}", CE_TYPE_PREFIX, kind),
|
||||
"source": &envelope.actor_did,
|
||||
"id": &git_ref_hex,
|
||||
"time": now_rfc3339(),
|
||||
"datacontenttype": "application/json",
|
||||
"data": data,
|
||||
});
|
||||
|
||||
self.http
|
||||
.post(&self.endpoint)
|
||||
.header("Content-Type", "application/cloudevents+json; charset=utf-8")
|
||||
.json(&ce)
|
||||
.send()
|
||||
.map(|r| r.status().is_success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// RFC 3339 timestamp for CloudEvents `time` field.
|
||||
|
|
@ -276,67 +224,6 @@ mod tests {
|
|||
assert_eq!((y, m, d), (1970, 1, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_emit_git_event_payload_structure() {
|
||||
use governance_types::GovernanceEnvelope;
|
||||
|
||||
let sha = hex::decode("95d09f2b10159347eece71399a7e2e907ea3df4f").unwrap();
|
||||
let mut git_ref = [0u8; 20];
|
||||
git_ref.copy_from_slice(&sha);
|
||||
|
||||
let mut envelope = GovernanceEnvelope::for_commit(
|
||||
git_ref,
|
||||
Some("refs/heads/main"),
|
||||
"guildhouse/substrate",
|
||||
[0; 32],
|
||||
"did:web:guildhouse.dev:user:tking",
|
||||
);
|
||||
envelope.timestamp_ns = 1_000_000;
|
||||
|
||||
let extra = serde_json::json!({
|
||||
"description": "test commit",
|
||||
"branch": "main",
|
||||
});
|
||||
|
||||
// Build the CloudEvent payload as emit_git_event would
|
||||
let git_ref_hex = hex::encode(envelope.git_ref);
|
||||
let mut data = extra.clone();
|
||||
if let serde_json::Value::Object(ref mut map) = data {
|
||||
map.insert("kind".to_string(), serde_json::json!("GOV_COMMIT_CREATED"));
|
||||
map.insert(
|
||||
"envelope".to_string(),
|
||||
serde_json::to_value(&envelope).unwrap(),
|
||||
);
|
||||
}
|
||||
|
||||
let ce = serde_json::json!({
|
||||
"specversion": "1.0",
|
||||
"type": format!("{}GOV_COMMIT_CREATED", CE_TYPE_PREFIX),
|
||||
"source": &envelope.actor_did,
|
||||
"id": &git_ref_hex,
|
||||
"time": now_rfc3339(),
|
||||
"datacontenttype": "application/json",
|
||||
"data": data,
|
||||
});
|
||||
|
||||
// CloudEvents 1.0 required fields
|
||||
assert_eq!(ce["specversion"], "1.0");
|
||||
assert!(ce["type"].as_str().unwrap().starts_with("dev.guildhouse.chronicle."));
|
||||
assert!(!ce["source"].as_str().unwrap().is_empty());
|
||||
assert_eq!(ce["id"], "95d09f2b10159347eece71399a7e2e907ea3df4f");
|
||||
|
||||
// Envelope is embedded in data
|
||||
assert!(ce["data"]["envelope"].is_object());
|
||||
assert_eq!(
|
||||
ce["data"]["envelope"]["git_ref"],
|
||||
"95d09f2b10159347eece71399a7e2e907ea3df4f"
|
||||
);
|
||||
assert_eq!(ce["data"]["envelope"]["git_ref_type"], "commit");
|
||||
|
||||
// CloudEvent id matches envelope.git_ref
|
||||
assert_eq!(ce["id"].as_str().unwrap(), ce["data"]["envelope"]["git_ref"].as_str().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_days_to_ymd_known_date() {
|
||||
// 2026-04-12 = day 20555 since epoch
|
||||
|
|
|
|||
|
|
@ -1,16 +1,5 @@
|
|||
/// Configuration for an org-ops instance.
|
||||
///
|
||||
/// 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()
|
||||
/// ```
|
||||
/// Fork org-ops and set these values for your consortium.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OrgOpsConfig {
|
||||
pub org_name: String,
|
||||
|
|
@ -20,27 +9,18 @@ pub struct OrgOpsConfig {
|
|||
pub binary_name: String,
|
||||
pub description: 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 {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
org_name: "my-org".into(),
|
||||
trust_domain: "example.com".into(),
|
||||
bascule_endpoint: "localhost:2222".into(),
|
||||
chronicle_endpoint: "localhost:8090".into(),
|
||||
binary_name: "org-ops".into(),
|
||||
description: "Governed operations CLI".into(),
|
||||
org_name: "BXNet".into(),
|
||||
trust_domain: "bxnet.io".into(),
|
||||
bascule_endpoint: "bascule.bxnet.io:443".into(),
|
||||
chronicle_endpoint: "chronicle.bxnet.io:8080".into(),
|
||||
binary_name: "bxnet-ops".into(),
|
||||
description: "BXNet governed operations CLI".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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,461 +0,0 @@
|
|||
// 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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,131 +0,0 @@
|
|||
// 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 { .. }
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -6,19 +6,8 @@
|
|||
use crate::chronicle_client::ChronicleClient;
|
||||
use crate::session::SessionContext;
|
||||
use crate::traits::OrgCommands;
|
||||
use governance_types::GovernanceEnvelope;
|
||||
use std::process::Command;
|
||||
|
||||
/// Parse a hex SHA string into a [u8; 20] git ref. Returns zeros on parse failure.
|
||||
fn parse_git_sha(hex_str: &str) -> [u8; 20] {
|
||||
let mut sha = [0u8; 20];
|
||||
if let Ok(bytes) = hex::decode(hex_str.trim()) {
|
||||
let len = bytes.len().min(20);
|
||||
sha[..len].copy_from_slice(&bytes[..len]);
|
||||
}
|
||||
sha
|
||||
}
|
||||
|
||||
pub struct GitConfig {
|
||||
pub forgejo_url: String,
|
||||
pub forgejo_token: Option<String>,
|
||||
|
|
@ -28,9 +17,9 @@ pub struct GitConfig {
|
|||
impl Default for GitConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
forgejo_url: "https://git.example.com".into(),
|
||||
forgejo_url: "https://git.bxnet.io".into(),
|
||||
forgejo_token: None,
|
||||
chronicle_webhook: "http://localhost:8090/webhook/cloudevents".into(),
|
||||
chronicle_webhook: "http://localhost:8090/webhook/forgejo".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -236,29 +225,20 @@ impl GovernedGitCommands {
|
|||
let chronicle = self.chronicle();
|
||||
let (commit_log, _, _) =
|
||||
Self::git(&["log", &format!("{}/{}..HEAD", remote, branch), "--format=%H|%s", "--no-merges"]);
|
||||
let ref_name = format!("refs/heads/{}", branch);
|
||||
let (remote_url, _, _) = Self::git(&["remote", "get-url", remote]);
|
||||
let repo_name = remote_url.trim().trim_end_matches(".git")
|
||||
.rsplit('/').take(2).collect::<Vec<_>>().into_iter().rev()
|
||||
.collect::<Vec<_>>().join("/");
|
||||
|
||||
for line in commit_log.lines().filter(|l| !l.is_empty()) {
|
||||
let parts: Vec<&str> = line.splitn(2, '|').collect();
|
||||
if parts.len() >= 2 {
|
||||
let commit_sha = parts[0].trim();
|
||||
let envelope = GovernanceEnvelope::for_commit(
|
||||
parse_git_sha(commit_sha),
|
||||
Some(&ref_name),
|
||||
&repo_name,
|
||||
[0; 32], // accord_hash populated when accord context is available
|
||||
&actor_did,
|
||||
);
|
||||
chronicle.emit_git_event(
|
||||
chronicle.emit(
|
||||
"GOV_COMMIT_CREATED",
|
||||
&envelope,
|
||||
&actor_did,
|
||||
commit_sha,
|
||||
serde_json::json!({
|
||||
"kind": "GOV_COMMIT_CREATED",
|
||||
"description": format!("sha={} msg={}", commit_sha, parts[1]),
|
||||
"git_commit": commit_sha,
|
||||
"message": parts[1],
|
||||
"git_ref": format!("refs/heads/{}", branch),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
|
@ -267,18 +247,15 @@ impl GovernedGitCommands {
|
|||
// Chronicle: GOV_PUSH
|
||||
let (sha, _, _) = Self::git(&["rev-parse", "HEAD"]);
|
||||
let head_sha = sha.trim();
|
||||
let push_envelope = GovernanceEnvelope::for_commit(
|
||||
parse_git_sha(head_sha),
|
||||
Some(&ref_name),
|
||||
&repo_name,
|
||||
[0; 32],
|
||||
&actor_did,
|
||||
);
|
||||
chronicle.emit_git_event(
|
||||
chronicle.emit(
|
||||
"GOV_PUSH",
|
||||
&push_envelope,
|
||||
&actor_did,
|
||||
head_sha,
|
||||
serde_json::json!({
|
||||
"kind": "GOV_PUSH",
|
||||
"description": format!("{}@{} -> {}/{}", head_sha, branch, remote, branch),
|
||||
"git_commit": head_sha,
|
||||
"git_ref": format!("refs/heads/{}", branch),
|
||||
"remote": remote,
|
||||
"branch": branch,
|
||||
}),
|
||||
|
|
@ -333,18 +310,14 @@ impl GovernedGitCommands {
|
|||
println!("PR created: {}", pr_url);
|
||||
let actor_did = format!("did:web:{}/user/operator", ctx.trust_domain);
|
||||
let (sha, _, _) = Self::git(&["rev-parse", "HEAD"]);
|
||||
let envelope = GovernanceEnvelope::for_commit(
|
||||
parse_git_sha(sha.trim()),
|
||||
Some(&format!("refs/heads/{}", branch)),
|
||||
&repo,
|
||||
[0; 32],
|
||||
&actor_did,
|
||||
);
|
||||
self.chronicle().emit_git_event(
|
||||
self.chronicle().emit(
|
||||
"GOV_PR_CREATED",
|
||||
&envelope,
|
||||
&actor_did,
|
||||
sha.trim(),
|
||||
serde_json::json!({
|
||||
"kind": "GOV_PR_CREATED",
|
||||
"description": format!("PR: {} ({})", title, pr_url),
|
||||
"git_commit": sha.trim(),
|
||||
"branch": branch,
|
||||
"pr_url": pr_url,
|
||||
"title": title,
|
||||
|
|
|
|||
|
|
@ -3,22 +3,49 @@
|
|||
|
||||
//! Git-compatible content hashing.
|
||||
//!
|
||||
//! Re-exports core hash functions from [`governance_types::git_hash`]
|
||||
//! and adds BPF map key helpers that are substrate-specific.
|
||||
//! Computes SHA-1 hashes identical to `git hash-object`, allowing
|
||||
//! content to be addressed by the same identifier git would assign
|
||||
//! to it as a blob. This bridges custom CID generation with git's
|
||||
//! native merkle tree.
|
||||
//!
|
||||
//! # Format
|
||||
//!
|
||||
//! Git blob hash: `SHA-1("blob {len}\0{content}")`
|
||||
//!
|
||||
//! Returned as `"gitsha1:{hex}"` for use in CID fields, or as raw
|
||||
//! 20-byte / 40-char hex values for direct git interop.
|
||||
|
||||
// Re-export from the shared crate.
|
||||
pub use governance_types::git_hash::{git_blob_cid, git_blob_hash, git_blob_hash_hex};
|
||||
use sha1::{Digest, Sha1};
|
||||
|
||||
/// Compute the git blob hash as a 40-char hex string.
|
||||
/// Compute the git blob hash of `content`.
|
||||
///
|
||||
/// Convenience wrapper matching the previous API that returned `String`.
|
||||
pub fn git_blob_hash_string(content: &[u8]) -> String {
|
||||
git_blob_hash_hex(content)
|
||||
/// Returns the same SHA-1 that `echo -n {content} | git hash-object --stdin`
|
||||
/// would produce. Result is a 40-character lowercase hex string.
|
||||
pub fn git_blob_hash(content: &[u8]) -> String {
|
||||
let header = format!("blob {}\0", content.len());
|
||||
let mut hasher = Sha1::new();
|
||||
hasher.update(header.as_bytes());
|
||||
hasher.update(content);
|
||||
hex::encode(hasher.finalize())
|
||||
}
|
||||
|
||||
/// Compute the git blob hash and return it with the `gitsha1:` prefix.
|
||||
///
|
||||
/// This is the CID format used in CorpusEntry and test evidence fields
|
||||
/// to indicate the hash was computed using git's blob algorithm.
|
||||
pub fn git_blob_cid(content: &[u8]) -> String {
|
||||
format!("gitsha1:{}", git_blob_hash(content))
|
||||
}
|
||||
|
||||
/// Extract raw hash bytes (20 bytes) from a git blob hash.
|
||||
///
|
||||
/// Returns `None` if the input is not a valid 40-char hex string.
|
||||
pub fn git_blob_hash_bytes(content: &[u8]) -> [u8; 20] {
|
||||
git_blob_hash(content)
|
||||
let header = format!("blob {}\0", content.len());
|
||||
let mut hasher = Sha1::new();
|
||||
hasher.update(header.as_bytes());
|
||||
hasher.update(content);
|
||||
hasher.finalize().into()
|
||||
}
|
||||
|
||||
/// Parse a CID string and extract the raw hash bytes for BPF map keys.
|
||||
|
|
@ -26,9 +53,9 @@ pub fn git_blob_hash_bytes(content: &[u8]) -> [u8; 20] {
|
|||
/// Supports both legacy `sha256:{hex}` and new `gitsha1:{hex}` formats.
|
||||
/// Returns the first 16 bytes of the hash for use as a BPF map key prefix.
|
||||
///
|
||||
/// - `gitsha1:{40 hex chars}` -> first 16 bytes of the SHA-1 hash
|
||||
/// - `sha256:{64 hex chars}` -> first 16 bytes of the ASCII prefix (legacy compat)
|
||||
/// - anything else -> first 16 bytes of the string (best effort)
|
||||
/// - `gitsha1:{40 hex chars}` → first 16 bytes of the SHA-1 hash
|
||||
/// - `sha256:{64 hex chars}` → first 16 bytes of the ASCII prefix (legacy compat)
|
||||
/// - anything else → first 16 bytes of the string (best effort)
|
||||
pub fn bpf_key_from_cid(cid: &str) -> [u8; 16] {
|
||||
let mut key = [0u8; 16];
|
||||
if let Some(hex_str) = cid.strip_prefix("gitsha1:") {
|
||||
|
|
@ -55,13 +82,14 @@ mod tests {
|
|||
#[test]
|
||||
fn test_git_blob_hash_hello_world() {
|
||||
// Must match: echo -n "hello world" | git hash-object --stdin
|
||||
let hash = git_blob_hash_hex(b"hello world");
|
||||
let hash = git_blob_hash(b"hello world");
|
||||
assert_eq!(hash, "95d09f2b10159347eece71399a7e2e907ea3df4f");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_blob_hash_empty() {
|
||||
let hash = git_blob_hash_hex(b"");
|
||||
// echo -n "" | git hash-object --stdin
|
||||
let hash = git_blob_hash(b"");
|
||||
assert_eq!(hash, "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391");
|
||||
}
|
||||
|
||||
|
|
@ -83,9 +111,11 @@ mod tests {
|
|||
fn test_bpf_key_from_gitsha1_cid() {
|
||||
let cid = "gitsha1:95d09f2b10159347eece71399a7e2e907ea3df4f";
|
||||
let key = bpf_key_from_cid(cid);
|
||||
// First 16 bytes of the decoded SHA-1
|
||||
assert_eq!(key.len(), 16);
|
||||
let expected = hex::decode("95d09f2b10159347eece71399a7e2e90").unwrap();
|
||||
assert_eq!(&key[..], &expected[..]);
|
||||
// Must NOT contain ASCII label bytes
|
||||
assert!(key[0] != b's' && key[0] != b'g');
|
||||
}
|
||||
|
||||
|
|
@ -93,6 +123,7 @@ mod tests {
|
|||
fn test_bpf_key_from_legacy_sha256_cid() {
|
||||
let cid = "sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
|
||||
let key = bpf_key_from_cid(cid);
|
||||
// Legacy: first 16 bytes of the ASCII string "sha256:abcdef..."
|
||||
assert_eq!(&key[..7], b"sha256:");
|
||||
assert_eq!(key[7], b'a');
|
||||
}
|
||||
|
|
@ -106,4 +137,18 @@ mod tests {
|
|||
assert_eq!(key2.len(), 16);
|
||||
assert_eq!(key3.len(), 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_blob_hash_is_40_hex_chars() {
|
||||
let hash = git_blob_hash(b"any content here");
|
||||
assert_eq!(hash.len(), 40);
|
||||
assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_blob_hash_deterministic() {
|
||||
let h1 = git_blob_hash(b"test data");
|
||||
let h2 = git_blob_hash(b"test data");
|
||||
assert_eq!(h1, h2);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,19 +11,14 @@ pub mod apply_gate;
|
|||
pub mod auth_commands;
|
||||
pub mod chronicle_client;
|
||||
pub mod config;
|
||||
pub mod corpus_commands;
|
||||
pub mod delegation;
|
||||
pub mod git_hash;
|
||||
pub mod manifest_loader;
|
||||
pub mod test_evidence;
|
||||
pub mod display;
|
||||
pub mod git_commands;
|
||||
pub mod pkce;
|
||||
pub mod playbook_commands;
|
||||
pub mod preflight;
|
||||
pub mod score_fetcher;
|
||||
pub mod session;
|
||||
pub mod shell_class;
|
||||
pub mod traits;
|
||||
pub mod gsap_client;
|
||||
|
||||
|
|
@ -32,8 +27,6 @@ pub use config::OrgOpsConfig;
|
|||
pub use playbook_commands::PlaybookCommands;
|
||||
pub use display::SessionBanner;
|
||||
pub use git_commands::{GitConfig, GovernedGitCommands};
|
||||
pub use corpus_commands::CorpusCommands;
|
||||
pub use shell_class::ShellClass;
|
||||
pub use traits::{OrgCommands, RiskScorer};
|
||||
|
||||
/// The main entry point. Build with your config and commands, then call run().
|
||||
|
|
@ -95,52 +88,9 @@ 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
|
||||
delegation_scope: delegation::DelegationScope::default(),
|
||||
};
|
||||
for cmd in &self.commands {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -195,8 +145,8 @@ impl OrgOps {
|
|||
let pf = std::process::Command::new("kubectl")
|
||||
.args([
|
||||
"port-forward",
|
||||
"-n", &self.config.infra_namespace,
|
||||
&format!("daemonset/{}", self.config.bridge_daemonset),
|
||||
"-n", "guildhouse-infra",
|
||||
"daemonset/substrate-bridge",
|
||||
"12222:2222",
|
||||
])
|
||||
.stdout(std::process::Stdio::null())
|
||||
|
|
@ -222,9 +172,8 @@ impl OrgOps {
|
|||
|
||||
// 5. Exec SSH — use certificate if available
|
||||
let home = std::env::var("HOME").unwrap_or_default();
|
||||
let config_name = &self.config.binary_name;
|
||||
let cert_file = format!("{}/.config/{}/identity.pem", home, config_name);
|
||||
let key_file = format!("{}/.config/{}/identity.key", home, config_name);
|
||||
let cert_file = format!("{}/.config/guildhouse-ops/identity.pem", home);
|
||||
let key_file = format!("{}/.config/guildhouse-ops/identity.key", home);
|
||||
let has_cert = std::path::Path::new(&cert_file).exists()
|
||||
&& std::path::Path::new(&key_file).exists();
|
||||
|
||||
|
|
@ -244,7 +193,7 @@ impl OrgOps {
|
|||
eprintln!(" Tip: run 'auth login' for certificate auth");
|
||||
}
|
||||
|
||||
ssh_args.push(format!("{}@{ssh_host}", self.config.ssh_user));
|
||||
ssh_args.push(format!("tking@{ssh_host}"));
|
||||
|
||||
let status = std::process::Command::new("ssh")
|
||||
.args(&ssh_args)
|
||||
|
|
|
|||
|
|
@ -1,353 +0,0 @@
|
|||
// 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -152,12 +152,12 @@ impl PlaybookCommands {
|
|||
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 driver_id = std::env::var("GSAP_DRIVER_ID")
|
||||
.unwrap_or_else(|_| "keycloak".into());
|
||||
.unwrap_or_else(|_| "keycloak-guildhouse".into());
|
||||
let accord = std::env::var("GSAP_ACCORD_TEMPLATE")
|
||||
.unwrap_or_else(|_| accord_name.clone());
|
||||
let session_dir = std::env::var("GSAP_SESSION_DIR")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| std::env::temp_dir().join("org-ops-gsap"));
|
||||
.unwrap_or_else(|_| std::env::temp_dir().join("bxnet-gsap"));
|
||||
|
||||
let client = GsapClient::new(broker_url, token, session_dir);
|
||||
let params_json = serde_json::to_string(&extra_vars).unwrap_or_default();
|
||||
|
|
|
|||
|
|
@ -1,181 +0,0 @@
|
|||
// 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 { .. }));
|
||||
}
|
||||
}
|
||||
|
|
@ -45,11 +45,7 @@ pub fn fetch_score(entry_name: &str) -> WorkloadRiskScore {
|
|||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Fetch the cluster's best Tier A corpus entry score.
|
||||
pub fn fetch_cluster_score() -> WorkloadRiskScore {
|
||||
// Default corpus entry — overridden by orgs in their CLI binary.
|
||||
fetch_score("org-ops")
|
||||
fetch_score("bxnet-ops")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,6 @@
|
|||
// Copyright 2026 Guildhouse Dev
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
use crate::delegation::DelegationScope;
|
||||
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).
|
||||
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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,77 +0,0 @@
|
|||
// 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
use crate::session::SessionContext;
|
||||
use crate::shell_class::ShellClass;
|
||||
|
||||
/// Implement this to add org-specific subcommands.
|
||||
pub trait OrgCommands: Send + Sync {
|
||||
|
|
@ -11,25 +10,6 @@ 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
|
||||
}
|
||||
|
||||
/// 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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue