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:
parent
242fb32180
commit
6912a46001
19 changed files with 4177 additions and 1 deletions
1943
Cargo.lock
generated
Normal file
1943
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
8
Cargo.toml
Normal file
8
Cargo.toml
Normal 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"
|
||||||
23
README.md
23
README.md
|
|
@ -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
13
org-ops-cli/Cargo.toml
Normal 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
31
org-ops-cli/src/main.rs
Normal 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
17
org-ops-core/Cargo.toml
Normal 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"] }
|
||||||
207
org-ops-core/src/ai_risk_analysis.rs
Normal file
207
org-ops-core/src/ai_risk_analysis.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
246
org-ops-core/src/apply_gate.rs
Normal file
246
org-ops-core/src/apply_gate.rs
Normal 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)
|
||||||
|
}
|
||||||
332
org-ops-core/src/auth_commands.rs
Normal file
332
org-ops-core/src/auth_commands.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
26
org-ops-core/src/config.rs
Normal file
26
org-ops-core/src/config.rs
Normal 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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
29
org-ops-core/src/display.rs
Normal file
29
org-ops-core/src/display.rs
Normal 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!();
|
||||||
|
}
|
||||||
|
}
|
||||||
403
org-ops-core/src/git_commands.rs
Normal file
403
org-ops-core/src/git_commands.rs
Normal 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
265
org-ops-core/src/lib.rs
Normal 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
135
org-ops-core/src/pkce.rs
Normal 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"))
|
||||||
|
}
|
||||||
257
org-ops-core/src/playbook_commands.rs
Normal file
257
org-ops-core/src/playbook_commands.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
51
org-ops-core/src/score_fetcher.rs
Normal file
51
org-ops-core/src/score_fetcher.rs
Normal 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")
|
||||||
|
}
|
||||||
6
org-ops-core/src/session.rs
Normal file
6
org-ops-core/src/session.rs
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SessionContext {
|
||||||
|
pub org_name: String,
|
||||||
|
pub trust_domain: String,
|
||||||
|
pub bascule_endpoint: String,
|
||||||
|
}
|
||||||
98
org-ops-core/src/test_evidence.rs
Normal file
98
org-ops-core/src/test_evidence.rs
Normal 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)
|
||||||
|
}
|
||||||
88
org-ops-core/src/traits.rs
Normal file
88
org-ops-core/src/traits.rs
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue