feat: bxnet-ops — BXNet governed shell

Fork of guildhouse/org-ops.
Binary: guildhouse-ops → bxnet-ops
DID: guildhouse.dev → bxnet.io
Upstream remote configured for sync.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tyler J King 2026-03-27 19:52:54 -04:00
parent 242fb32180
commit 6912a46001
19 changed files with 4177 additions and 1 deletions

1943
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

8
Cargo.toml Normal file
View file

@ -0,0 +1,8 @@
[workspace]
members = ["org-ops-core", "org-ops-cli"]
resolver = "2"
[workspace.package]
version = "0.1.0"
edition = "2021"
license = "Apache-2.0"

View file

@ -1,3 +1,24 @@
# bxnet-ops # bxnet-ops
BXNet governed shell binary — fork of org-ops with BXNet identity BXNet governed shell binary. Fork of [guildhouse/org-ops](https://git.guildhouse.dev/guildhouse/org-ops).
## Identity
- Consultancy DID: `did:web:bxnet.io`
- Operator DID: `did:web:bxnet.io/user/tking`
- Platform: Guildhouse PaaS
## Usage
```bash
bxnet-ops auth login
bxnet-ops playbook list
bxnet-ops playbook run cpanel-provision-account --target cpanel-server-01
```
## Upstream sync
```bash
git fetch upstream
git merge upstream/main
```

13
org-ops-cli/Cargo.toml Normal file
View file

@ -0,0 +1,13 @@
[package]
name = "bxnet-ops"
description = "Guildhouse governed operations CLI — reference org-ops implementation"
version.workspace = true
edition.workspace = true
[[bin]]
name = "bxnet-ops"
path = "src/main.rs"
[dependencies]
org-ops-core = { path = "../org-ops-core" }
anyhow = "1"

31
org-ops-cli/src/main.rs Normal file
View file

@ -0,0 +1,31 @@
use org_ops_core::{
AuthCommands, AuthConfig, GitConfig, GovernedGitCommands, OrgOps, OrgOpsConfig,
PlaybookCommands,
};
fn main() -> anyhow::Result<()> {
let forgejo_token = std::env::var("FORGEJO_TOKEN").ok();
OrgOps::builder()
.with_config(OrgOpsConfig {
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(),
})
.with_commands(AuthCommands::new(AuthConfig::default()))
.with_commands(GovernedGitCommands::new(GitConfig {
forgejo_url: "https://git.bxnet.io".into(),
forgejo_token,
chronicle_webhook: "http://localhost:8090/webhook/forgejo".into(),
}))
.with_commands(PlaybookCommands::new(
"./playbooks",
"http://localhost:8090/webhook/forgejo",
))
.build()
.run()
}

17
org-ops-core/Cargo.toml Normal file
View file

@ -0,0 +1,17 @@
[package]
name = "org-ops-core"
description = "Framework for building governed consortium CLI tools"
version.workspace = true
edition.workspace = true
[dependencies]
clap = { version = "4", features = ["derive", "string"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
anyhow = "1"
reqwest = { version = "0.12", features = ["json", "blocking"] }
rand = "0.8"
sha2 = "0.10"
base64 = "0.22"
urlencoding = "2"
uuid = { version = "1", features = ["v4"] }

View file

@ -0,0 +1,207 @@
//! AI agent risk analysis for playbook runs.
//!
//! Analyzes test result CIDs before production apply.
//! Produces confidence score + recommendation.
use crate::test_evidence::TestRunResult;
use sha2::{Digest, Sha256};
/// Confidence thresholds for AI recommendations.
/// Loaded from AccordTemplate min_confidence_* fields when available.
/// Falls back to GCAP defaults when absent.
#[derive(Debug, Clone)]
pub struct ConfidenceThresholds {
pub auto_approve: u8,
pub totp_sufficient: u8,
pub peer_review_required: u8,
}
impl Default for ConfidenceThresholds {
fn default() -> Self {
Self {
auto_approve: 90,
totp_sufficient: 75,
peer_review_required: 50,
}
}
}
impl ConfidenceThresholds {
/// Load from AccordTemplate on cluster.
/// Falls back to defaults if fields are absent.
/// Unlike AccordMfaPolicy::from_accord, this is NOT fail-closed —
/// threshold absence falls back to safe defaults, not permissive ones.
pub fn from_accord(accord_name: &str) -> Self {
let output = std::process::Command::new("kubectl")
.args(["get", "accordtemplate", accord_name, "-o", "json"])
.output();
match output {
Ok(out) if out.status.success() => {
let val: serde_json::Value =
serde_json::from_slice(&out.stdout).unwrap_or_default();
let spec = &val["spec"];
Self {
auto_approve: spec["min_confidence_for_auto_approve"]
.as_u64()
.unwrap_or(90) as u8,
totp_sufficient: spec["min_confidence_for_totp"]
.as_u64()
.unwrap_or(75) as u8,
peer_review_required: spec["min_confidence_for_peer_review"]
.as_u64()
.unwrap_or(50) as u8,
}
}
_ => Self::default(),
}
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct RiskAnalysis {
pub confidence_score: u8,
pub recommendation: String,
pub reasoning: Vec<String>,
pub test_results_analyzed: Vec<String>,
pub diff_match: bool,
pub test_run_confidence: u8,
}
impl RiskAnalysis {
/// Analyze test results and produce risk assessment.
pub fn analyze(test_results: &[TestRunResult], prod_diff_hash: Option<&str>) -> Self {
if test_results.is_empty() {
return Self {
confidence_score: 30,
recommendation: "APPROVE_WITH_REVIEW".into(),
reasoning: vec!["No test evidence. Manual review required.".into()],
test_results_analyzed: vec![],
diff_match: false,
test_run_confidence: 0,
};
}
let mut reasoning = Vec::new();
let mut confidence: u32 = 50;
let mut all_passed = true;
let mut any_idempotent = false;
let mut diff_match = false;
let mut op_scores = Vec::new();
let cids: Vec<String> = test_results.iter().map(|r| r.compute_cid()).collect();
for result in test_results {
op_scores.push(result.test_run_confidence_score());
if result.tasks_failed > 0 {
all_passed = false;
reasoning.push(format!("{} failure(s) in {} env", result.tasks_failed, result.test_environment.env_type));
}
if result.idempotency_verified {
any_idempotent = true;
reasoning.push("Idempotency verified".into());
}
if let Some(prod_hash) = prod_diff_hash {
let m = result.diff_matches_prod(prod_hash);
if m >= 0.9 {
diff_match = true;
reasoning.push("Test diff matches prod diff exactly".into());
confidence += 20;
} else if m >= 0.5 {
reasoning.push("Test diff partially matches".into());
confidence += 5;
}
}
}
if all_passed {
confidence += 20;
reasoning.push("All test runs passed".into());
}
if any_idempotent {
confidence += 10;
}
let avg_op = if op_scores.is_empty() {
0
} else {
op_scores.iter().map(|&s| s as u32).sum::<u32>() / op_scores.len() as u32
} as u8;
confidence += (avg_op as u32 * 20) / 100;
let confidence = confidence.min(100) as u8;
// Accord-controlled thresholds. Defaults: auto=90, totp=75, peer=50.
let thresholds = ConfidenceThresholds::default();
let recommendation = if confidence >= thresholds.auto_approve {
reasoning.push(format!(
"High confidence ({}%). Auto-approve threshold ({}) met.",
confidence, thresholds.auto_approve
));
"APPROVE"
} else if confidence >= thresholds.totp_sufficient {
reasoning.push(format!(
"Good confidence ({}%). Standard MFA sufficient (threshold: {}).",
confidence, thresholds.totp_sufficient
));
"APPROVE_WITH_REVIEW"
} else if confidence >= thresholds.peer_review_required {
reasoning.push(format!(
"Moderate confidence ({}%). Peer review recommended (threshold: {}).",
confidence, thresholds.peer_review_required
));
"PEER_REVIEW"
} else {
reasoning.push(format!("Low confidence ({}%). Do not apply.", confidence));
"REJECT"
};
Self {
confidence_score: confidence,
recommendation: recommendation.into(),
reasoning,
test_results_analyzed: cids,
diff_match,
test_run_confidence: avg_op,
}
}
pub fn print_summary(&self) {
println!("\n-- AI Risk Analysis --");
println!(" {}% confidence", self.confidence_score);
println!(" Recommendation: {}", self.recommendation);
for r in &self.reasoning {
println!(" - {}", r);
}
if self.diff_match {
println!(" Diff match: exact");
}
println!("--");
}
/// Emit AI_RISK_ASSESSMENT to Chronicle.
pub fn emit_chronicle(&self, agent_did: &str, playbook_name: &str, webhook: &str) {
let reasoning_json = serde_json::to_string(&self.reasoning).unwrap_or_default();
let mut h = Sha256::new();
h.update(reasoning_json.as_bytes());
let reasoning_cid = format!("sha256:{:x}", h.finalize());
let body = serde_json::json!({
"pusher": {"login": agent_did},
"ref": "refs/ai/AI_RISK_ASSESSMENT",
"repository": {"full_name": "platform/ai-governance"},
"commits": [{"message": format!("AI_RISK_ASSESSMENT: {} {}%", playbook_name, self.confidence_score)}],
});
reqwest::blocking::Client::new()
.post(webhook)
.header("X-Forgejo-Event", "push")
.json(&body)
.timeout(std::time::Duration::from_secs(5))
.send()
.ok();
let _ = reasoning_cid; // used in full implementation
}
}

View file

@ -0,0 +1,246 @@
//! The apply authorization gate.
//!
//! Sits between --check (diff) and apply phases.
//! Accord-controlled MFA before any CAP_MUTATE+ operation.
//!
//! SECURITY INVARIANT: All accord loading failures are fail-closed.
//! If the accord cannot be loaded, the operation MUST be blocked.
//! Never fall back to a permissive default on error.
use sha2::{Digest, Sha256};
use std::io::{BufRead, Write};
use std::time::{Duration, Instant};
/// Error type for accord loading failures.
/// All variants are fail-closed: the caller MUST block the operation.
#[derive(Debug)]
pub enum AccordLoadError {
/// kubectl unavailable or returned non-zero exit code.
KubectlUnavailable(String),
/// kubectl returned non-JSON output.
JsonParseFailed(String),
/// AccordTemplate not found on cluster.
NotFound(String),
}
impl std::fmt::Display for AccordLoadError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::KubectlUnavailable(e) => write!(
f,
"kubectl unavailable: {e}. Governed operations blocked \
until cluster connectivity is restored."
),
Self::JsonParseFailed(e) => write!(
f,
"AccordTemplate returned non-JSON: {e}. \
Governed operations blocked."
),
Self::NotFound(name) => write!(
f,
"AccordTemplate '{name}' not found on cluster. \
Governed operations require a named accord. Blocked."
),
}
}
}
impl std::error::Error for AccordLoadError {}
#[derive(Debug, Clone)]
pub struct AccordMfaPolicy {
pub mfa_required: bool,
pub mfa_method: String,
pub change_review_window_secs: u64,
pub require_diff_acknowledgment: bool,
pub diff_hash_in_token: bool,
pub mfa_timeout_secs: u64,
}
impl AccordMfaPolicy {
/// Load from AccordTemplate via kubectl.
///
/// Returns `Err` on any failure — kubectl unavailable, non-JSON output,
/// or accord not found. The caller MUST block the governed operation.
/// This is a fail-closed governance control.
pub fn from_accord(accord_name: &str) -> Result<Self, AccordLoadError> {
let output = std::process::Command::new("kubectl")
.args(["get", "accordtemplate", accord_name, "-o", "json"])
.output();
match output {
Ok(out) if out.status.success() => {
let val: serde_json::Value = serde_json::from_slice(&out.stdout)
.map_err(|e| {
AccordLoadError::JsonParseFailed(format!(
"{e}. stdout: {}",
String::from_utf8_lossy(&out.stdout)
.chars()
.take(200)
.collect::<String>()
))
})?;
let spec = &val["spec"];
Ok(Self {
mfa_required: spec["mfa_required_for_apply"].as_bool().unwrap_or(false),
mfa_method: spec["mfa_method"]
.as_str()
.unwrap_or("none")
.to_string(),
change_review_window_secs: spec["change_review_window_secs"]
.as_u64()
.unwrap_or(30),
require_diff_acknowledgment: spec["require_diff_acknowledgment"]
.as_bool()
.unwrap_or(true),
diff_hash_in_token: spec["diff_hash_in_mfa_token"]
.as_bool()
.unwrap_or(true),
mfa_timeout_secs: spec["mfa_timeout_secs"].as_u64().unwrap_or(300),
})
}
Ok(out) => {
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
if stderr.contains("not found") || stderr.contains("NotFound") {
Err(AccordLoadError::NotFound(accord_name.to_string()))
} else {
Err(AccordLoadError::KubectlUnavailable(format!(
"exit {:?}: {}",
out.status.code(),
stderr.chars().take(200).collect::<String>()
)))
}
}
Err(e) => Err(AccordLoadError::KubectlUnavailable(e.to_string())),
}
}
}
/// Hash the diff output.
pub fn hash_diff(diff: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(diff.as_bytes());
format!("sha256:{:x}", hasher.finalize())
}
/// Display diff with review countdown.
fn display_diff_with_countdown(diff: &str, diff_hash: &str, window_secs: u64) {
println!("\n-- Proposed changes (diff) --");
println!("{}", diff);
println!("--");
println!(" Diff hash: {}", diff_hash);
if window_secs > 0 {
let start = Instant::now();
let window = Duration::from_secs(window_secs);
while start.elapsed() < window {
let remaining = window.saturating_sub(start.elapsed()).as_secs();
print!("\r Review: {} seconds remaining... ", remaining);
std::io::stdout().flush().ok();
std::thread::sleep(Duration::from_secs(1));
}
println!("\r Review: complete. ");
}
}
/// Prompt for diff acknowledgment.
fn prompt_acknowledgment(diff_hash: &str) -> anyhow::Result<()> {
println!("\nI have reviewed the diff and confirm it is the intended change.");
println!("Diff: {}", diff_hash);
print!("Confirm [yes/no]: ");
std::io::stdout().flush()?;
let mut line = String::new();
std::io::stdin().lock().read_line(&mut line)?;
if line.trim().eq_ignore_ascii_case("yes") {
println!(" Acknowledged.");
Ok(())
} else {
anyhow::bail!("Change not acknowledged. Apply cancelled.")
}
}
/// TOTP verification (format check — server validation next sprint).
fn verify_totp(diff_hash: &str, timeout_secs: u64) -> anyhow::Result<String> {
println!("\n-- MFA Sign-off Required --");
println!(" Method: TOTP");
if diff_hash.len() > 16 {
println!(" Signing: {}...", &diff_hash[..24]);
}
println!(" Expires in: {} seconds", timeout_secs);
print!(" Enter 6-digit code: ");
std::io::stdout().flush()?;
let mut code = String::new();
std::io::stdin().lock().read_line(&mut code)?;
let code = code.trim().to_string();
if code.len() != 6 || !code.chars().all(|c| c.is_ascii_digit()) {
anyhow::bail!("Invalid TOTP code. Expected 6 digits.");
}
println!(" MFA accepted. (Server validation: next sprint)");
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
Ok(format!("totp-auth:{}:{}", diff_hash, now))
}
/// Run the full apply gate. Returns the authorized diff_hash.
pub fn run_apply_gate(
diff: &str,
policy: &AccordMfaPolicy,
actor_did: &str,
chronicle_webhook: &str,
) -> anyhow::Result<String> {
let diff_hash = hash_diff(diff);
// Step 1: Display diff with countdown
display_diff_with_countdown(diff, &diff_hash, policy.change_review_window_secs);
// Step 2: Acknowledgment
if policy.require_diff_acknowledgment {
prompt_acknowledgment(&diff_hash)?;
}
// Step 3: MFA
let _auth_token = match policy.mfa_method.as_str() {
"totp" => verify_totp(&diff_hash, policy.mfa_timeout_secs)?,
"webauthn" | "push" | "peer_review" => {
println!(" {} method: falling back to TOTP (next sprint)", policy.mfa_method);
verify_totp(&diff_hash, policy.mfa_timeout_secs)?
}
_ => "no-mfa".to_string(),
};
// Step 4: Chronicle APPLY_AUTHORIZED
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let body = serde_json::json!({
"pusher": {"login": actor_did},
"ref": "refs/governance/APPLY_AUTHORIZED",
"repository": {"full_name": "platform/apply-governance"},
"commits": [{"message": format!("APPLY_AUTHORIZED: {} signed {}", actor_did, &diff_hash[..24])}],
});
let ok = reqwest::blocking::Client::new()
.post(chronicle_webhook)
.header("X-Forgejo-Event", "push")
.json(&body)
.timeout(Duration::from_secs(5))
.send()
.map(|r| r.status().is_success())
.unwrap_or(false);
if ok {
println!("\n Chronicle: APPLY_AUTHORIZED recorded");
}
println!(" Authorization valid for {} seconds.", policy.mfa_timeout_secs);
println!("--");
Ok(diff_hash)
}

View file

@ -0,0 +1,332 @@
//! OIDC authentication via did-bridge.
//!
//! auth login: start did-bridge → token → certificate → store
//! auth status: show identity + expiry
//! auth logout: remove credentials
use crate::session::SessionContext;
use crate::traits::OrgCommands;
use std::fs;
use std::path::PathBuf;
use std::process::Command;
pub struct AuthConfig {
pub oidc_issuer: String,
pub client_id: String,
pub did_bridge_port: u16,
}
impl Default for AuthConfig {
fn default() -> Self {
Self {
oidc_issuer: "https://auth.bxnet.io/realms/guildhouse".into(),
client_id: "bxnet-ops".into(),
did_bridge_port: 7777,
}
}
}
fn config_dir() -> PathBuf {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
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 {
config: AuthConfig,
}
impl AuthCommands {
pub fn new(config: AuthConfig) -> Self {
Self { config }
}
fn ensure_config_dir() {
let dir = config_dir();
if !dir.exists() {
fs::create_dir_all(&dir).ok();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&dir, fs::Permissions::from_mode(0o700)).ok();
}
}
}
fn start_did_bridge(&self) -> anyhow::Result<Option<std::process::Child>> {
// Find did_bridge.py
let home = std::env::var("HOME").unwrap_or_default();
let bridge_paths = [
format!("{}/projects/substrate-project/guildhouse/services/did-bridge/did_bridge.py", home),
"did_bridge.py".to_string(),
];
for path in &bridge_paths {
if std::path::Path::new(path).exists() {
let child = Command::new("python3")
.arg(path)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()?;
std::thread::sleep(std::time::Duration::from_secs(2));
eprintln!(" did-bridge started (pid {})", child.id());
return Ok(Some(child));
}
}
// Try docker
let output = Command::new("docker")
.args(["run", "--rm", "-d", "-p", "127.0.0.1:7777:7777", "guildhouse/did-bridge:latest"])
.output();
if let Ok(o) = output {
if o.status.success() {
std::thread::sleep(std::time::Duration::from_secs(2));
eprintln!(" did-bridge started (docker)");
return Ok(None);
}
}
anyhow::bail!("did-bridge not available. Install Python cryptography package or Docker.")
}
fn derive_certificate(&self, token: &str) -> anyhow::Result<(String, String, String, i64)> {
let url = format!("http://127.0.0.1:{}/derive", self.config.did_bridge_port);
let body = serde_json::json!({"oidc_token": token}).to_string();
let client = reqwest::blocking::Client::new();
let resp = client
.post(&url)
.header("Content-Type", "application/json")
.body(body)
.timeout(std::time::Duration::from_secs(10))
.send()?;
if !resp.status().is_success() {
anyhow::bail!("did-bridge error: {}", resp.status());
}
let data: serde_json::Value = resp.json()?;
let cert = data["certificate_pem"].as_str().ok_or_else(|| anyhow::anyhow!("No cert"))?.to_string();
let key = data["private_key_pem"].as_str().unwrap_or("").to_string();
let did = data["did"].as_str().unwrap_or("unknown").to_string();
let expires = data["expires_at"].as_i64().unwrap_or(0);
Ok((cert, key, did, expires))
}
fn cmd_login(&self, _ctx: &SessionContext) -> anyhow::Result<()> {
println!("Authenticating via OIDC...");
println!(" Issuer: {}", self.config.oidc_issuer);
Self::ensure_config_dir();
// Start did-bridge
eprintln!("Starting did-bridge...");
let mut bridge = self.start_did_bridge()?;
// PKCE browser flow (RFC 7636):
use crate::pkce;
let port = pkce::find_free_port();
let redirect_uri = format!("http://127.0.0.1:{}/callback", port);
let code_verifier = pkce::generate_code_verifier();
let code_challenge = pkce::derive_code_challenge(&code_verifier);
let state = format!("{:x}", rand::random::<u64>());
let auth_url = pkce::authorization_url(
&self.config.oidc_issuer,
&self.config.client_id,
&redirect_uri,
&code_challenge,
&state,
);
println!();
// Open browser:
let opener = if std::env::var("WSL_DISTRO_NAME").is_ok() {
"wslview"
} else if cfg!(target_os = "macos") {
"open"
} else {
"xdg-open"
};
println!(" Opening browser...");
Command::new(opener).arg(&auth_url).spawn().ok();
println!(" If browser didn't open, visit:");
println!(" {}", auth_url);
println!(" Waiting for callback on port {}...", port);
let (code, _returned_state) = match pkce::wait_for_callback(port) {
Ok(result) => result,
Err(e) => {
if let Some(ref mut child) = bridge {
child.kill().ok();
}
anyhow::bail!("Auth failed: {}", e);
}
};
if code.is_empty() {
if let Some(ref mut child) = bridge {
child.kill().ok();
}
anyhow::bail!("No authorization code received");
}
println!(" Authorization code received.");
// Exchange code for token:
let token = match pkce::exchange_code(
&self.config.oidc_issuer,
&self.config.client_id,
&code,
&code_verifier,
&redirect_uri,
) {
Ok(t) => t,
Err(e) => {
if let Some(ref mut child) = bridge {
child.kill().ok();
}
anyhow::bail!("Token exchange failed: {}", e);
}
};
if token.is_empty() {
if let Some(ref mut child) = bridge {
child.kill().ok();
}
anyhow::bail!("Empty token received");
}
println!("Deriving DID via did-bridge...");
let result = self.derive_certificate(&token);
// Kill bridge
if let Some(ref mut child) = bridge {
child.kill().ok();
}
// Token NOT stored. Drop it.
drop(token);
let (cert, key, did, expires) = result?;
// Store certificate and key (not token)
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(key_path(), fs::Permissions::from_mode(0o600)).ok();
}
println!();
println!("Authenticated.");
println!(" DID: {}", did);
println!(" Certificate: {}", cert_path().display());
println!(" Expires: 1 hour");
println!(" Token: zeroized");
Ok(())
}
fn cmd_status(&self, _ctx: &SessionContext) -> anyhow::Result<()> {
if !cert_path().exists() {
println!("Not authenticated.");
println!("Run: guildhouse-ops auth login");
return Ok(());
}
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()
.unwrap_or(0);
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
if expires > 0 && now > expires {
println!("Session expired.");
println!("Run: guildhouse-ops auth login");
return Ok(());
}
let remaining = if expires > 0 {
format!("{} minutes", (expires - now) / 60)
} else {
"unknown".into()
};
println!("Authenticated");
println!(" DID: {}", did);
println!(" Remaining: {}", remaining);
println!(" Certificate: {}", cert_path().display());
Ok(())
}
fn cmd_logout(&self, _ctx: &SessionContext) -> anyhow::Result<()> {
for path in &[cert_path(), key_path(), did_path(), expiry_path()] {
if path.exists() {
fs::remove_file(path)?;
}
}
println!("Logged out.");
Ok(())
}
}
impl OrgCommands for AuthCommands {
fn commands(&self) -> Vec<clap::Command> {
vec![clap::Command::new("auth")
.about("OIDC authentication via did-bridge")
.subcommand(clap::Command::new("login").about("Authenticate and store certificate"))
.subcommand(clap::Command::new("status").about("Show identity and expiry"))
.subcommand(clap::Command::new("logout").about("Remove credentials"))]
}
fn handles(&self, name: &str) -> bool {
name == "auth"
}
fn handle(
&self,
_name: &str,
matches: &clap::ArgMatches,
ctx: &SessionContext,
) -> anyhow::Result<()> {
match matches.subcommand() {
Some(("login", _)) => self.cmd_login(ctx),
Some(("status", _)) => self.cmd_status(ctx),
Some(("logout", _)) => self.cmd_logout(ctx),
_ => {
println!("Usage: auth <login|status|logout>");
Ok(())
}
}
}
}

View file

@ -0,0 +1,26 @@
/// Configuration for an org-ops instance.
/// Fork org-ops and set these values for your consortium.
#[derive(Debug, Clone)]
pub struct OrgOpsConfig {
pub org_name: String,
pub trust_domain: String,
pub bascule_endpoint: String,
pub chronicle_endpoint: String,
pub binary_name: String,
pub description: String,
pub version: String,
}
impl Default for OrgOpsConfig {
fn default() -> Self {
Self {
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(),
}
}
}

View file

@ -0,0 +1,29 @@
pub struct SessionBanner<'a> {
pub org_name: &'a str,
pub cluster: &'a str,
pub risk_score: u8,
pub capability_ceiling: &'a str,
pub bom_triad_complete: bool,
}
impl SessionBanner<'_> {
pub fn print(&self) {
let triad = if self.bom_triad_complete {
"Complete"
} else {
"Incomplete"
};
println!();
println!(" ╔══════════════════════════════════════════╗");
println!("{} Governed Shell", self.org_name);
println!(" ║ Cluster: {}", self.cluster);
println!(
" ║ Score: {}/100 {}",
self.risk_score, self.capability_ceiling
);
println!(" ║ BOM Triad: {}", triad);
println!(" ╚══════════════════════════════════════════╝");
println!();
}
}

