feat(libgsh): GSH_* env contract for org-ops-core child processes

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) <noreply@anthropic.com>
Signed-off-by: Tyler J King <tking@guildhouse.dev>
This commit is contained in:
Tyler J King 2026-05-03 08:50:20 -04:00
parent f810537581
commit 88840ae620
6 changed files with 296 additions and 29 deletions

View file

@ -54,7 +54,7 @@ pub fn run_human_mode(
// Classify command: // Classify command:
match classify_command(line, &session.corpus_cid, &corpus_dir) { match classify_command(line, &session.corpus_cid, &corpus_dir) {
CommandClass::Free => { CommandClass::Free => {
execute_passthrough(line); execute_passthrough(line, session);
session.free_count += 1; session.free_count += 1;
} }
CommandClass::Governed { corpus_binary } => { CommandClass::Governed { corpus_binary } => {
@ -80,7 +80,7 @@ pub fn run_human_mode(
"{}", "{}",
format!(" ⚠ ungoverned: '{}' not in corpus", cmd_name).yellow() format!(" ⚠ ungoverned: '{}' not in corpus", cmd_name).yellow()
); );
execute_passthrough(line); execute_passthrough(line, session);
session.ungoverned_count += 1; session.ungoverned_count += 1;
} }
CommandClass::Denied { reason } => { CommandClass::Denied { reason } => {
@ -234,11 +234,11 @@ fn build_prompt(session: &SessionState) -> DefaultPrompt {
) )
} }
fn execute_passthrough(line: &str) -> i32 { fn execute_passthrough(line: &str, session: &SessionState) -> i32 {
let status = std::process::Command::new("sh") let mut cmd = std::process::Command::new("sh");
.arg("-c") cmd.arg("-c").arg(line);
.arg(line) session.apply_governance_env(&mut cmd);
.status(); let status = cmd.status();
match status { match status {
Ok(s) => s.code().unwrap_or(1), Ok(s) => s.code().unwrap_or(1),
Err(e) => { Err(e) => {
@ -250,11 +250,12 @@ fn execute_passthrough(line: &str) -> i32 {
fn execute_governed(line: &str, corpus_binary: &Path, session: &SessionState) -> i32 { fn execute_governed(line: &str, corpus_binary: &Path, session: &SessionState) -> i32 {
let args: Vec<&str> = line.split_whitespace().skip(1).collect(); let args: Vec<&str> = line.split_whitespace().skip(1).collect();
let status = std::process::Command::new(corpus_binary) let mut cmd = std::process::Command::new(corpus_binary);
.args(&args) cmd.args(&args)
.env("BASCULE_SESSION_ID", &session.ac_id) .env("BASCULE_SESSION_ID", &session.ac_id)
.env("BASCULE_CORPUS_CID", &session.corpus_cid) .env("BASCULE_CORPUS_CID", &session.corpus_cid);
.status(); session.apply_governance_env(&mut cmd);
let status = cmd.status();
match status { match status {
Ok(s) => s.code().unwrap_or(1), Ok(s) => s.code().unwrap_or(1),
Err(e) => { Err(e) => {

View file

@ -215,7 +215,11 @@ fn run(args: Args) -> Result<i32> {
// Determine AC mode: // Determine AC mode:
let pre_issued = args.ac.clone().or_else(|| std::env::var("GSAP_AC").ok()); 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 mut registry = ConsumedRegistry::default_location();
let ac = libgsh::ac::validate_ac(&ac_json, &corpus, &mut registry) let ac = libgsh::ac::validate_ac(&ac_json, &corpus, &mut registry)
.map_err(|e| anyhow::anyhow!("AC validation failed (exit {}): {}", e.exit_code(), e))?; .map_err(|e| anyhow::anyhow!("AC validation failed (exit {}): {}", e.exit_code(), e))?;
@ -223,9 +227,9 @@ fn run(args: Args) -> Result<i32> {
if let Some(ref p) = ac.principal { if let Some(ref p) = ac.principal {
if let Some(ref did) = p.did { eprintln!("gsh: principal — {}", did); } 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") { } else if let Ok(session_ac) = std::env::var("GSAP_SESSION_AC") {
(session_ac, "session".to_string()) (session_ac, "session".to_string(), None)
} else { } else {
let base = args.broker_url.clone() let base = args.broker_url.clone()
.or_else(|| std::env::var("GSAP_BROKER_URL").ok()) .or_else(|| std::env::var("GSAP_BROKER_URL").ok())
@ -235,7 +239,7 @@ fn run(args: Args) -> Result<i32> {
let id = request_ac_inline(&client, &base, &args.operation, &command_hash, &corpus) let id = request_ac_inline(&client, &base, &args.operation, &command_hash, &corpus)
.map_err(|e| anyhow::anyhow!(e))?; .map_err(|e| anyhow::anyhow!(e))?;
eprintln!("gsh: AC issued — {}", &id[..8.min(id.len())]); eprintln!("gsh: AC issued — {}", &id[..8.min(id.len())]);
(id, "inline".to_string()) (id, "inline".to_string(), None)
}; };
// Corpus gate: // Corpus gate:
@ -293,7 +297,12 @@ fn run(args: Args) -> Result<i32> {
} }
// Execute: // 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 exit_code = output.status.code().unwrap_or(1);
let stdout_str = String::from_utf8_lossy(&output.stdout).to_string(); let stdout_str = String::from_utf8_lossy(&output.stdout).to_string();
let stderr_str = String::from_utf8_lossy(&output.stderr).to_string(); let stderr_str = String::from_utf8_lossy(&output.stderr).to_string();

View file

@ -1,40 +1,59 @@
//! Authorization Context validation (R-22, R-23, R-24). //! Authorization Context validation (R-22, R-23, R-24).
use guildhouse_did::Did; use guildhouse_did::Did;
use serde::Deserialize; use serde::{Deserialize, Serialize};
use thiserror::Error; use thiserror::Error;
use crate::registry::ConsumedRegistry; use crate::registry::ConsumedRegistry;
/// A pre-issued Authorization Context from the broker. /// 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 struct AuthorizationContext {
pub context_id: String, pub context_id: String,
#[serde(default)] #[serde(default, skip_serializing_if = "Option::is_none")]
pub issued_at: Option<String>, pub issued_at: Option<String>,
#[serde(default)] #[serde(default, skip_serializing_if = "Option::is_none")]
pub expires_at: Option<String>, pub expires_at: Option<String>,
#[serde(default)] #[serde(default, skip_serializing_if = "Option::is_none")]
pub operation: Option<AcOperation>, pub operation: Option<AcOperation>,
#[serde(default)] #[serde(default, skip_serializing_if = "Option::is_none")]
pub principal: Option<AcPrincipal>, pub principal: Option<AcPrincipal>,
/// SHA-256 of the governing accord; threaded into `GSH_ACCORD_HASH`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub accord_hash: Option<String>,
/// `"Application"` | `"System"` (or future variants); threaded into `GSH_SHELL_CLASS`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shell_class: Option<String>,
/// Capability bitmask; threaded into `GSH_CAPABILITY_SET` as `0x{:08x}`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub capability_set: Option<u32>,
/// Posture / DEFCON level (1..=5); threaded into `GSH_POSTURE_LEVEL`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub posture_level: Option<u8>,
} }
#[derive(Deserialize, Debug, Clone, Default)] #[derive(Serialize, Deserialize, Debug, Clone, Default)]
pub struct AcOperation { pub struct AcOperation {
#[serde(default)] #[serde(default, skip_serializing_if = "Option::is_none")]
pub corpus_entry_cid: Option<String>, pub corpus_entry_cid: Option<String>,
#[serde(default)] #[serde(default, skip_serializing_if = "Option::is_none")]
pub parameters_cid: Option<String>, pub parameters_cid: Option<String>,
#[serde(default)] #[serde(default, skip_serializing_if = "Option::is_none")]
pub playbook: Option<String>, pub playbook: Option<String>,
} }
#[derive(Deserialize, Debug, Clone, Default)] #[derive(Serialize, Deserialize, Debug, Clone, Default)]
pub struct AcPrincipal { pub struct AcPrincipal {
#[serde(default)] #[serde(default, skip_serializing_if = "Option::is_none")]
pub did: Option<Did>, pub did: Option<Did>,
#[serde(default)] #[serde(default, skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>, pub display_name: Option<String>,
} }
@ -172,4 +191,49 @@ mod tests {
Err(AcValidationError::MissingContextId) 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"));
}
} }

View file

@ -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<u8>,
capability_set: Option<u32>,
) {
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<String> {
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());
}
}

View file

@ -4,6 +4,7 @@ pub mod classifier;
pub mod config; pub mod config;
pub mod corpus; pub mod corpus;
pub mod cr; pub mod cr;
pub mod governance_env;
pub mod registry; pub mod registry;
pub mod session; pub mod session;

View file

@ -17,6 +17,13 @@ pub struct SessionState {
pub free_count: u32, pub free_count: u32,
pub ungoverned_count: u32, pub ungoverned_count: u32,
pub denied_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<String>,
pub shell_class: Option<String>,
pub capability_set: Option<u32>,
pub posture_level: Option<u8>,
} }
impl SessionState { impl SessionState {
@ -64,6 +71,10 @@ impl SessionState {
free_count: 0, free_count: 0,
ungoverned_count: 0, ungoverned_count: 0,
denied_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, free_count: 0,
ungoverned_count: 0, ungoverned_count: 0,
denied_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 { pub fn minutes_remaining(&self) -> i64 {
match &self.expires_at { match &self.expires_at {
Some(exp) => (*exp - chrono::Utc::now()).num_minutes(), Some(exp) => (*exp - chrono::Utc::now()).num_minutes(),