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