View file

@ -0,0 +1,403 @@
//! Governed git subcommands.
//!
//! Wraps git operations with accord validation, corpus score checks,
//! and Chronicle attribution.
use crate::session::SessionContext;
use crate::traits::OrgCommands;
use std::process::Command;
pub struct GitConfig {
pub forgejo_url: String,
pub forgejo_token: Option<String>,
pub chronicle_webhook: String,
}
impl Default for GitConfig {
fn default() -> Self {
Self {
forgejo_url: "https://git.bxnet.io".into(),
forgejo_token: None,
chronicle_webhook: "http://localhost:8090/webhook/forgejo".into(),
}
}
}
pub struct GovernedGitCommands {
config: GitConfig,
}
impl GovernedGitCommands {
pub fn new(config: GitConfig) -> Self {
Self { config }
}
fn git(args: &[&str]) -> (String, String, i32) {
let output = Command::new("git")
.args(args)
.output()
.expect("git not found in PATH");
(
String::from_utf8_lossy(&output.stdout).to_string(),
String::from_utf8_lossy(&output.stderr).to_string(),
output.status.code().unwrap_or(-1),
)
}
fn corpus_score(entry_name: &str) -> Option<(u8, String, bool)> {
let output = Command::new("kubectl")
.args([
"get", "corpusentry", entry_name, "-o",
"jsonpath={.status.riskScore.composite}|{.status.riskScore.capabilityCeiling}|{.status.riskScore.bomTriadComplete}",
])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let s = String::from_utf8_lossy(&output.stdout);
let parts: Vec<&str> = s.trim().split('|').collect();
Some((
parts.first().and_then(|v| v.parse().ok()).unwrap_or(0),
parts.get(1).unwrap_or(&"CAP_READ").to_string(),
parts.get(2).map(|v| *v == "true").unwrap_or(false),
))
}
fn changed_files() -> Vec<String> {
let (out, _, _) = Self::git(&["diff", "--name-only", "HEAD"]);
let mut files: Vec<String> = out.lines().map(|s| s.to_string()).collect();
// Also include staged but not yet committed
let (staged, _, _) = Self::git(&["diff", "--cached", "--name-only"]);
for f in staged.lines() {
if !files.contains(&f.to_string()) {
files.push(f.to_string());
}
}
files
}
fn infer_corpus_entry(path: &str) -> Option<String> {
std::path::Path::new(path)
.file_stem()
.and_then(|s| s.to_str())
.map(|s| s.to_string())
}
fn emit_chronicle(&self, kind: &str, actor_did: &str, message: &str) -> bool {
let body = serde_json::json!({
"pusher": {"login": actor_did},
"ref": format!("refs/governed/{}", kind),
"repository": {"full_name": "platform/git-governance"},
"commits": [{"message": format!("{}: {}", kind, message)}],
});
reqwest::blocking::Client::new()
.post(&self.config.chronicle_webhook)
.header("X-Forgejo-Event", "push")
.json(&body)
.timeout(std::time::Duration::from_secs(5))
.send()
.map(|r| r.status().is_success())
.unwrap_or(false)
}
fn cmd_status(&self, _ctx: &SessionContext) -> anyhow::Result<()> {
let (out, _, _) = Self::git(&["status", "--short"]);
if out.is_empty() {
println!("Nothing to commit, working tree clean.");
return Ok(());
}
println!("{}", out.trim());
// Governance overlay
let changed = Self::changed_files();
if changed.is_empty() {
return Ok(());
}
println!("\n-- Governance Impact --");
for file in &changed {
if let Some(entry) = Self::infer_corpus_entry(file) {
if let Some((score, ceiling, triad)) = Self::corpus_score(&entry) {
let triad_mark = if triad { "Y" } else { "N" };
println!(
" {} -> corpus:{} score={}/100 {} triad={}",
file, entry, score, ceiling, triad_mark
);
} else {
println!(" {} (no corpus entry)", file);
}
} else {
println!(" {}", file);
}
}
println!("--");
Ok(())
}
fn cmd_clone(&self, repo: &str, ctx: &SessionContext) -> anyhow::Result<()> {
let url = if repo.contains("://") {
repo.to_string()
} else {
format!("{}/{}.git", self.config.forgejo_url, repo)
};
println!("Cloning {}...", url);
let (out, err, rc) = Self::git(&["clone", &url]);
if rc != 0 {
anyhow::bail!("git clone failed: {}", err);
}
if !out.is_empty() {
println!("{}", out.trim());
}
self.emit_chronicle(
"REPO_CLONED",
&format!("did:web:{}/user/operator", ctx.trust_domain),
&format!("repo={}", repo),
);
println!("Cloned. Chronicle: REPO_CLONED");
Ok(())
}
fn cmd_commit(&self, message: &str, _ctx: &SessionContext) -> anyhow::Result<()> {
// Pre-commit checks
let changed = Self::changed_files();
let yaml_files: Vec<&String> = changed
.iter()
.filter(|f| f.ends_with(".yml") || f.ends_with(".yaml"))
.collect();
// Basic secret scan
for f in &yaml_files {
if let Ok(content) = std::fs::read_to_string(f) {
let lc = content.to_lowercase();
for pat in &["password:", "secret:", "api_key:", "private_key:"] {
if lc.contains(pat) {
eprintln!(" [secrets] {}: possible secret ({}) — review", f, pat);
}
}
}
}
let (out, err, rc) = Self::git(&["commit", "-m", message]);
if rc != 0 {
anyhow::bail!("git commit failed: {}", err);
}
println!("{}", out.trim());
Ok(())
}
fn cmd_push(
&self,
remote: &str,
branch: &str,
ctx: &SessionContext,
) -> anyhow::Result<()> {
println!("-- Pre-push governance checks --");
// Corpus score check for changed files
let changed = Self::changed_files();
let mut blocked = false;
for file in &changed {
if let Some(entry) = Self::infer_corpus_entry(file) {
if let Some((score, ceiling, _)) = Self::corpus_score(&entry) {
print!(" corpus [{}]: {}/100 {}", entry, score, ceiling);
if branch == "main" && score < 70 {
println!(" BLOCKED (main requires >= 70)");
blocked = true;
} else {
println!(" OK");
}
}
}
}
if blocked {
anyhow::bail!("Push blocked by governance checks.");
}
// Governance summary
let (log, _, _) = Self::git(&["log", "--oneline", "-3"]);
println!("\n Commits:");
for line in log.lines() {
println!(" {}", line);
}
println!(" Target: {}/{}", remote, branch);
println!(" Actor: did:web:{}/user/operator", ctx.trust_domain);
println!("--");
// Emit COMMIT_CREATED for each commit in the push range (0x1704)
let actor_did = format!("did:web:{}/user/operator", ctx.trust_domain);
let (commit_log, _, _) =
Self::git(&["log", &format!("{}/{}..HEAD", remote, branch), "--format=%H|%s", "--no-merges"]);
for line in commit_log.lines().filter(|l| !l.is_empty()) {
let parts: Vec<&str> = line.splitn(2, '|').collect();
if parts.len() >= 2 {
self.emit_chronicle(
"COMMIT_CREATED",
&actor_did,
&format!("sha={} msg={}", parts[0], parts[1]),
);
}
}
// Chronicle event
let (sha, _, _) = Self::git(&["rev-parse", "HEAD"]);
self.emit_chronicle(
"GOVERNED_PUSH",
&actor_did,
&format!("{}@{} -> {}/{}", sha.trim(), branch, remote, branch),
);
// Actual push
let (out, err, rc) = Self::git(&["push", remote, branch]);
if rc != 0 {
anyhow::bail!("git push failed: {}", err);
}
if !out.is_empty() {
println!("{}", out.trim());
}
println!("Chronicle: GOVERNED_PUSH recorded");
Ok(())
}
fn cmd_pr_create(&self, title: &str, ctx: &SessionContext) -> anyhow::Result<()> {
let (branch, _, _) = Self::git(&["branch", "--show-current"]);
let branch = branch.trim();
let (remote_url, _, _) = Self::git(&["remote", "get-url", "origin"]);
let repo = remote_url
.trim()
.trim_end_matches(".git")
.rsplit('/')
.take(2)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect::<Vec<_>>()
.join("/");
let url = format!("{}/api/v1/repos/{}/pulls", self.config.forgejo_url, repo);
let mut req = reqwest::blocking::Client::new().post(&url).json(&serde_json::json!({
"title": title,
"head": branch,
"base": "main",
"body": format!(
"## Governance\n\nActor: did:web:{}/user/operator\n\n*Created via guildhouse-ops git pr create*",
ctx.trust_domain
)
}));
if let Some(ref tok) = self.config.forgejo_token {
req = req.bearer_auth(tok);
}
match req.send() {
Ok(resp) if resp.status().is_success() => {
let data: serde_json::Value = resp.json().unwrap_or_default();
let pr_url = data["html_url"].as_str().unwrap_or("?");
println!("PR created: {}", pr_url);
self.emit_chronicle(
"PR_CREATED",
&format!("did:web:{}/user/operator", ctx.trust_domain),
&format!("PR: {} ({})", title, pr_url),
);
}
Ok(resp) => eprintln!("PR creation failed: {}", resp.status()),
Err(e) => eprintln!("PR creation error: {}", e),
}
Ok(())
}
}
impl OrgCommands for GovernedGitCommands {
fn commands(&self) -> Vec<clap::Command> {
use clap::{Arg, Command};
vec![Command::new("git")
.about("Governed git operations with Chronicle attribution")
.subcommand(
Command::new("clone")
.about("Clone a governed repository")
.arg(Arg::new("repo").required(true)),
)
.subcommand(Command::new("status").about("git status with governance overlay"))
.subcommand(
Command::new("add")
.about("Stage files")
.arg(Arg::new("files").num_args(1..)),
)
.subcommand(
Command::new("commit")
.about("Commit with pre-commit checks")
.arg(Arg::new("message").short('m').required(true)),
)
.subcommand(
Command::new("push")
.about("Governed push: corpus check -> Chronicle -> push")
.arg(Arg::new("remote").default_value("origin"))
.arg(Arg::new("branch").default_value("main")),
)
.subcommand(
Command::new("pr").about("PR governance").subcommand(
Command::new("create")
.about("Create governed PR")
.arg(Arg::new("title").short('t').required(true)),
),
)]
}
fn handles(&self, name: &str) -> bool {
name == "git"
}
fn handle(
&self,
_name: &str,
matches: &clap::ArgMatches,
ctx: &SessionContext,
) -> anyhow::Result<()> {
match matches.subcommand() {
Some(("status", _)) => self.cmd_status(ctx),
Some(("clone", sub)) => {
let repo = sub.get_one::<String>("repo").unwrap();
self.cmd_clone(repo, ctx)
}
Some(("add", sub)) => {
let files: Vec<&str> = sub
.get_many::<String>("files")
.unwrap_or_default()
.map(|s| s.as_str())
.collect();
let mut args = vec!["add"];
args.extend(files);
let (_, err, rc) = Self::git(&args);
if rc != 0 {
anyhow::bail!(err);
}
Ok(())
}
Some(("commit", sub)) => {
let msg = sub.get_one::<String>("message").unwrap();
self.cmd_commit(msg, ctx)
}
Some(("push", sub)) => {
let remote = sub.get_one::<String>("remote").map(|s| s.as_str()).unwrap_or("origin");
let branch = sub.get_one::<String>("branch").map(|s| s.as_str()).unwrap_or("main");
self.cmd_push(remote, branch, ctx)
}
Some(("pr", sub)) => match sub.subcommand() {
Some(("create", s)) => {
let title = s.get_one::<String>("title").unwrap();
self.cmd_pr_create(title, ctx)
}
_ => {
println!("Usage: git pr create -t 'title'");
Ok(())
}
},
_ => {
println!("Usage: guildhouse-ops git <clone|status|add|commit|push|pr>");
Ok(())
}
}
}
}

265
org-ops-core/src/lib.rs Normal file
View file

@ -0,0 +1,265 @@
//! org-ops-core: Framework for building governed consortium CLI tools.
//!
//! Fork org-ops to create your own governed CLI (slayer-ops, gator-ops):
//! 1. Clone this repo
//! 2. Edit org-ops-cli/src/main.rs (org_name, trust_domain, bascule_endpoint)
//! 3. Implement OrgCommands for your domain
//! 4. cargo build --release
pub mod ai_risk_analysis;
pub mod apply_gate;
pub mod auth_commands;
pub mod config;
pub mod test_evidence;
pub mod display;
pub mod git_commands;
pub mod pkce;
pub mod playbook_commands;
pub mod score_fetcher;
pub mod session;
pub mod traits;
pub use auth_commands::{AuthCommands, AuthConfig};
pub use config::OrgOpsConfig;
pub use playbook_commands::PlaybookCommands;
pub use display::SessionBanner;
pub use git_commands::{GitConfig, GovernedGitCommands};
pub use traits::{OrgCommands, RiskScorer};
/// The main entry point. Build with your config and commands, then call run().
pub struct OrgOps {
pub config: OrgOpsConfig,
pub scorer: Box<dyn RiskScorer>,
pub commands: Vec<Box<dyn OrgCommands>>,
}
impl OrgOps {
pub fn builder() -> OrgOpsBuilder {
OrgOpsBuilder::default()
}
pub fn run(self) -> anyhow::Result<()> {
use clap::{Arg, Command};
let binary_name = self.config.binary_name.clone();
let description = self.config.description.clone();
let version = self.config.version.clone();
let mut app = Command::new(binary_name)
.about(description)
.version(version)
.subcommand(
Command::new("connect")
.about("Open a governed shell via Bascule")
.arg(Arg::new("cluster").help("Cluster name or endpoint")),
)
.subcommand(Command::new("status").about("Show cluster risk posture"))
.subcommand(
Command::new("corpus")
.about("Manage corpus entries")
.subcommand(Command::new("list").about("List corpus entries with scores")),
);
for cmd in &self.commands {
for subcmd in cmd.commands() {
app = app.subcommand(subcmd);
}
}
let matches = app.get_matches();
match matches.subcommand() {
Some(("connect", sub)) => {
let cluster = sub
.get_one::<String>("cluster")
.map(|s| s.as_str())
.unwrap_or(&self.config.bascule_endpoint);
self.cmd_connect(cluster)
}
Some(("status", _)) => self.cmd_status(),
Some(("corpus", sub)) => match sub.subcommand() {
Some(("list", _)) => self.cmd_corpus_list(),
_ => Ok(()),
},
Some((name, sub)) => {
let ctx = session::SessionContext {
org_name: self.config.org_name.clone(),
trust_domain: self.config.trust_domain.clone(),
bascule_endpoint: self.config.bascule_endpoint.clone(),
};
for cmd in &self.commands {
if cmd.handles(name) {
return cmd.handle(name, sub, &ctx);
}
}
eprintln!("Unknown command: {name}");
Ok(())
}
None => {
println!("{}", self.config.org_name);
println!("Run --help for usage.");
Ok(())
}
}
}
fn cmd_connect(&self, cluster: &str) -> anyhow::Result<()> {
// 1. Fetch live score from cluster
let score = score_fetcher::fetch_cluster_score();
let banner = SessionBanner {
org_name: &self.config.org_name,
cluster,
risk_score: score.composite,
capability_ceiling: &score.capability_ceiling,
bom_triad_complete: score.bom_triad_complete,
};
banner.print();
// 2. Determine SSH endpoint
// If cluster contains ':' or '.', treat as explicit endpoint.
// Otherwise use configured bascule_endpoint.
let endpoint = if cluster.contains(':') || cluster.contains('.') {
cluster.to_string()
} else {
self.config.bascule_endpoint.clone()
};
// 3. Parse host:port (default port 2222)
let (host, port) = if let Some(idx) = endpoint.rfind(':') {
(&endpoint[..idx], &endpoint[idx + 1..])
} else {
(endpoint.as_str(), "2222")
};
// 4. Start kubectl port-forward if connecting to ClusterIP
let mut port_forward: Option<std::process::Child> = None;
let (ssh_host, ssh_port) = if host == "127.0.0.1" || host == "localhost" {
// Direct: already port-forwarded or local
(host.to_string(), port.to_string())
} else {
// Port-forward to a substrate-bridge pod (Bascule DaemonSet)
eprintln!("Starting kubectl port-forward...");
let pf = std::process::Command::new("kubectl")
.args([
"port-forward",
"-n", "guildhouse-infra",
"daemonset/substrate-bridge",
"12222:2222",
])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn();
match pf {
Ok(child) => {
port_forward = Some(child);
// Wait for port-forward to establish
std::thread::sleep(std::time::Duration::from_secs(3));
("127.0.0.1".to_string(), "12222".to_string())
}
Err(e) => {
eprintln!("kubectl port-forward failed: {e}");
eprintln!("Trying direct SSH to {host}:{port}...");
(host.to_string(), port.to_string())
}
}
};
println!("Connecting to {ssh_host}:{ssh_port}...");
// 5. Exec SSH — use certificate if available
let home = std::env::var("HOME").unwrap_or_default();
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();
let mut ssh_args = vec![
"-p".to_string(), ssh_port.clone(),
"-o".to_string(), "StrictHostKeyChecking=no".to_string(),
"-o".to_string(), "UserKnownHostsFile=/dev/null".to_string(),
"-o".to_string(), "LogLevel=ERROR".to_string(),
];
if has_cert {
ssh_args.push("-i".to_string());
ssh_args.push(key_file);
eprintln!(" Using: certificate auth");
} else {
eprintln!(" Using: SSH key auth");
eprintln!(" Tip: run 'auth login' for certificate auth");
}
ssh_args.push(format!("tking@{ssh_host}"));
let status = std::process::Command::new("ssh")
.args(&ssh_args)
.status();
// 6. Cleanup port-forward
if let Some(mut pf) = port_forward {
let _ = pf.kill();
let _ = pf.wait();
}
match status {
Ok(s) if s.success() => Ok(()),
Ok(s) => {
eprintln!("Session ended (exit {})", s.code().unwrap_or(-1));
Ok(())
}
Err(e) => {
eprintln!("SSH failed: {e}");
Ok(())
}
}
}
fn cmd_status(&self) -> anyhow::Result<()> {
println!("Cluster risk posture:");
println!(" Trust domain: {}", self.config.trust_domain);
println!(" Bascule: {}", self.config.bascule_endpoint);
println!(" (Chronicle query — next sprint)");
Ok(())
}
fn cmd_corpus_list(&self) -> anyhow::Result<()> {
println!("Corpus entries:");
println!(" (K8s API query — next sprint)");
Ok(())
}
}
#[derive(Default)]
pub struct OrgOpsBuilder {
config: Option<OrgOpsConfig>,
scorer: Option<Box<dyn RiskScorer>>,
commands: Vec<Box<dyn OrgCommands>>,
}
impl OrgOpsBuilder {
pub fn with_config(mut self, config: OrgOpsConfig) -> Self {
self.config = Some(config);
self
}
pub fn with_scorer(mut self, scorer: impl RiskScorer + 'static) -> Self {
self.scorer = Some(Box::new(scorer));
self
}
pub fn with_commands(mut self, cmd: impl OrgCommands + 'static) -> Self {
self.commands.push(Box::new(cmd));
self
}
pub fn build(self) -> OrgOps {
OrgOps {
config: self.config.unwrap_or_default(),
scorer: self
.scorer
.unwrap_or_else(|| Box::new(traits::DefaultRiskScorer)),
commands: self.commands,
}
}
}

135
org-ops-core/src/pkce.rs Normal file
View file

@ -0,0 +1,135 @@
//! PKCE (RFC 7636) browser auth flow.
//!
//! Starts a localhost callback server, opens the browser,
//! waits for the authorization code, exchanges it for a token.
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use sha2::{Digest, Sha256};
use std::io::{Read, Write};
use std::net::TcpListener;
use std::sync::mpsc;
/// Generate PKCE code verifier (random 64 bytes, base64url).
pub fn generate_code_verifier() -> String {
let mut bytes = [0u8; 64];
use rand::RngCore;
rand::thread_rng().fill_bytes(&mut bytes);
URL_SAFE_NO_PAD.encode(bytes)
}
/// Derive code_challenge = BASE64URL(SHA256(verifier)).
pub fn derive_code_challenge(verifier: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(verifier.as_bytes());
URL_SAFE_NO_PAD.encode(hasher.finalize())
}
/// Find a free localhost port.
pub fn find_free_port() -> u16 {
TcpListener::bind("127.0.0.1:0")
.expect("No free port")
.local_addr()
.unwrap()
.port()
}
/// Build the PKCE authorization URL.
pub fn authorization_url(
issuer: &str,
client_id: &str,
redirect_uri: &str,
code_challenge: &str,
state: &str,
) -> String {
format!(
"{}/protocol/openid-connect/auth?client_id={}&response_type=code&redirect_uri={}&code_challenge={}&code_challenge_method=S256&scope=openid+email+profile&state={}",
issuer.trim_end_matches('/'),
client_id,
urlencoding::encode(redirect_uri),
code_challenge,
state,
)
}
/// Start localhost callback server. Returns (code, state).
pub fn wait_for_callback(port: u16) -> anyhow::Result<(String, String)> {
let listener = TcpListener::bind(format!("127.0.0.1:{}", port))?;
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
if let Ok((mut stream, _)) = listener.accept() {
let mut buf = [0u8; 4096];
let request = match stream.read(&mut buf) {
Ok(n) => String::from_utf8_lossy(&buf[..n]).to_string(),
Err(_) => String::new(),
};
let query = request
.split_whitespace()
.nth(1)
.and_then(|path| path.split('?').nth(1))
.unwrap_or("");
let code = query
.split('&')
.find(|p| p.starts_with("code="))
.map(|p| p.trim_start_matches("code=").to_string())
.unwrap_or_default();
let state = query
.split('&')
.find(|p| p.starts_with("state="))
.map(|p| p.trim_start_matches("state=").to_string())
.unwrap_or_default();
let html = "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n\
<html><body><h2>Authenticated</h2>\
<p>Return to your terminal.</p>\
<script>window.close()</script></body></html>";
stream.write_all(html.as_bytes()).ok();
tx.send((code, state)).ok();
}
});
rx.recv_timeout(std::time::Duration::from_secs(120))
.map_err(|_| anyhow::anyhow!("Timeout waiting for browser callback"))
}
/// Exchange authorization code for access token.
pub fn exchange_code(
issuer: &str,
client_id: &str,
code: &str,
code_verifier: &str,
redirect_uri: &str,
) -> anyhow::Result<String> {
let token_url = format!(
"{}/protocol/openid-connect/token",
issuer.trim_end_matches('/')
);
let client = reqwest::blocking::Client::new();
let resp = client
.post(&token_url)
.form(&[
("grant_type", "authorization_code"),
("client_id", client_id),
("code", code),
("code_verifier", code_verifier),
("redirect_uri", redirect_uri),
])
.timeout(std::time::Duration::from_secs(30))
.send()?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().unwrap_or_default();
anyhow::bail!("Token exchange failed ({status}): {body}");
}
let data: serde_json::Value = resp.json()?;
data["access_token"]
.as_str()
.map(|s| s.to_string())
.ok_or_else(|| anyhow::anyhow!("No access_token in response"))
}

View file

@ -0,0 +1,257 @@
//! Governed Ansible playbook execution.
//!
//! guildhouse-ops playbook run <name> — validates corpus, runs ansible-playbook, Chronicle
//! guildhouse-ops playbook list — lists governed playbooks
use crate::session::SessionContext;
use crate::traits::OrgCommands;
use std::collections::HashMap;
use std::process::Command;
pub struct PlaybookCommands {
pub playbook_base: String,
pub chronicle_webhook: String,
}
impl PlaybookCommands {
pub fn new(playbook_base: &str, chronicle_webhook: &str) -> Self {
Self {
playbook_base: playbook_base.into(),
chronicle_webhook: chronicle_webhook.into(),
}
}
fn get_corpus_entry(name: &str) -> Option<serde_json::Value> {
let out = Command::new("kubectl")
.args(["get", "corpusentry", name, "-o", "json"])
.output()
.ok()?;
if !out.status.success() {
return None;
}
serde_json::from_slice(&out.stdout).ok()
}
fn find_playbook(&self, name: &str) -> Option<String> {
let out = Command::new("find")
.args([&self.playbook_base, "-name", &format!("{}.yml", name)])
.output()
.ok()?;
let path = String::from_utf8_lossy(&out.stdout).trim().lines().next()?.to_string();
if path.is_empty() { None } else { Some(path) }
}
fn emit_chronicle(&self, kind: &str, actor_did: &str, message: &str) -> bool {
let body = serde_json::json!({
"pusher": {"login": actor_did},
"ref": format!("refs/playbook/{}", kind),
"repository": {"full_name": "platform/ansible-governance"},
"commits": [{"message": format!("{}: {}", kind, message)}],
});
reqwest::blocking::Client::new()
.post(&self.chronicle_webhook)
.header("X-Forgejo-Event", "push")
.json(&body)
.timeout(std::time::Duration::from_secs(5))
.send()
.map(|r| r.status().is_success())
.unwrap_or(false)
}
fn cmd_run(
&self,
name: &str,
target: Option<&str>,
extra_vars: &HashMap<String, String>,
dry_run: bool,
ctx: &SessionContext,
) -> anyhow::Result<()> {
println!("-- Playbook governance check --");
println!(" Playbook: {}", name);
// Find playbook file
let path = self.find_playbook(name).ok_or_else(|| {
anyhow::anyhow!("Playbook '{}' not found under {}", name, self.playbook_base)
})?;
println!(" Path: {}", path);
// Validate corpus entry
let entry = Self::get_corpus_entry(name)
.ok_or_else(|| anyhow::anyhow!("No CorpusEntry for '{}'. Create one first.", name))?;
let rs = entry.get("status").and_then(|s| s.get("riskScore")).cloned().unwrap_or_default();
let composite = rs.get("composite").and_then(|v| v.as_u64()).unwrap_or(0) as u8;
let ceiling = rs.get("capabilityCeiling").and_then(|v| v.as_str()).unwrap_or("CAP_READ");
let triad = rs.get("bomTriadComplete").and_then(|v| v.as_bool()).unwrap_or(false);
println!(" Score: {}/100 {} triad={}", composite, ceiling, if triad { "Y" } else { "N" });
if composite < 70 {
println!("\n BLOCKED: score {} < 70 (CAP_MUTATE required)", composite);
anyhow::bail!("Playbook blocked by governance check.");
}
println!(" Governance check passed.");
if dry_run {
println!(" [--check] Dry run mode.");
}
println!("--\n");
// Chronicle: PLAYBOOK_STARTED
let actor_did = format!("did:web:{}/user/operator", ctx.trust_domain);
let pb_cid = entry.get("spec").and_then(|s| s.get("cid")).and_then(|v| v.as_str()).unwrap_or("?");
self.emit_chronicle(
"PLAYBOOK_STARTED",
&actor_did,
&format!("{} cid={} target={}", name, pb_cid, target.unwrap_or("all")),
);
// Helper to build ansible-playbook command
let build_cmd = |check_mode: bool| {
let mut c = Command::new("ansible-playbook");
c.arg(&path);
if let Some(hosts) = target {
c.arg("--limit").arg(hosts);
}
for (k, v) in extra_vars {
c.arg("--extra-vars").arg(format!("{}={}", k, v));
}
if check_mode || dry_run {
c.arg("--check").arg("--diff");
}
c.env("GUILDHOUSE_DID", &actor_did);
c
};
// Load accord MFA policy — fail-closed on any error.
let accord_name = std::env::var("GUILDHOUSE_ACCORD").unwrap_or("dev-operations".into());
let mfa_policy = match crate::apply_gate::AccordMfaPolicy::from_accord(&accord_name) {
Ok(policy) => policy,
Err(e) => {
eprintln!("\n BLOCKED: Accord load failed: {}", e);
self.emit_chronicle(
"ACCORD_LOAD_FAILED",
&actor_did,
&format!("{} accord={} error={}", name, accord_name, e),
);
anyhow::bail!(
"Accord '{}' could not be loaded. Governed operation blocked. {}",
accord_name,
e
);
}
};
// Phase 1: --check to generate diff
println!("Phase 1: Generating diff (--check)...");
let check_output = build_cmd(true).output()?;
let diff = String::from_utf8_lossy(&check_output.stdout).to_string();
if dry_run {
print!("{}", diff);
println!("\n [--check] Dry run complete.");
self.emit_chronicle("PLAYBOOK_COMPLETED", &actor_did, &format!("{} dry_run=true", name));
return Ok(());
}
// Phase 2: Apply gate (if MFA required)
if mfa_policy.mfa_required {
println!("\n Accord: {} (MFA: {})", accord_name, mfa_policy.mfa_method);
let diff_hash = crate::apply_gate::run_apply_gate(
&diff, &mfa_policy, &actor_did, &self.chronicle_webhook,
)?;
// Re-verify diff hasn't changed (TOCTOU prevention)
let recheck = build_cmd(true).output()?;
let recheck_diff = String::from_utf8_lossy(&recheck.stdout).to_string();
let recheck_hash = crate::apply_gate::hash_diff(&recheck_diff);
if recheck_hash != diff_hash {
eprintln!("\n BLOCKED: Diff changed since MFA sign-off!");
self.emit_chronicle("DIFF_MISMATCH_DETECTED", &actor_did,
&format!("{} signed={} actual={}", name, &diff_hash[..24], &recheck_hash[..24]));
anyhow::bail!("Apply blocked: diff changed after MFA.");
}
} else if !diff.trim().is_empty() {
println!("{}", diff);
}
// Phase 3: Apply
println!("\nApplying changes...");
let start = std::time::Instant::now();
let status = build_cmd(false).status()?;
let duration = start.elapsed();
let rc = status.code().unwrap_or(-1);
// Chronicle: PLAYBOOK_COMPLETED
self.emit_chronicle(
"PLAYBOOK_COMPLETED",
&actor_did,
&format!("{} rc={} duration={}s", name, rc, duration.as_secs()),
);
if status.success() {
println!("\nPlaybook complete. ({:.1}s)", duration.as_secs_f64());
println!("Chronicle: PLAYBOOK_COMPLETED recorded");
} else {
anyhow::bail!("ansible-playbook exited with code {}", rc);
}
Ok(())
}
fn cmd_list(&self, _ctx: &SessionContext) -> anyhow::Result<()> {
let out = Command::new("kubectl")
.args([
"get", "corpusentry", "-l", "substrate.io/playbook-type=ansible",
"-o", "custom-columns=NAME:.metadata.name,SCORE:.status.riskScore.composite,CEILING:.status.riskScore.capabilityCeiling,TRIAD:.status.riskScore.bomTriadComplete,OS:.metadata.labels.substrate\\.io/target-os",
])
.output()?;
println!("{}", String::from_utf8_lossy(&out.stdout));
Ok(())
}
}
impl OrgCommands for PlaybookCommands {
fn commands(&self) -> Vec<clap::Command> {
use clap::{Arg, ArgAction, Command};
vec![Command::new("playbook")
.about("Governed Ansible playbook execution")
.subcommand(
Command::new("run")
.about("Run governed playbook (corpus-validated + Chronicle)")
.arg(Arg::new("name").required(true))
.arg(Arg::new("target").long("target").short('t'))
.arg(Arg::new("var").long("var").short('e').action(ArgAction::Append))
.arg(Arg::new("check").long("check").action(ArgAction::SetTrue)),
)
.subcommand(Command::new("list").about("List governed playbooks"))]
}
fn handles(&self, name: &str) -> bool {
name == "playbook"
}
fn handle(&self, _name: &str, matches: &clap::ArgMatches, ctx: &SessionContext) -> anyhow::Result<()> {
match matches.subcommand() {
Some(("run", sub)) => {
let name = sub.get_one::<String>("name").unwrap();
let target = sub.get_one::<String>("target").map(|s| s.as_str());
let check = sub.get_flag("check");
let vars: HashMap<String, String> = sub
.get_many::<String>("var")
.unwrap_or_default()
.filter_map(|v| {
let mut p = v.splitn(2, '=');
Some((p.next()?.to_string(), p.next()?.to_string()))
})
.collect();
self.cmd_run(name, target, &vars, check, ctx)
}
Some(("list", _)) => self.cmd_list(ctx),
_ => {
println!("Usage: playbook <run|list>");
Ok(())
}
}
}
}

