feat(org-ops): manifest loader with ShellClass filtering and CID verification

GSH manifest loader reads verified entries from the manifest-{name}
ConfigMap written by the substrate-operator reconciler:

- Filters entries by session ShellClass (System hidden in App shells)
- Filters delegation-context binaries in non-delegation sessions
- compute_file_cid() verifies on-disk binary hashes against CIDs
- verify_binary_hashes() detects tampering and missing binaries
- ManifestLoadResult reports loaded/excluded with reasons

10 unit tests covering:
- Application shell excludes system binaries
- System shell loads all non-delegation entries
- Delegation-permitted sessions load delegation binaries
- Empty/invalid manifest handling
- File CID computation (SHA-256, verified against known hash)
- Hash mismatch and missing file detection

Signed-off-by: Tyler King <tking@guildhouse.dev>
Signed-off-by: Tyler J King <tking727@gmail.com>
This commit is contained in:
Tyler J King 2026-04-15 18:38:44 -04:00
parent c68456d745
commit 5c92c027fc
2 changed files with 300 additions and 0 deletions

View file

@ -13,6 +13,7 @@ pub mod chronicle_client;
pub mod config;
pub mod delegation;
pub mod git_hash;
pub mod manifest_loader;
pub mod test_evidence;
pub mod display;
pub mod git_commands;

View file

@ -0,0 +1,299 @@
// Copyright 2026 Guildhouse Dev
// SPDX-License-Identifier: Apache-2.0
//! Manifest loader — reads verified manifest entries and filters them
//! by the session's ShellClass and delegation scope.
//!
//! Called at GSH session start to determine which binaries are authorized.
//! Reads the `manifest-{name}` ConfigMap output written by the
//! substrate-operator's manifest reconciler.
use crate::session::SessionContext;
use crate::shell_class::ShellClass;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
/// A verified manifest entry (matches the reconciler's output format).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VerifiedEntry {
pub binary_name: String,
pub cid: String,
pub shell_class: String,
pub delegation_context: bool,
pub capability_mask: i64,
pub tier: String,
}
/// A binary that was loaded (authorized for this session).
#[derive(Debug, Clone)]
pub struct LoadedBinary {
pub name: String,
pub cid: String,
pub shell_class: String,
}
/// A binary that was excluded (not authorized for this session).
#[derive(Debug, Clone)]
pub struct ExcludedBinary {
pub name: String,
pub reason: String,
}
/// Result of loading and filtering a manifest for a session.
#[derive(Debug)]
pub struct ManifestLoadResult {
pub loaded: Vec<LoadedBinary>,
pub excluded: Vec<ExcludedBinary>,
pub manifest_cid: String,
}
/// Load and filter manifest entries for the current session.
///
/// Reads the manifest JSON (from ConfigMap mount or API), filters
/// entries by the session's ShellClass and delegation scope, and
/// optionally verifies on-disk binary hashes.
pub fn load_manifest(
ctx: &SessionContext,
manifest_json: &str,
) -> Result<ManifestLoadResult, String> {
let entries: Vec<VerifiedEntry> =
serde_json::from_str(manifest_json).map_err(|e| format!("manifest parse error: {e}"))?;
let manifest_cid = format!(
"sha256:{}",
hex::encode(Sha256::digest(manifest_json.as_bytes()))
);
let mut loaded = Vec::new();
let mut excluded = Vec::new();
for entry in &entries {
// Filter by ShellClass
let entry_class = parse_shell_class(&entry.shell_class);
if !ctx.shell_class.satisfies(entry_class) {
excluded.push(ExcludedBinary {
name: entry.binary_name.clone(),
reason: format!("requires {} shell", entry.shell_class),
});
continue;
}
// Filter delegation-context binaries in non-delegation sessions
if entry.delegation_context && !ctx.delegation_scope.permitted {
excluded.push(ExcludedBinary {
name: entry.binary_name.clone(),
reason: "requires delegation authority".into(),
});
continue;
}
loaded.push(LoadedBinary {
name: entry.binary_name.clone(),
cid: entry.cid.clone(),
shell_class: entry.shell_class.clone(),
});
}
Ok(ManifestLoadResult {
loaded,
excluded,
manifest_cid,
})
}
/// Compute a CID (SHA-256) for a file on disk.
///
/// Matches the `compute_cid()` format in gsap_client.rs.
pub fn compute_file_cid(path: &std::path::Path) -> Result<String, String> {
let bytes = std::fs::read(path).map_err(|e| format!("read {}: {e}", path.display()))?;
Ok(format!("sha256:{}", hex::encode(Sha256::digest(&bytes))))
}
/// Verify that on-disk binaries match their manifest CIDs.
///
/// Returns a list of mismatched entries. Empty = all verified.
pub fn verify_binary_hashes(
loaded: &[LoadedBinary],
bin_dir: &std::path::Path,
) -> Vec<ExcludedBinary> {
let mut mismatches = Vec::new();
for binary in loaded {
let path = bin_dir.join(&binary.name);
match compute_file_cid(&path) {
Ok(on_disk_cid) => {
if on_disk_cid != binary.cid {
mismatches.push(ExcludedBinary {
name: binary.name.clone(),
reason: format!(
"hash mismatch: expected {}, found {}",
binary.cid, on_disk_cid
),
});
}
}
Err(reason) => {
mismatches.push(ExcludedBinary {
name: binary.name.clone(),
reason,
});
}
}
}
mismatches
}
fn parse_shell_class(s: &str) -> ShellClass {
match s {
"system" => ShellClass::System,
_ => ShellClass::Application,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::delegation::DelegationScope;
fn ctx(shell_class: ShellClass, delegation_permitted: bool) -> SessionContext {
SessionContext {
org_name: "test".into(),
trust_domain: "test.io".into(),
bascule_endpoint: "localhost".into(),
shell_class,
posture_level: 5,
delegation_scope: DelegationScope {
permitted: delegation_permitted,
target_hosts: vec![],
max_delegated_class: ShellClass::System,
},
}
}
fn manifest_json() -> String {
serde_json::to_string(&vec![
VerifiedEntry {
binary_name: "kubectl".into(),
cid: "sha256:aaa".into(),
shell_class: "application".into(),
delegation_context: false,
capability_mask: 1,
tier: "a".into(),
},
VerifiedEntry {
binary_name: "firmware-update".into(),
cid: "sha256:bbb".into(),
shell_class: "system".into(),
delegation_context: false,
capability_mask: 7,
tier: "c".into(),
},
VerifiedEntry {
binary_name: "ansible-playbook".into(),
cid: "sha256:ccc".into(),
shell_class: "application".into(),
delegation_context: true,
capability_mask: 3,
tier: "a".into(),
},
])
.unwrap()
}
#[test]
fn application_shell_excludes_system_binaries() {
let result = load_manifest(&ctx(ShellClass::Application, false), &manifest_json()).unwrap();
assert_eq!(result.loaded.len(), 1); // only kubectl (ansible needs delegation)
assert_eq!(result.loaded[0].name, "kubectl");
assert_eq!(result.excluded.len(), 2);
}
#[test]
fn system_shell_loads_all_non_delegation() {
let result = load_manifest(&ctx(ShellClass::System, false), &manifest_json()).unwrap();
// kubectl + firmware-update loaded; ansible excluded (delegation_context)
assert_eq!(result.loaded.len(), 2);
assert_eq!(result.excluded.len(), 1);
assert_eq!(result.excluded[0].name, "ansible-playbook");
}
#[test]
fn delegation_permitted_loads_delegation_binaries() {
let result = load_manifest(&ctx(ShellClass::Application, true), &manifest_json()).unwrap();
// kubectl + ansible loaded; firmware-update excluded (system)
assert_eq!(result.loaded.len(), 2);
let names: Vec<&str> = result.loaded.iter().map(|b| b.name.as_str()).collect();
assert!(names.contains(&"kubectl"));
assert!(names.contains(&"ansible-playbook"));
assert_eq!(result.excluded.len(), 1);
assert_eq!(result.excluded[0].name, "firmware-update");
}
#[test]
fn system_shell_with_delegation_loads_all() {
let result = load_manifest(&ctx(ShellClass::System, true), &manifest_json()).unwrap();
assert_eq!(result.loaded.len(), 3);
assert_eq!(result.excluded.len(), 0);
}
#[test]
fn empty_manifest_returns_empty() {
let result = load_manifest(&ctx(ShellClass::Application, false), "[]").unwrap();
assert_eq!(result.loaded.len(), 0);
assert_eq!(result.excluded.len(), 0);
}
#[test]
fn invalid_json_returns_error() {
let result = load_manifest(&ctx(ShellClass::Application, false), "not json");
assert!(result.is_err());
}
#[test]
fn manifest_cid_is_computed() {
let result = load_manifest(&ctx(ShellClass::System, true), &manifest_json()).unwrap();
assert!(result.manifest_cid.starts_with("sha256:"));
assert_eq!(result.manifest_cid.len(), 7 + 64); // "sha256:" + 64 hex chars
}
#[test]
fn file_cid_computation() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test-binary");
std::fs::write(&path, b"hello world").unwrap();
let cid = compute_file_cid(&path).unwrap();
assert!(cid.starts_with("sha256:"));
// SHA-256 of "hello world" is known
assert_eq!(
cid,
"sha256:b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
);
}
#[test]
fn verify_hashes_detects_mismatch() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test-bin");
std::fs::write(&path, b"actual content").unwrap();
let loaded = vec![LoadedBinary {
name: "test-bin".into(),
cid: "sha256:wrong".into(),
shell_class: "application".into(),
}];
let mismatches = verify_binary_hashes(&loaded, dir.path());
assert_eq!(mismatches.len(), 1);
assert!(mismatches[0].reason.contains("hash mismatch"));
}
#[test]
fn verify_hashes_missing_file() {
let dir = tempfile::tempdir().unwrap();
let loaded = vec![LoadedBinary {
name: "nonexistent".into(),
cid: "sha256:abc".into(),
shell_class: "application".into(),
}];
let mismatches = verify_binary_hashes(&loaded, dir.path());
assert_eq!(mismatches.len(), 1);
}
}