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 auth_commands;
|
||||||
pub mod chronicle_client;
|
pub mod chronicle_client;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod corpus_commands;
|
||||||
pub mod delegation;
|
pub mod delegation;
|
||||||
pub mod git_hash;
|
pub mod git_hash;
|
||||||
pub mod manifest_loader;
|
pub mod manifest_loader;
|
||||||
|
|
@ -31,6 +32,7 @@ pub use config::OrgOpsConfig;
|
||||||
pub use playbook_commands::PlaybookCommands;
|
pub use playbook_commands::PlaybookCommands;
|
||||||
pub use display::SessionBanner;
|
pub use display::SessionBanner;
|
||||||
pub use git_commands::{GitConfig, GovernedGitCommands};
|
pub use git_commands::{GitConfig, GovernedGitCommands};
|
||||||
|
pub use corpus_commands::CorpusCommands;
|
||||||
pub use shell_class::ShellClass;
|
pub use shell_class::ShellClass;
|
||||||
pub use traits::{OrgCommands, RiskScorer};
|
pub use traits::{OrgCommands, RiskScorer};
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue