From 88840ae620a5a7c787cd747eca3bd54b69b91487bdca5be8bdd9978d2365a0b4 Mon Sep 17 00:00:00 2001 From: Tyler J King Date: Sun, 3 May 2026 08:50:20 -0400 Subject: [PATCH] feat(libgsh): GSH_* env contract for org-ops-core child processes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of org-ops-core CLI standardization: gsh now exports a discrete set of governance-context env vars to child processes so org-ops-core (substrate-level operations library, future move) can construct a GshContext without re-parsing the GSAP_SESSION_AC blob. Contract: GSH_DID principal.did (canonical string) GSH_ACCORD_HASH accord_hash GSH_SHELL_CLASS shell_class ("Application" | "System" | ...) GSH_POSTURE_LEVEL posture_level (decimal 1..=5) GSH_CAPABILITY_SET capability_set formatted "0x{:08x}" AC schema (libgsh::ac::AuthorizationContext) gains four optional fields — accord_hash, shell_class, capability_set, posture_level — all #[serde(default, skip_serializing_if = "Option::is_none")]. Existing AC producers continue working unchanged; ACs without the new fields parse cleanly. Serialize is added to the AC structs to enable round-trip and to let library consumers construct ACs programmatically. New module libgsh::governance_env exposes: - apply(cmd, did, accord_hash, shell_class, posture_level, capability_set) — stateless decorator - apply_from_ac(cmd, &AC) — convenience wrapper over apply SessionState gains the four governance fields (populated from AC in from_ac, left None in ungoverned). SessionState::apply_governance_env threads them onto a child Command at REPL spawn sites. Spawn sites updated: - gsh::main::run (governed --exec) — retains the parsed AC and calls governance_env::apply_from_ac on the exec Command. - gsh::human::execute_passthrough — now takes &SessionState; applies session governance env (REPL Free/Ungoverned paths). - gsh::human::execute_governed — applies session governance env alongside the existing BASCULE_SESSION_ID / BASCULE_CORPUS_CID. Legacy GSAP_SESSION_AC / GSAP_SESSION_ID / GSAP_SESSION_SCOPE exports remain intact — the GSH_* vars are purely additive convenience for org-ops-core. Session and inline AC modes (which surface only an ID, not the full struct) export nothing new — same fail-soft behaviour as before. Tests added: - ac::tests::test_governance_fields_round_trip — full payload parses and re-serializes losslessly. - ac::tests::test_governance_fields_absent_back_compat — legacy AC parses without governance fields and round-trips without emitting them. - governance_env::tests::apply_all_fields — every GSH_* var set. - governance_env::tests::apply_partial_only_did — missing fields leave the env var unset rather than empty. - governance_env::tests::apply_from_ac_full — end-to-end AC → env var application. - governance_env::tests::apply_from_legacy_ac_no_governance_fields — legacy AC sets only GSH_DID, no other GSH_* vars. 24 tests pass; cargo build clean. Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: Tyler J King --- gsh/src/human.rs | 23 ++--- gsh/src/main.rs | 19 +++-- libgsh/src/ac.rs | 90 +++++++++++++++++--- libgsh/src/governance_env.rs | 159 +++++++++++++++++++++++++++++++++++ libgsh/src/lib.rs | 1 + libgsh/src/session.rs | 33 ++++++++ 6 files changed, 296 insertions(+), 29 deletions(-) create mode 100644 libgsh/src/governance_env.rs diff --git a/gsh/src/human.rs b/gsh/src/human.rs index 99bd5fc..e973929 100644 --- a/gsh/src/human.rs +++ b/gsh/src/human.rs @@ -54,7 +54,7 @@ pub fn run_human_mode( // Classify command: match classify_command(line, &session.corpus_cid, &corpus_dir) { CommandClass::Free => { - execute_passthrough(line); + execute_passthrough(line, session); session.free_count += 1; } CommandClass::Governed { corpus_binary } => { @@ -80,7 +80,7 @@ pub fn run_human_mode( "{}", format!(" ⚠ ungoverned: '{}' not in corpus", cmd_name).yellow() ); - execute_passthrough(line); + execute_passthrough(line, session); session.ungoverned_count += 1; } CommandClass::Denied { reason } => { @@ -234,11 +234,11 @@ fn build_prompt(session: &SessionState) -> DefaultPrompt { ) } -fn execute_passthrough(line: &str) -> i32 { - let status = std::process::Command::new("sh") - .arg("-c") - .arg(line) - .status(); +fn execute_passthrough(line: &str, session: &SessionState) -> i32 { + let mut cmd = std::process::Command::new("sh"); + cmd.arg("-c").arg(line); + session.apply_governance_env(&mut cmd); + let status = cmd.status(); match status { Ok(s) => s.code().unwrap_or(1), Err(e) => { @@ -250,11 +250,12 @@ fn execute_passthrough(line: &str) -> i32 { fn execute_governed(line: &str, corpus_binary: &Path, session: &SessionState) -> i32 { let args: Vec<&str> = line.split_whitespace().skip(1).collect(); - let status = std::process::Command::new(corpus_binary) - .args(&args) + let mut cmd = std::process::Command::new(corpus_binary); + cmd.args(&args) .env("BASCULE_SESSION_ID", &session.ac_id) - .env("BASCULE_CORPUS_CID", &session.corpus_cid) - .status(); + .env("BASCULE_CORPUS_CID", &session.corpus_cid); + session.apply_governance_env(&mut cmd); + let status = cmd.status(); match status { Ok(s) => s.code().unwrap_or(1), Err(e) => { diff --git a/gsh/src/main.rs b/gsh/src/main.rs index 3d5d0b9..e829d78 100644 --- a/gsh/src/main.rs +++ b/gsh/src/main.rs @@ -215,7 +215,11 @@ fn run(args: Args) -> Result { // Determine AC mode: let pre_issued = args.ac.clone().or_else(|| std::env::var("GSAP_AC").ok()); - let (ac_id, ac_mode) = if let Some(ac_json) = pre_issued { + // Retain the full AC struct for pre-issued mode so the governance-env + // contract (`GSH_DID`/`GSH_ACCORD_HASH`/...) can be threaded into the + // child process at the exec site below. Session and inline modes only + // surface an ID; their governance fields stay un-exported. + let (ac_id, ac_mode, ac_struct) = if let Some(ac_json) = pre_issued { let mut registry = ConsumedRegistry::default_location(); let ac = libgsh::ac::validate_ac(&ac_json, &corpus, &mut registry) .map_err(|e| anyhow::anyhow!("AC validation failed (exit {}): {}", e.exit_code(), e))?; @@ -223,9 +227,9 @@ fn run(args: Args) -> Result { if let Some(ref p) = ac.principal { if let Some(ref did) = p.did { eprintln!("gsh: principal — {}", did); } } - (ac.context_id, "pre-issued".to_string()) + (ac.context_id.clone(), "pre-issued".to_string(), Some(ac)) } else if let Ok(session_ac) = std::env::var("GSAP_SESSION_AC") { - (session_ac, "session".to_string()) + (session_ac, "session".to_string(), None) } else { let base = args.broker_url.clone() .or_else(|| std::env::var("GSAP_BROKER_URL").ok()) @@ -235,7 +239,7 @@ fn run(args: Args) -> Result { let id = request_ac_inline(&client, &base, &args.operation, &command_hash, &corpus) .map_err(|e| anyhow::anyhow!(e))?; eprintln!("gsh: AC issued — {}", &id[..8.min(id.len())]); - (id, "inline".to_string()) + (id, "inline".to_string(), None) }; // Corpus gate: @@ -293,7 +297,12 @@ fn run(args: Args) -> Result { } // Execute: - let output = process::Command::new("sh").arg("-c").arg(exec).output().context("exec failed")?; + let mut command = process::Command::new("sh"); + command.arg("-c").arg(exec); + if let Some(ref ac) = ac_struct { + libgsh::governance_env::apply_from_ac(&mut command, ac); + } + let output = command.output().context("exec failed")?; let exit_code = output.status.code().unwrap_or(1); let stdout_str = String::from_utf8_lossy(&output.stdout).to_string(); let stderr_str = String::from_utf8_lossy(&output.stderr).to_string(); diff --git a/libgsh/src/ac.rs b/libgsh/src/ac.rs index f27e1f8..8ed380f 100644 --- a/libgsh/src/ac.rs +++ b/libgsh/src/ac.rs @@ -1,40 +1,59 @@ //! Authorization Context validation (R-22, R-23, R-24). use guildhouse_did::Did; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use thiserror::Error; use crate::registry::ConsumedRegistry; /// A pre-issued Authorization Context from the broker. -#[derive(Deserialize, Debug, Clone)] +/// +/// Phase 1 of org-ops-core CLI standardization (2026-05-03) added the +/// governance-context fields (`accord_hash`, `shell_class`, +/// `capability_set`, `posture_level`) so child processes spawned inside +/// gsh can read the operator's accord/posture/capability scope from +/// discrete `GSH_*` env vars without re-parsing the AC blob. All new +/// fields are optional; existing AC producers keep working unchanged. +#[derive(Serialize, Deserialize, Debug, Clone)] pub struct AuthorizationContext { pub context_id: String, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub issued_at: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub expires_at: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub operation: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub principal: Option, + /// SHA-256 of the governing accord; threaded into `GSH_ACCORD_HASH`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub accord_hash: Option, + /// `"Application"` | `"System"` (or future variants); threaded into `GSH_SHELL_CLASS`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub shell_class: Option, + /// Capability bitmask; threaded into `GSH_CAPABILITY_SET` as `0x{:08x}`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub capability_set: Option, + /// Posture / DEFCON level (1..=5); threaded into `GSH_POSTURE_LEVEL`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub posture_level: Option, } -#[derive(Deserialize, Debug, Clone, Default)] +#[derive(Serialize, Deserialize, Debug, Clone, Default)] pub struct AcOperation { - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub corpus_entry_cid: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub parameters_cid: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub playbook: Option, } -#[derive(Deserialize, Debug, Clone, Default)] +#[derive(Serialize, Deserialize, Debug, Clone, Default)] pub struct AcPrincipal { - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub did: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub display_name: Option, } @@ -172,4 +191,49 @@ mod tests { Err(AcValidationError::MissingContextId) )); } + + #[test] + fn test_governance_fields_round_trip() { + // AC with all new governance fields populated. + let ac_json = r#"{ + "context_id":"gov-ctx-1", + "expires_at":"2099-01-01T00:00:00Z", + "principal":{"did":"did:web:guildhouse.dev:user:tking","display_name":"tking"}, + "accord_hash":"sha256:abcd1234", + "shell_class":"Application", + "capability_set":7, + "posture_level":4 + }"#; + let ac: AuthorizationContext = serde_json::from_str(ac_json).unwrap(); + assert_eq!(ac.accord_hash.as_deref(), Some("sha256:abcd1234")); + assert_eq!(ac.shell_class.as_deref(), Some("Application")); + assert_eq!(ac.capability_set, Some(7)); + assert_eq!(ac.posture_level, Some(4)); + + // Re-serialize and parse again; round-trip must preserve the values. + let reserialized = serde_json::to_string(&ac).unwrap(); + let parsed: AuthorizationContext = serde_json::from_str(&reserialized).unwrap(); + assert_eq!(parsed.accord_hash, ac.accord_hash); + assert_eq!(parsed.shell_class, ac.shell_class); + assert_eq!(parsed.capability_set, ac.capability_set); + assert_eq!(parsed.posture_level, ac.posture_level); + } + + #[test] + fn test_governance_fields_absent_back_compat() { + // Legacy AC blob with no governance fields parses cleanly. + let ac_json = r#"{"context_id":"legacy","operation":{"corpus_entry_cid":"sha256:ungoverned"}}"#; + let ac: AuthorizationContext = serde_json::from_str(ac_json).unwrap(); + assert!(ac.accord_hash.is_none()); + assert!(ac.shell_class.is_none()); + assert!(ac.capability_set.is_none()); + assert!(ac.posture_level.is_none()); + + // Round-trip serialize: with skip_serializing_if, omitted fields stay omitted. + let reserialized = serde_json::to_string(&ac).unwrap(); + assert!(!reserialized.contains("accord_hash")); + assert!(!reserialized.contains("shell_class")); + assert!(!reserialized.contains("capability_set")); + assert!(!reserialized.contains("posture_level")); + } } diff --git a/libgsh/src/governance_env.rs b/libgsh/src/governance_env.rs new file mode 100644 index 0000000..3e4106f --- /dev/null +++ b/libgsh/src/governance_env.rs @@ -0,0 +1,159 @@ +//! `GSH_*` env-var contract for child processes spawned inside gsh. +//! +//! org-ops-core (substrate-level operations library) reads these +//! discrete env vars to construct a `GshContext` without re-parsing +//! the `GSAP_SESSION_AC` JSON blob: +//! +//! | Variable | Source | +//! |----------------------|---------------------------------------| +//! | `GSH_DID` | `principal.did` (canonical string) | +//! | `GSH_ACCORD_HASH` | `accord_hash` | +//! | `GSH_SHELL_CLASS` | `shell_class` | +//! | `GSH_POSTURE_LEVEL` | `posture_level` (decimal) | +//! | `GSH_CAPABILITY_SET` | `capability_set` formatted `0x{:08x}` | +//! +//! The legacy `GSAP_SESSION_*` exports are kept by the gsh binary for +//! existing consumers; this module is purely additive. + +use std::process::Command; + +use crate::ac::AuthorizationContext; + +/// Apply the `GSH_*` env-var contract to a child `Command`. +/// +/// Each parameter is `Option`-typed; absent values leave the +/// corresponding env var unset (the child sees no `GSH_FOO` rather +/// than an empty `GSH_FOO`). Caller passes whatever subset is known — +/// ungoverned mode might supply only `did`. +pub fn apply( + cmd: &mut Command, + did: Option<&str>, + accord_hash: Option<&str>, + shell_class: Option<&str>, + posture_level: Option, + capability_set: Option, +) { + if let Some(d) = did { + cmd.env("GSH_DID", d); + } + if let Some(h) = accord_hash { + cmd.env("GSH_ACCORD_HASH", h); + } + if let Some(c) = shell_class { + cmd.env("GSH_SHELL_CLASS", c); + } + if let Some(p) = posture_level { + cmd.env("GSH_POSTURE_LEVEL", p.to_string()); + } + if let Some(c) = capability_set { + cmd.env("GSH_CAPABILITY_SET", format!("0x{:08x}", c)); + } +} + +/// Apply the `GSH_*` env-var contract from a parsed AC. +pub fn apply_from_ac(cmd: &mut Command, ac: &AuthorizationContext) { + let did = ac + .principal + .as_ref() + .and_then(|p| p.did.as_ref()) + .map(|d| d.as_str().to_owned()); + apply( + cmd, + did.as_deref(), + ac.accord_hash.as_deref(), + ac.shell_class.as_deref(), + ac.posture_level, + ac.capability_set, + ); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::process::Command; + + fn cmd_env(cmd: &Command, key: &str) -> Option { + cmd.get_envs().find_map(|(k, v)| { + if k == std::ffi::OsStr::new(key) { + v.map(|s| s.to_string_lossy().into_owned()) + } else { + None + } + }) + } + + #[test] + fn apply_all_fields() { + let mut cmd = Command::new("true"); + apply( + &mut cmd, + Some("did:web:guildhouse.dev:user:tking"), + Some("sha256:abcd"), + Some("Application"), + Some(3), + Some(0xCAFEBABE), + ); + assert_eq!( + cmd_env(&cmd, "GSH_DID").as_deref(), + Some("did:web:guildhouse.dev:user:tking") + ); + 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_POSTURE_LEVEL").as_deref(), Some("3")); + assert_eq!( + cmd_env(&cmd, "GSH_CAPABILITY_SET").as_deref(), + Some("0xcafebabe") + ); + } + + #[test] + fn apply_partial_only_did() { + let mut cmd = Command::new("true"); + apply(&mut cmd, Some("did:web:foo:bar"), 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_POSTURE_LEVEL").is_none()); + assert!(cmd_env(&cmd, "GSH_CAPABILITY_SET").is_none()); + } + + #[test] + fn apply_from_ac_full() { + let ac_json = r#"{ + "context_id":"x", + "principal":{"did":"did:web:guildhouse.dev:user:tking"}, + "accord_hash":"sha256:zz", + "shell_class":"System", + "capability_set":1, + "posture_level":5 + }"#; + 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_DID").as_deref(), + Some("did:web:guildhouse.dev:user:tking") + ); + 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_POSTURE_LEVEL").as_deref(), Some("5")); + assert_eq!( + cmd_env(&cmd, "GSH_CAPABILITY_SET").as_deref(), + Some("0x00000001") + ); + } + + #[test] + fn apply_from_legacy_ac_no_governance_fields() { + let ac_json = r#"{"context_id":"legacy","principal":{"did":"did:web:foo:bar"}}"#; + 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_DID").as_deref(), Some("did:web:foo:bar")); + // No governance metadata — none of the other GSH_* vars set. + assert!(cmd_env(&cmd, "GSH_ACCORD_HASH").is_none()); + assert!(cmd_env(&cmd, "GSH_SHELL_CLASS").is_none()); + assert!(cmd_env(&cmd, "GSH_POSTURE_LEVEL").is_none()); + assert!(cmd_env(&cmd, "GSH_CAPABILITY_SET").is_none()); + } +} diff --git a/libgsh/src/lib.rs b/libgsh/src/lib.rs index a61113f..a26ea06 100644 --- a/libgsh/src/lib.rs +++ b/libgsh/src/lib.rs @@ -4,6 +4,7 @@ pub mod classifier; pub mod config; pub mod corpus; pub mod cr; +pub mod governance_env; pub mod registry; pub mod session; diff --git a/libgsh/src/session.rs b/libgsh/src/session.rs index da7515a..6f4de42 100644 --- a/libgsh/src/session.rs +++ b/libgsh/src/session.rs @@ -17,6 +17,13 @@ pub struct SessionState { pub free_count: u32, pub ungoverned_count: u32, pub denied_count: u32, + /// Governance-context fields propagated from the AC (Phase 1 of org-ops-core + /// CLI standardization, 2026-05-03). Used to decorate child Commands with + /// `GSH_*` env vars via [`SessionState::apply_governance_env`]. + pub accord_hash: Option, + pub shell_class: Option, + pub capability_set: Option, + pub posture_level: Option, } impl SessionState { @@ -64,6 +71,10 @@ impl SessionState { free_count: 0, ungoverned_count: 0, denied_count: 0, + accord_hash: ac.accord_hash.clone(), + shell_class: ac.shell_class.clone(), + capability_set: ac.capability_set, + posture_level: ac.posture_level, } } @@ -96,9 +107,31 @@ impl SessionState { free_count: 0, ungoverned_count: 0, denied_count: 0, + accord_hash: None, + shell_class: None, + capability_set: None, + posture_level: None, } } + /// Apply the `GSH_*` env-var contract to a child `Command`. + /// + /// Always exports `GSH_DID` (the resolved principal). Governance + /// metadata (`GSH_ACCORD_HASH`, `GSH_SHELL_CLASS`, + /// `GSH_POSTURE_LEVEL`, `GSH_CAPABILITY_SET`) is exported only when + /// the session has the corresponding fields populated (i.e. governed + /// mode with a full AC). + pub fn apply_governance_env(&self, cmd: &mut std::process::Command) { + crate::governance_env::apply( + cmd, + Some(&self.principal), + self.accord_hash.as_deref(), + self.shell_class.as_deref(), + self.posture_level, + self.capability_set, + ); + } + pub fn minutes_remaining(&self) -> i64 { match &self.expires_at { Some(exp) => (*exp - chrono::Utc::now()).num_minutes(),