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:
parent
f810537581
commit
88840ae620
6 changed files with 296 additions and 29 deletions
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
159
libgsh/src/governance_env.rs
Normal file
159
libgsh/src/governance_env.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue