diff --git a/Cargo.lock b/Cargo.lock index c0ab8b5..7b23eb0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -117,6 +117,15 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + [[package]] name = "bitflags" version = "2.11.0" @@ -141,6 +150,12 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.11.1" @@ -293,6 +308,21 @@ dependencies = [ "libc", ] +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "crossterm" version = "0.28.1" @@ -440,6 +470,15 @@ dependencies = [ "syn", ] +[[package]] +name = "doxygen-rs" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "415b6ec780d34dcf624666747194393603d0373b7141eef01d12ee58881507d9" +dependencies = [ + "phf", +] + [[package]] name = "ed25519" version = "2.2.3" @@ -633,6 +672,44 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "heed" +version = "0.20.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d4f449bab7320c56003d37732a917e18798e2f1709d80263face2b4f9436ddb" +dependencies = [ + "bitflags", + "byteorder", + "heed-traits", + "heed-types", + "libc", + "lmdb-master-sys", + "once_cell", + "page_size", + "serde", + "synchronoise", + "url", +] + +[[package]] +name = "heed-traits" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb3130048d404c57ce5a1ac61a903696e8fcde7e8c2991e9fcfc1f27c3ef74ff" + +[[package]] +name = "heed-types" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d3f528b053a6d700b2734eabcd0fd49cb8230647aa72958467527b0b7917114" +dependencies = [ + "bincode", + "byteorder", + "heed-traits", + "serde", + "serde_json", +] + [[package]] name = "hermit-abi" version = "0.1.19" @@ -854,6 +931,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "substrate-identity-store", "substrate-ipc", "tempfile", "thiserror 2.0.18", @@ -888,6 +966,17 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "lmdb-master-sys" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aaeb9bd22e73bd1babffff614994b341e9b2008de7bb73bf1f7e9154f1978f8b" +dependencies = [ + "cc", + "doxygen-rs", + "libc", +] + [[package]] name = "lock_api" version = "0.4.14" @@ -1002,6 +1091,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "page_size" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -1031,6 +1130,48 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1090,6 +1231,15 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "rand_core", +] + [[package]] name = "rand_core" version = "0.6.4" @@ -1292,6 +1442,12 @@ dependencies = [ "rand_core", ] +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + [[package]] name = "smallvec" version = "1.15.1" @@ -1358,6 +1514,17 @@ dependencies = [ "syn", ] +[[package]] +name = "substrate-identity-store" +version = "0.1.0" +dependencies = [ + "chrono", + "heed", + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "substrate-ipc" version = "0.1.0" @@ -1387,6 +1554,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synchronoise" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dbc01390fc626ce8d1cffe3376ded2b72a11bb70e1c75f404a210e4daa4def2" +dependencies = [ + "crossbeam-queue", +] + [[package]] name = "synstructure" version = "0.13.2" diff --git a/Cargo.toml b/Cargo.toml index b6086ae..ae8a294 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,5 +24,6 @@ colored = "2" atty = "0.2" tracing = "0.1" substrate-ipc = { path = "../substrate/crates/substrate-ipc" } +substrate-identity-store = { path = "../substrate/crates/substrate-identity-store" } tokio = { version = "1", features = ["full"] } libc = "0.2" diff --git a/gsh/Cargo.toml b/gsh/Cargo.toml index 15c521b..94c92e5 100644 --- a/gsh/Cargo.toml +++ b/gsh/Cargo.toml @@ -21,3 +21,7 @@ colored = { workspace = true } atty = { workspace = true } tokio = { workspace = true } substrate-ipc = { workspace = true } + +[features] +default = [] +lmdb = ["libgsh/lmdb"] diff --git a/gsh/src/human.rs b/gsh/src/human.rs index 364ee82..dac2201 100644 --- a/gsh/src/human.rs +++ b/gsh/src/human.rs @@ -143,6 +143,15 @@ fn print_banner(session: &SessionState) { }, "║".bright_blue()); + if let Some(ref class) = session.identity_class { + println!("{} Class: {:<44}{}", "║".bright_blue(), class, "║".bright_blue()); + } + if !session.earned_credentials.is_empty() { + for cred in &session.earned_credentials { + println!("{} Credential: {:<44}{}", "║".bright_blue(), cred, "║".bright_blue()); + } + } + if session.defcon_level < 5 { let defcon_label = match session.defcon_level { 1 => "LOCKDOWN".red().to_string(), @@ -205,9 +214,10 @@ fn build_prompt(session: &SessionState) -> DefaultPrompt { }; let short_name = session.display_name.split('@').next().unwrap_or(&session.display_name); + let tier_tag = format!("T{}", session.shell_tier.numeric_level()); DefaultPrompt::new( - DefaultPromptSegment::Basic(format!("{} {}@gsh", risk_indicator, short_name)), + DefaultPromptSegment::Basic(format!("{} {}:{}@gsh", risk_indicator, tier_tag, short_name)), DefaultPromptSegment::Empty, ) } diff --git a/libgsh/Cargo.toml b/libgsh/Cargo.toml index 99a7255..d848607 100644 --- a/libgsh/Cargo.toml +++ b/libgsh/Cargo.toml @@ -15,9 +15,14 @@ chrono = { workspace = true } dirs = { workspace = true } tracing = { workspace = true } substrate-ipc = { workspace = true } +substrate-identity-store = { workspace = true, optional = true } tokio = { workspace = true } libc = { workspace = true } +[features] +default = [] +lmdb = ["substrate-identity-store"] [dev-dependencies] +substrate-identity-store = { workspace = true } tempfile = "3" diff --git a/libgsh/src/ac.rs b/libgsh/src/ac.rs index 8ed380f..4680097 100644 --- a/libgsh/src/ac.rs +++ b/libgsh/src/ac.rs @@ -31,6 +31,10 @@ pub struct AuthorizationContext { /// `"Application"` | `"System"` (or future variants); threaded into `GSH_SHELL_CLASS`. #[serde(default, skip_serializing_if = "Option::is_none")] pub shell_class: Option, + /// Shell tier (0–6); threaded into `GSH_SHELL_TIER`. When absent, + /// derived from `shell_class` (Application→T2, System→T1). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub shell_tier: Option, /// Capability bitmask; threaded into `GSH_CAPABILITY_SET` as `0x{:08x}`. #[serde(default, skip_serializing_if = "Option::is_none")] pub capability_set: Option, diff --git a/libgsh/src/governance_env.rs b/libgsh/src/governance_env.rs index fbcbcd9..37fe996 100644 --- a/libgsh/src/governance_env.rs +++ b/libgsh/src/governance_env.rs @@ -9,6 +9,7 @@ //! | `GSH_DID` | `principal.did` (canonical string) | //! | `GSH_ACCORD_HASH` | `accord_hash` | //! | `GSH_SHELL_CLASS` | `shell_class` | +//! | `GSH_SHELL_TIER` | `shell_tier` numeric (0–6) | //! | `GSH_POSTURE_LEVEL` | `posture_level` (decimal) | //! | `GSH_CAPABILITY_SET` | `capability_set` formatted `0x{:08x}` | //! @@ -18,6 +19,7 @@ use std::process::Command; use crate::ac::AuthorizationContext; +use crate::shell_tier::ShellTier; /// Apply the `GSH_*` env-var contract to a child `Command`. /// @@ -30,6 +32,7 @@ pub fn apply( did: Option<&str>, accord_hash: Option<&str>, shell_class: Option<&str>, + shell_tier: Option, posture_level: Option, capability_set: Option, cap_bounding: Option, @@ -43,6 +46,9 @@ pub fn apply( if let Some(c) = shell_class { cmd.env("GSH_SHELL_CLASS", c); } + if let Some(tier) = shell_tier { + cmd.env("GSH_SHELL_TIER", tier.numeric_level().to_string()); + } if let Some(p) = posture_level { cmd.env("GSH_POSTURE_LEVEL", p.to_string()); } @@ -61,11 +67,18 @@ pub fn apply_from_ac(cmd: &mut Command, ac: &AuthorizationContext) { .as_ref() .and_then(|p| p.did.as_ref()) .map(|d| d.as_str().to_owned()); + + let tier = ac + .shell_tier + .and_then(ShellTier::from_numeric) + .or_else(|| ac.shell_class.as_deref().map(ShellTier::from_shell_class)); + apply( cmd, did.as_deref(), ac.accord_hash.as_deref(), ac.shell_class.as_deref(), + tier, ac.posture_level, ac.capability_set, None, @@ -95,6 +108,7 @@ mod tests { Some("did:web:guildhouse.dev:user:tking"), Some("sha256:abcd"), Some("Application"), + Some(ShellTier::T2Operator), Some(3), Some(0xCAFEBABE), Some(0x0000000000200404), @@ -105,6 +119,7 @@ mod tests { ); assert_eq!(cmd_env(&cmd, "GSH_ACCORD_HASH").as_deref(), Some("sha256:abcd")); assert_eq!(cmd_env(&cmd, "GSH_SHELL_CLASS").as_deref(), Some("Application")); + assert_eq!(cmd_env(&cmd, "GSH_SHELL_TIER").as_deref(), Some("2")); assert_eq!(cmd_env(&cmd, "GSH_POSTURE_LEVEL").as_deref(), Some("3")); assert_eq!( cmd_env(&cmd, "GSH_CAPABILITY_SET").as_deref(), @@ -119,14 +134,23 @@ mod tests { #[test] fn apply_partial_only_did() { let mut cmd = Command::new("true"); - apply(&mut cmd, Some("did:web:foo:bar"), None, None, None, None, None); + apply(&mut cmd, Some("did:web:foo:bar"), None, None, None, None, None, None); assert_eq!(cmd_env(&cmd, "GSH_DID").as_deref(), Some("did:web:foo:bar")); assert!(cmd_env(&cmd, "GSH_ACCORD_HASH").is_none()); assert!(cmd_env(&cmd, "GSH_SHELL_CLASS").is_none()); + assert!(cmd_env(&cmd, "GSH_SHELL_TIER").is_none()); assert!(cmd_env(&cmd, "GSH_POSTURE_LEVEL").is_none()); assert!(cmd_env(&cmd, "GSH_CAPABILITY_SET").is_none()); } + #[test] + fn apply_shell_tier_without_class() { + let mut cmd = Command::new("true"); + apply(&mut cmd, None, None, None, Some(ShellTier::T3Agent), None, None, None); + assert!(cmd_env(&cmd, "GSH_SHELL_CLASS").is_none()); + assert_eq!(cmd_env(&cmd, "GSH_SHELL_TIER").as_deref(), Some("3")); + } + #[test] fn apply_from_ac_full() { let ac_json = r#"{ @@ -146,6 +170,7 @@ mod tests { ); assert_eq!(cmd_env(&cmd, "GSH_ACCORD_HASH").as_deref(), Some("sha256:zz")); assert_eq!(cmd_env(&cmd, "GSH_SHELL_CLASS").as_deref(), Some("System")); + assert_eq!(cmd_env(&cmd, "GSH_SHELL_TIER").as_deref(), Some("1")); assert_eq!(cmd_env(&cmd, "GSH_POSTURE_LEVEL").as_deref(), Some("5")); assert_eq!( cmd_env(&cmd, "GSH_CAPABILITY_SET").as_deref(), @@ -153,6 +178,21 @@ mod tests { ); } + #[test] + fn apply_from_ac_with_explicit_tier() { + let ac_json = r#"{ + "context_id":"x", + "principal":{"did":"did:web:example.com:user:bob"}, + "shell_class":"Application", + "shell_tier":3 + }"#; + let ac: AuthorizationContext = serde_json::from_str(ac_json).unwrap(); + let mut cmd = Command::new("true"); + apply_from_ac(&mut cmd, &ac); + assert_eq!(cmd_env(&cmd, "GSH_SHELL_CLASS").as_deref(), Some("Application")); + assert_eq!(cmd_env(&cmd, "GSH_SHELL_TIER").as_deref(), Some("3")); + } + #[test] fn apply_from_legacy_ac_no_governance_fields() { let ac_json = r#"{"context_id":"legacy","principal":{"did":"did:web:foo:bar"}}"#; diff --git a/libgsh/src/lib.rs b/libgsh/src/lib.rs index 687ba82..884e208 100644 --- a/libgsh/src/lib.rs +++ b/libgsh/src/lib.rs @@ -7,9 +7,12 @@ pub mod config; pub mod corpus; pub mod cr; pub mod governance_env; +#[cfg(feature = "lmdb")] +pub mod lmdb_enrichment; pub mod register; pub mod registry; pub mod session; +pub mod shell_tier; pub use ac::{AcValidationError, AuthorizationContext}; pub use classifier::{classify_command, CommandClass, FREE_COMMANDS}; @@ -18,6 +21,7 @@ pub use corpus::{corpus_check, corpus_check_with_base, CorpusCheckResult, DEFAUL pub use cr::{post_cr, CrResult}; pub use registry::ConsumedRegistry; pub use session::SessionState; +pub use shell_tier::ShellTier; /// Compute SHA-256 hash with "sha256:" prefix. pub fn sha256_hash(data: &[u8]) -> String { diff --git a/libgsh/src/lmdb_enrichment.rs b/libgsh/src/lmdb_enrichment.rs new file mode 100644 index 0000000..b53a8f2 --- /dev/null +++ b/libgsh/src/lmdb_enrichment.rs @@ -0,0 +1,95 @@ +//! Optional LMDB session enrichment (behind `lmdb` feature flag). +//! +//! Reads earned credentials, identity class, shell tier, and capability +//! bounding set from the substrate identity store for the session's DID. + +use substrate_identity_store::{CredentialRef, IdentityStore}; +use std::path::Path; + +/// Enrichment data retrieved from the LMDB identity store. +pub struct LmdbEnrichment { + pub credentials: Vec, + pub identity_class: String, + pub shell_tier: u8, + pub cap_bounding: u64, +} + +/// Read identity data from LMDB for the given DID. +/// Returns `None` if LMDB is unavailable or the DID is not found. +pub fn enrich_from_lmdb(did: &str) -> Option { + let path = std::env::var("SUBSTRATE_IDENTITY_PATH") + .unwrap_or_else(|_| substrate_identity_store::DEFAULT_IDENTITY_PATH.to_string()); + let store = IdentityStore::open(Path::new(&path)).ok()?; + let entry = store.get_by_did(did).ok()??; + Some(LmdbEnrichment { + credentials: entry.credentials, + identity_class: entry.identity_class, + shell_tier: entry.shell_tier, + cap_bounding: entry.cap_bounding, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn enrich_returns_none_when_lmdb_unavailable() { + std::env::set_var("SUBSTRATE_IDENTITY_PATH", "/nonexistent/lmdb"); + let result = enrich_from_lmdb("did:web:example.com:user:test"); + assert!(result.is_none()); + std::env::remove_var("SUBSTRATE_IDENTITY_PATH"); + } + + #[test] + fn enrich_returns_none_for_unknown_did() { + let dir = tempfile::tempdir().unwrap(); + let _store = IdentityStore::open(dir.path()).unwrap(); + std::env::set_var("SUBSTRATE_IDENTITY_PATH", dir.path().to_str().unwrap()); + let result = enrich_from_lmdb("did:web:example.com:user:nobody"); + assert!(result.is_none()); + std::env::remove_var("SUBSTRATE_IDENTITY_PATH"); + } + + #[test] + fn enrich_returns_data_for_known_did() { + let dir = tempfile::tempdir().unwrap(); + let store = IdentityStore::open(dir.path()).unwrap(); + let entry = substrate_identity_store::IdentityEntry { + did: "did:web:example.com:user:tking".into(), + uid: 60001, + gid: 60001, + login_name: "tking".into(), + home_directory: "/home/tking".into(), + login_shell: "/usr/bin/gsh".into(), + gecos: String::new(), + supplementary_groups: vec![], + shell_tier: 2, + cap_bounding: 0x0404, + cap_ambient: 0, + namespace_flags: 0, + identity_class: "Authority".into(), + credentials: vec![substrate_identity_store::CredentialRef { + vc_hash: "sha256:abc".into(), + capability: "CAP_DEPLOY".into(), + issuer_did: "did:web:example.com:authority:root".into(), + earned_at: "2026-05-28".into(), + ceremony_id: "cer-001".into(), + corpus_evidence_root: "sha256:def".into(), + }], + forge_sources: vec![], + generation: 1, + last_updated_ns: 0, + source_resource_version: "rv-1".into(), + }; + store.put_identity(&entry).unwrap(); + + std::env::set_var("SUBSTRATE_IDENTITY_PATH", dir.path().to_str().unwrap()); + let result = enrich_from_lmdb("did:web:example.com:user:tking").unwrap(); + assert_eq!(result.identity_class, "Authority"); + assert_eq!(result.credentials.len(), 1); + assert_eq!(result.credentials[0].capability, "CAP_DEPLOY"); + assert_eq!(result.shell_tier, 2); + std::env::remove_var("SUBSTRATE_IDENTITY_PATH"); + } +} diff --git a/libgsh/src/session.rs b/libgsh/src/session.rs index 326b169..f338e89 100644 --- a/libgsh/src/session.rs +++ b/libgsh/src/session.rs @@ -1,6 +1,7 @@ //! Session state tracking for human mode. use crate::ac::AuthorizationContext; +use crate::shell_tier::ShellTier; /// Tracks session state across the REPL loop. pub struct SessionState { @@ -22,8 +23,15 @@ pub struct SessionState { /// `GSH_*` env vars via [`SessionState::apply_governance_env`]. pub accord_hash: Option, pub shell_class: Option, + pub shell_tier: ShellTier, pub capability_set: Option, pub posture_level: Option, + /// Earned credentials from LMDB identity store, formatted as + /// "capability (issuer_did)". Populated only when the `lmdb` + /// feature is enabled and the DID is found in the store. + pub earned_credentials: Vec, + /// Identity class from LMDB (e.g. "Authority", "Operator"). + pub identity_class: Option, } impl SessionState { @@ -57,7 +65,7 @@ impl SessionState { .ok().and_then(|v| v.parse().ok()).unwrap_or(5); let defcon_reason = std::env::var("BASCULE_DEFCON_REASON").ok(); - Self { + let mut session = Self { ac_id: ac.context_id.clone(), corpus_cid: corpus_cid.to_string(), principal, @@ -73,9 +81,24 @@ impl SessionState { denied_count: 0, accord_hash: ac.accord_hash.clone(), shell_class: ac.shell_class.clone(), + shell_tier: resolve_shell_tier(ac.shell_tier, ac.shell_class.as_deref()), capability_set: ac.capability_set, posture_level: ac.posture_level, + earned_credentials: Vec::new(), + identity_class: None, + }; + + #[cfg(feature = "lmdb")] + { + if let Some(enrichment) = crate::lmdb_enrichment::enrich_from_lmdb(&session.principal) { + session.earned_credentials = enrichment.credentials.iter() + .map(|c| format!("{} ({})", c.capability, c.issuer_did)) + .collect(); + session.identity_class = Some(enrichment.identity_class); + } } + + session } /// Create a minimal session for ungoverned mode. @@ -93,7 +116,7 @@ impl SessionState { .ok().and_then(|v| v.parse().ok()).unwrap_or(5); let defcon_reason = std::env::var("BASCULE_DEFCON_REASON").ok(); - Self { + let mut session = Self { ac_id: "ungoverned".to_string(), corpus_cid: corpus_cid.to_string(), principal, @@ -109,9 +132,24 @@ impl SessionState { denied_count: 0, accord_hash: None, shell_class: None, + shell_tier: resolve_shell_tier(None, None), capability_set: None, posture_level: None, + earned_credentials: Vec::new(), + identity_class: None, + }; + + #[cfg(feature = "lmdb")] + { + if let Some(enrichment) = crate::lmdb_enrichment::enrich_from_lmdb(&session.principal) { + session.earned_credentials = enrichment.credentials.iter() + .map(|c| format!("{} ({})", c.capability, c.issuer_did)) + .collect(); + session.identity_class = Some(enrichment.identity_class); + } } + + session } /// Apply the `GSH_*` env-var contract to a child `Command`. @@ -127,6 +165,7 @@ impl SessionState { Some(&self.principal), self.accord_hash.as_deref(), self.shell_class.as_deref(), + Some(self.shell_tier), self.posture_level, self.capability_set, None, @@ -145,6 +184,23 @@ impl SessionState { } } +fn resolve_shell_tier(explicit: Option, shell_class: Option<&str>) -> ShellTier { + if let Some(n) = explicit { + if let Some(tier) = ShellTier::from_numeric(n) { + return tier; + } + } + if let Some(env_tier) = std::env::var("GSH_SHELL_TIER").ok() { + if let Ok(tier) = env_tier.parse::() { + return tier; + } + } + if let Some(class) = shell_class { + return ShellTier::from_shell_class(class); + } + ShellTier::default() +} + fn whoami() -> String { std::env::var("USER") .or_else(|_| std::env::var("USERNAME")) diff --git a/libgsh/src/shell_tier.rs b/libgsh/src/shell_tier.rs new file mode 100644 index 0000000..0a0fe6b --- /dev/null +++ b/libgsh/src/shell_tier.rs @@ -0,0 +1,263 @@ +// Copyright 2026 Guildhouse Dev +// SPDX-License-Identifier: Apache-2.0 + +//! Typed shell hierarchy (T0–T6). +//! +//! Local definition matching org-ops-core's `ShellTier`. Kept here to +//! avoid pulling the full org-ops-core dependency tree into gsh. If a +//! shared governance-types crate is created, consolidate there. +//! +//! ```text +//! T0 Genesis → T1 Infrastructure → ┬─ T2 Operator → ┬─ T3 Agent +//! │ └─ T4 Task +//! ├─ T5 Forensic +//! └─ T6 Recovery +//! ``` + +use std::fmt; +use std::str::FromStr; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum ShellTier { + T0Genesis, + T1Infrastructure, + T2Operator, + T3Agent, + T4Task, + T5Forensic, + T6Recovery, +} + +impl ShellTier { + pub fn numeric_level(&self) -> u8 { + match self { + Self::T0Genesis => 0, + Self::T1Infrastructure => 1, + Self::T2Operator => 2, + Self::T3Agent => 3, + Self::T4Task => 4, + Self::T5Forensic => 5, + Self::T6Recovery => 6, + } + } + + pub fn from_numeric(n: u8) -> Option { + match n { + 0 => Some(Self::T0Genesis), + 1 => Some(Self::T1Infrastructure), + 2 => Some(Self::T2Operator), + 3 => Some(Self::T3Agent), + 4 => Some(Self::T4Task), + 5 => Some(Self::T5Forensic), + 6 => Some(Self::T6Recovery), + _ => None, + } + } + + pub fn label(&self) -> &'static str { + match self { + Self::T0Genesis => "Genesis", + Self::T1Infrastructure => "Infrastructure", + Self::T2Operator => "Operator", + Self::T3Agent => "Agent", + Self::T4Task => "Task", + Self::T5Forensic => "Forensic", + Self::T6Recovery => "Recovery", + } + } + + pub fn parent(&self) -> Option { + match self { + Self::T0Genesis => None, + Self::T1Infrastructure => Some(Self::T0Genesis), + Self::T2Operator => Some(Self::T1Infrastructure), + Self::T3Agent => Some(Self::T2Operator), + Self::T4Task => Some(Self::T2Operator), + Self::T5Forensic => Some(Self::T1Infrastructure), + Self::T6Recovery => Some(Self::T1Infrastructure), + } + } + + pub fn satisfies(&self, required: ShellTier) -> bool { + if *self == required { + return true; + } + let mut current = required; + while let Some(p) = current.parent() { + if p == *self { + return true; + } + current = p; + } + false + } + + /// Map a legacy `GSH_SHELL_CLASS` string to a tier. + pub fn from_shell_class(class: &str) -> Self { + match class { + "System" => Self::T1Infrastructure, + "Application" => Self::T2Operator, + _ => Self::T2Operator, + } + } + + /// Return the legacy shell class string for backward compatibility. + pub fn to_shell_class(&self) -> &'static str { + match self { + Self::T0Genesis | Self::T1Infrastructure => "System", + _ => "Application", + } + } +} + +impl Default for ShellTier { + fn default() -> Self { + ShellTier::T2Operator + } +} + +impl fmt::Display for ShellTier { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "T{} ({})", self.numeric_level(), self.label()) + } +} + +impl FromStr for ShellTier { + type Err = String; + + fn from_str(s: &str) -> Result { + if let Ok(n) = s.parse::() { + return Self::from_numeric(n).ok_or_else(|| format!("unknown shell tier: {s}")); + } + match s.to_lowercase().trim() { + "t0" | "genesis" | "t0genesis" => Ok(Self::T0Genesis), + "t1" | "infrastructure" | "t1infrastructure" => Ok(Self::T1Infrastructure), + "t2" | "operator" | "t2operator" => Ok(Self::T2Operator), + "t3" | "agent" | "t3agent" => Ok(Self::T3Agent), + "t4" | "task" | "t4task" => Ok(Self::T4Task), + "t5" | "forensic" | "t5forensic" => Ok(Self::T5Forensic), + "t6" | "recovery" | "t6recovery" => Ok(Self::T6Recovery), + other => Err(format!("unknown shell tier: {other}")), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_is_t2() { + assert_eq!(ShellTier::default(), ShellTier::T2Operator); + } + + #[test] + fn display_format() { + assert_eq!(format!("{}", ShellTier::T0Genesis), "T0 (Genesis)"); + assert_eq!(format!("{}", ShellTier::T2Operator), "T2 (Operator)"); + assert_eq!(format!("{}", ShellTier::T5Forensic), "T5 (Forensic)"); + } + + #[test] + fn parse_canonical_short() { + assert_eq!("t0".parse::().unwrap(), ShellTier::T0Genesis); + assert_eq!("T2".parse::().unwrap(), ShellTier::T2Operator); + assert_eq!("T6".parse::().unwrap(), ShellTier::T6Recovery); + } + + #[test] + fn parse_numeric_string() { + assert_eq!("0".parse::().unwrap(), ShellTier::T0Genesis); + assert_eq!("3".parse::().unwrap(), ShellTier::T3Agent); + assert_eq!("6".parse::().unwrap(), ShellTier::T6Recovery); + assert!("7".parse::().is_err()); + } + + #[test] + fn parse_label() { + assert_eq!("genesis".parse::().unwrap(), ShellTier::T0Genesis); + assert_eq!("operator".parse::().unwrap(), ShellTier::T2Operator); + assert_eq!("forensic".parse::().unwrap(), ShellTier::T5Forensic); + } + + #[test] + fn parse_invalid() { + assert!("t7".parse::().is_err()); + assert!("bogus".parse::().is_err()); + } + + #[test] + fn parent_tree() { + assert_eq!(ShellTier::T0Genesis.parent(), None); + assert_eq!(ShellTier::T1Infrastructure.parent(), Some(ShellTier::T0Genesis)); + assert_eq!(ShellTier::T2Operator.parent(), Some(ShellTier::T1Infrastructure)); + assert_eq!(ShellTier::T3Agent.parent(), Some(ShellTier::T2Operator)); + assert_eq!(ShellTier::T4Task.parent(), Some(ShellTier::T2Operator)); + assert_eq!(ShellTier::T5Forensic.parent(), Some(ShellTier::T1Infrastructure)); + assert_eq!(ShellTier::T6Recovery.parent(), Some(ShellTier::T1Infrastructure)); + } + + #[test] + fn satisfies_self() { + for tier in [ + ShellTier::T0Genesis, ShellTier::T1Infrastructure, ShellTier::T2Operator, + ShellTier::T3Agent, ShellTier::T4Task, ShellTier::T5Forensic, ShellTier::T6Recovery, + ] { + assert!(tier.satisfies(tier)); + } + } + + #[test] + fn t0_satisfies_everything() { + assert!(ShellTier::T0Genesis.satisfies(ShellTier::T4Task)); + assert!(ShellTier::T0Genesis.satisfies(ShellTier::T6Recovery)); + } + + #[test] + fn t2_satisfies_t3_t4_not_t5_t6() { + assert!(ShellTier::T2Operator.satisfies(ShellTier::T3Agent)); + assert!(ShellTier::T2Operator.satisfies(ShellTier::T4Task)); + assert!(!ShellTier::T2Operator.satisfies(ShellTier::T5Forensic)); + assert!(!ShellTier::T2Operator.satisfies(ShellTier::T6Recovery)); + } + + #[test] + fn leaf_does_not_satisfy_sibling() { + assert!(!ShellTier::T3Agent.satisfies(ShellTier::T4Task)); + assert!(!ShellTier::T5Forensic.satisfies(ShellTier::T6Recovery)); + } + + #[test] + fn from_shell_class_mapping() { + assert_eq!(ShellTier::from_shell_class("Application"), ShellTier::T2Operator); + assert_eq!(ShellTier::from_shell_class("System"), ShellTier::T1Infrastructure); + assert_eq!(ShellTier::from_shell_class("Unknown"), ShellTier::T2Operator); + } + + #[test] + fn to_shell_class_backward_compat() { + assert_eq!(ShellTier::T0Genesis.to_shell_class(), "System"); + assert_eq!(ShellTier::T1Infrastructure.to_shell_class(), "System"); + assert_eq!(ShellTier::T2Operator.to_shell_class(), "Application"); + assert_eq!(ShellTier::T3Agent.to_shell_class(), "Application"); + assert_eq!(ShellTier::T5Forensic.to_shell_class(), "Application"); + } + + #[test] + fn numeric_levels() { + assert_eq!(ShellTier::T0Genesis.numeric_level(), 0); + assert_eq!(ShellTier::T3Agent.numeric_level(), 3); + assert_eq!(ShellTier::T6Recovery.numeric_level(), 6); + } + + #[test] + fn from_numeric_round_trip() { + for n in 0..=6u8 { + let tier = ShellTier::from_numeric(n).unwrap(); + assert_eq!(tier.numeric_level(), n); + } + assert!(ShellTier::from_numeric(7).is_none()); + } +}