View file

@ -0,0 +1,51 @@
//! Fetches live WorkloadRiskScore from the cluster corpus-operator.
use crate::traits::WorkloadRiskScore;
use std::process::Command;
/// Fetch the risk score for a corpus entry from the live cluster.
pub fn fetch_score(entry_name: &str) -> WorkloadRiskScore {
let output = Command::new("kubectl")
.args([
"get",
"corpusentry",
entry_name,
"-o",
"jsonpath={.status.riskScore.composite}|{.status.riskScore.capabilityCeiling}|{.status.riskScore.bomTriadComplete}",
])
.output();
match output {
Ok(out) if out.status.success() => {
let s = String::from_utf8_lossy(&out.stdout);
let parts: Vec<&str> = s.trim().split('|').collect();
let composite = parts.first().and_then(|v| v.parse().ok()).unwrap_or(0u8);
let ceiling = parts.get(1).unwrap_or(&"CAP_READ").to_string();
let bom_complete = parts.get(2).map(|v| *v == "true").unwrap_or(false);
WorkloadRiskScore {
hardware_score: 0,
software_score: 0,
ai_score: 0,
attestation_score: 0,
composite,
capability_ceiling: ceiling,
bom_triad_complete: bom_complete,
}
}
_ => WorkloadRiskScore {
hardware_score: 0,
software_score: 0,
ai_score: 0,
attestation_score: 0,
composite: 0,
capability_ceiling: "UNKNOWN".into(),
bom_triad_complete: false,
},
}
}
/// Fetch the cluster's best Tier A corpus entry score.
pub fn fetch_cluster_score() -> WorkloadRiskScore {
fetch_score("bxnet-ops")
}

View file

@ -0,0 +1,6 @@
#[derive(Debug, Clone)]
pub struct SessionContext {
pub org_name: String,
pub trust_domain: String,
pub bascule_endpoint: String,
}

View file

@ -0,0 +1,98 @@
//! Test run evidence for governed playbooks.
//!
//! TestRunResult captures the outcome of running a playbook against
//! a test/staging environment. Content-addressed by CID.
use sha2::{Digest, Sha256};
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct TestRunResult {
pub run_id: String,
pub playbook_cid: String,
pub playbook_name: String,
pub test_environment: TestEnvironment,
pub test_timestamp: String,
pub duration_secs: u64,
pub tasks_total: u32,
pub tasks_changed: u32,
pub tasks_failed: u32,
pub tasks_ok: u32,
pub idempotency_verified: bool,
pub diff_hash: Option<String>,
pub exit_code: i32,
pub test_runner_did: String,
pub notes: Option<String>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct TestEnvironment {
pub env_type: String,
pub target_os: String,
pub target_os_version: String,
pub description: String,
}
impl TestRunResult {
/// Compute content-addressed CID.
pub fn compute_cid(&self) -> String {
let canonical = serde_json::to_string(self).unwrap_or_default();
let mut hasher = Sha256::new();
hasher.update(canonical.as_bytes());
format!("sha256:{:x}", hasher.finalize())
}
/// Compute operational confidence score (0-100).
pub fn test_run_confidence_score(&self) -> u8 {
let mut score: u32 = 30; // base: test exists
if self.tasks_failed == 0 {
score += 25;
}
if self.idempotency_verified {
score += 20;
}
if self.diff_hash.is_some() {
score += 15;
}
score += 10; // test completed
score.min(100) as u8
}
/// Compare test diff against prod diff hash.
pub fn diff_matches_prod(&self, prod_hash: &str) -> f32 {
match &self.diff_hash {
Some(h) if h == prod_hash => 1.0,
Some(_) => 0.5,
None => 0.3,
}
}
pub fn print_summary(&self) {
let score = self.test_run_confidence_score();
println!(
" Test: {}/100 | env={} | tasks={}/{} ok | idem={}",
score,
self.test_environment.env_type,
self.tasks_ok,
self.tasks_total,
if self.idempotency_verified { "Y" } else { "N" }
);
}
}
/// Load test result from local file by CID.
pub fn load_test_result(cid: &str) -> anyhow::Result<TestRunResult> {
let path = format!("./test-results/{}.json", cid);
let content =
std::fs::read_to_string(&path).map_err(|_| anyhow::anyhow!("Test result not found: {}", cid))?;
Ok(serde_json::from_str(&content)?)
}
/// Save test result and return CID.
pub fn save_test_result(result: &TestRunResult) -> anyhow::Result<String> {
let cid = result.compute_cid();
let content = serde_json::to_string_pretty(result)?;
std::fs::create_dir_all("./test-results")?;
std::fs::write(format!("./test-results/{}.json", cid), &content)?;
println!(" Test result saved: {}", cid);
Ok(cid)
}

View file

@ -0,0 +1,88 @@
use crate::session::SessionContext;
/// Implement this to add org-specific subcommands.
pub trait OrgCommands: Send + Sync {
fn commands(&self) -> Vec<clap::Command>;
fn handles(&self, name: &str) -> bool;
fn handle(
&self,
name: &str,
matches: &clap::ArgMatches,
ctx: &SessionContext,
) -> anyhow::Result<()>;
}
/// Implement this for custom risk scoring.
pub trait RiskScorer: Send + Sync {
fn score(
&self,
attestation_method: &str,
has_sbom: bool,
has_aibom: bool,
has_cdxa: bool,
human_authored: bool,
) -> WorkloadRiskScore;
}
#[derive(Debug, Clone)]
pub struct WorkloadRiskScore {
pub hardware_score: u8,
pub software_score: u8,
pub ai_score: u8,
pub attestation_score: u8,
pub composite: u8,
pub capability_ceiling: String,
pub bom_triad_complete: bool,
}
/// Default GCAP reference scorer.
pub struct DefaultRiskScorer;
impl RiskScorer for DefaultRiskScorer {
fn score(
&self,
attestation_method: &str,
has_sbom: bool,
has_aibom: bool,
has_cdxa: bool,
human_authored: bool,
) -> WorkloadRiskScore {
let hardware_score: u8 = match attestation_method {
"tpm_psat" => 100,
"k8s_psat" => 60,
"join_token" => 30,
_ => 0,
};
let software_score: u8 = if has_sbom { 50 } else { 0 };
let ai_score: u8 = if has_aibom {
90
} else if human_authored {
50
} else {
0
};
let attestation_score: u8 = if has_cdxa { 100 } else { 0 };
let composite = ((hardware_score as u32 * 25)
+ (software_score as u32 * 35)
+ (ai_score as u32 * 20)
+ (attestation_score as u32 * 20))
/ 100;
let composite = composite as u8;
let capability_ceiling = match composite {
90..=u8::MAX => "CAP_GOVERN",
70..=89 => "CAP_MUTATE",
50..=69 => "CAP_PROPOSE",
_ => "CAP_READ",
}
.to_string();
WorkloadRiskScore {
hardware_score,
software_score,
ai_score,
attestation_score,
composite,
capability_ceiling,
bom_triad_complete: hardware_score > 0 && software_score >= 50 && ai_score > 0,
}
}
}