refactor: extract libgsh from monolith

Phase 2 of the WSL2 jumphost build.

Workspace: gsh/ (binary) + libgsh/ (library).

libgsh modules:
  ac.rs       — AC validation (R-22 single-use, R-23 corpus match, expiry)
  cr.rs       — CR construction + broker posting + inline AC request
  corpus.rs   — Corpus directory gate (killswitch)
  config.rs   — GshConfig from environment
  registry.rs — Filesystem-based consumed AC registry

gsh/src/main.rs: CLI only (~170 lines).
  Clap args, mode detection, calls libgsh, formats output.

11 unit tests in libgsh:
  ac: valid AC, expired, corpus mismatch, replay, missing context_id
  cr: broker URL formatting
  corpus: ungoverned skip, missing dir, command name extraction
  registry: consume and check
  config: default corpus_cid

Zero behavior change. Same JSON output, same exit codes,
same flags, same env vars, same broker interaction.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tyler J King 2026-04-02 09:31:50 -04:00
parent af11a797ee
commit 919d8accde
12 changed files with 909 additions and 674 deletions

46
Cargo.lock generated
View file

@ -435,14 +435,10 @@ name = "gsh"
version = "0.1.0"
dependencies = [
"anyhow",
"chrono",
"clap",
"dirs",
"hex",
"reqwest",
"libgsh",
"serde",
"serde_json",
"sha2",
"uuid",
]
@ -806,6 +802,21 @@ version = "0.2.184"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af"
[[package]]
name = "libgsh"
version = "0.1.0"
dependencies = [
"chrono",
"dirs",
"hex",
"reqwest",
"serde",
"serde_json",
"sha2",
"tempfile",
"thiserror 2.0.18",
]
[[package]]
name = "libredox"
version = "0.1.15"
@ -1013,7 +1024,7 @@ checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
dependencies = [
"getrandom 0.2.17",
"libredox",
"thiserror",
"thiserror 1.0.69",
]
[[package]]
@ -1351,7 +1362,16 @@ version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl",
"thiserror-impl 1.0.69",
]
[[package]]
name = "thiserror"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
"thiserror-impl 2.0.18",
]
[[package]]
@ -1365,6 +1385,17 @@ dependencies = [
"syn",
]
[[package]]
name = "thiserror-impl"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tinystr"
version = "0.8.3"
@ -1548,7 +1579,6 @@ checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9"
dependencies = [
"getrandom 0.4.2",
"js-sys",
"serde_core",
"wasm-bindgen",
]

View file

@ -1,21 +1,22 @@
[package]
name = "gsh"
[workspace]
members = ["libgsh", "gsh"]
resolver = "2"
[workspace.package]
version = "0.1.0"
edition = "2021"
description = "Governed shell — GCAP-SPEC-SHELLBOUND-SDK-0001"
license = "MIT OR Apache-2.0"
repository = "https://git.guildhouse.dev/guildhouse/gsh"
[[bin]]
name = "gsh"
path = "src/main.rs"
[dependencies]
reqwest = { version = "0.12", features = ["json", "blocking"] }
[workspace.dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
reqwest = { version = "0.12", features = ["blocking", "json"] }
thiserror = "2"
anyhow = "1"
clap = { version = "4", features = ["derive"] }
sha2 = "0.10"
hex = "0.4"
uuid = { version = "1", features = ["v4", "serde"] }
anyhow = "1"
uuid = { version = "1", features = ["v4"] }
chrono = "0.4"
dirs = "5"

17
gsh/Cargo.toml Normal file
View file

@ -0,0 +1,17 @@
[package]
name = "gsh"
version.workspace = true
edition.workspace = true
description = "Governed shell — GCAP-SPEC-SHELLBOUND-SDK-0001"
[[bin]]
name = "gsh"
path = "src/main.rs"
[dependencies]
libgsh = { path = "../libgsh" }
clap = { workspace = true }
anyhow = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
uuid = { workspace = true }

255
gsh/src/main.rs Normal file
View file

@ -0,0 +1,255 @@
//! gsh — Governed Shell CLI
//!
//! Thin CLI wrapper around libgsh. Handles arg parsing, mode detection,
//! output formatting, and exit code mapping. All governance logic is in libgsh.
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use serde::Serialize;
use std::process;
use uuid::Uuid;
use libgsh::cr::{build_client, post_cr, request_ac_inline};
use libgsh::registry::ConsumedRegistry;
use libgsh::{corpus_check, sha256_hash};
// ── CLI ───────────────────────────────────────────────────────
#[derive(Parser)]
#[command(name = "gsh", about = "Governed shell — GCAP machine mode")]
struct Args {
#[command(subcommand)]
command: Option<Cmd>,
#[arg(long, short = 'e', global = true)]
exec: Option<String>,
#[arg(long, global = true)]
ac: Option<String>,
#[arg(long, global = true)]
ungoverned: bool,
#[arg(long, global = true)]
broker_url: Option<String>,
#[arg(long, global = true)]
agent_did: Option<String>,
#[arg(long, default_value = "shell:exec", global = true)]
operation: String,
#[arg(long, global = true)]
json: bool,
#[arg(long, global = true)]
dry_run: bool,
}
#[derive(Subcommand)]
enum Cmd {
SessionStart {
#[arg(long, default_value = "shell:session")]
scope: String,
},
SessionEnd,
SessionStatus,
}
#[derive(Serialize)]
struct GshOutput {
exit_code: i32,
stdout: String,
stderr: String,
ac_id: String,
ac_mode: String,
cr_id: String,
chronicle_cid: String,
command_hash: String,
run_id: String,
corpus_cid: String,
}
// ── Main ─────────────────────────────────────────────────────
fn main() {
let args = Args::parse();
match run(args) {
Ok(code) => process::exit(code),
Err(e) => {
let msg = format!("{:#}", e);
eprintln!("gsh: {}", msg);
if msg.contains("exit 2") {
process::exit(2);
} else if msg.contains("exit 3") {
process::exit(3);
} else {
process::exit(125);
}
}
}
}
fn run(args: Args) -> Result<i32> {
let corpus = std::env::var("GSAP_CORPUS_CID").unwrap_or_else(|_| "sha256:ungoverned".into());
let token = std::env::var("GSAP_TOKEN").ok();
// ── Ungoverned mode ──────────────────────────────────────
if args.ungoverned {
let exec = args.exec.as_ref().context("--ungoverned requires --exec")?;
let output = process::Command::new("sh").arg("-c").arg(exec).output().context("exec failed")?;
let code = output.status.code().unwrap_or(1);
if args.json {
println!("{}", serde_json::to_string_pretty(&GshOutput {
exit_code: code,
stdout: String::from_utf8_lossy(&output.stdout).into(),
stderr: String::from_utf8_lossy(&output.stderr).into(),
ac_id: String::new(), ac_mode: "ungoverned".into(),
cr_id: String::new(), chronicle_cid: String::new(),
command_hash: sha256_hash(exec.as_bytes()),
run_id: Uuid::new_v4().to_string(), corpus_cid: corpus,
})?);
} else {
print!("{}", String::from_utf8_lossy(&output.stdout));
eprint!("{}", String::from_utf8_lossy(&output.stderr));
}
return Ok(code);
}
// ── Session subcommands ──────────────────────────────────
if let Some(cmd) = &args.command {
let base = args.broker_url.clone()
.or_else(|| std::env::var("GSAP_BROKER_URL").ok())
.context("GSAP_BROKER_URL not set")?;
let client = build_client(&token).map_err(|e| anyhow::anyhow!(e))?;
return match cmd {
Cmd::SessionStart { scope } => {
let hash = sha256_hash(format!("session:{}", scope).as_bytes());
eprintln!("gsh: starting session (scope: {})", scope);
let ac_id = request_ac_inline(&client, &base, scope, &hash, &corpus)
.map_err(|e| anyhow::anyhow!(e))?;
eprintln!("gsh: session AC — {}", &ac_id[..8.min(ac_id.len())]);
println!("export GSAP_SESSION_AC=\"{}\";", ac_id);
println!("export GSAP_SESSION_ID=\"{}\";", Uuid::new_v4());
println!("export GSAP_SESSION_SCOPE=\"{}\";", scope);
Ok(0)
}
Cmd::SessionEnd => {
let ac_id = std::env::var("GSAP_SESSION_AC")
.context("No session active (GSAP_SESSION_AC not set)")?;
let cr = post_cr(&client, &base, &ac_id, "completed");
eprintln!("gsh: session closed");
if !cr.chronicle_cid.is_empty() {
eprintln!("gsh: session CID {}", &cr.chronicle_cid[..40.min(cr.chronicle_cid.len())]);
}
println!("unset GSAP_SESSION_AC GSAP_SESSION_ID GSAP_SESSION_SCOPE;");
Ok(0)
}
Cmd::SessionStatus => {
match std::env::var("GSAP_SESSION_AC") {
Ok(ac) => {
println!("Session active: {}", &ac[..8.min(ac.len())]);
if let Ok(sid) = std::env::var("GSAP_SESSION_ID") { println!("Session ID: {}", sid); }
if let Ok(scope) = std::env::var("GSAP_SESSION_SCOPE") { println!("Scope: {}", scope); }
println!("Corpus: {}", corpus);
}
Err(_) => println!("No session active.\nStart: eval \"$(gsh session-start)\""),
}
Ok(0)
}
};
}
// ── Exec mode ────────────────────────────────────────────
let exec = args.exec.as_ref().context("Provide --exec 'command' or a subcommand")?;
let run_id = Uuid::new_v4().to_string();
let command_hash = sha256_hash(exec.as_bytes());
// 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 {
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))?;
eprintln!("gsh: pre-issued AC — {}", &ac.context_id[..8.min(ac.context_id.len())]);
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())
} else if let Ok(session_ac) = std::env::var("GSAP_SESSION_AC") {
(session_ac, "session".to_string())
} else {
let base = args.broker_url.clone()
.or_else(|| std::env::var("GSAP_BROKER_URL").ok())
.context("No AC provided. Set GSAP_AC, GSAP_SESSION_AC, or GSAP_BROKER_URL")?;
let client = build_client(&token).map_err(|e| anyhow::anyhow!(e))?;
eprintln!("gsh: requesting AC for '{}'", exec);
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())
};
// Corpus gate:
match corpus_check(&corpus, exec) {
libgsh::CorpusCheckResult::Denied { command, corpus_cid } => {
eprintln!("gsh: command '{}' not in corpus {} (killswitch active)", command, corpus_cid);
return Ok(3);
}
libgsh::CorpusCheckResult::NotMounted => {
eprintln!("gsh: corpus directory not found (host may not have corpus mounted)");
}
_ => {}
}
if args.dry_run {
eprintln!("gsh: dry-run — skipping exec");
if args.json {
println!("{}", serde_json::to_string_pretty(&GshOutput {
exit_code: 0, stdout: String::new(), stderr: String::new(),
ac_id, ac_mode, cr_id: String::new(), chronicle_cid: String::new(),
command_hash, run_id, corpus_cid: corpus,
})?);
}
return Ok(0);
}
// Execute:
let output = process::Command::new("sh").arg("-c").arg(exec).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();
// Post CR:
let outcome = if exit_code == 0 { "completed" } else { "failed" };
let base = args.broker_url.or_else(|| std::env::var("GSAP_BROKER_URL").ok());
let (cr_id, chronicle_cid) = if let Some(base) = base {
let client = build_client(&token).map_err(|e| anyhow::anyhow!(e))?;
let cr = post_cr(&client, &base, &ac_id, outcome);
if ac_mode == "session" && cr.receipt_id.is_empty() && cr.chronicle_cid.is_empty() {
eprintln!("gsh: session CR not recorded (broker session support pending)");
}
(cr.receipt_id, cr.chronicle_cid)
} else {
eprintln!("gsh: no GSAP_BROKER_URL — CR not posted");
(String::new(), String::new())
};
// Output:
if args.json {
println!("{}", serde_json::to_string_pretty(&GshOutput {
exit_code, stdout: stdout_str, stderr: stderr_str,
ac_id, ac_mode, cr_id, chronicle_cid, command_hash, run_id, corpus_cid: corpus,
})?);
} else {
print!("{}", stdout_str);
eprint!("{}", stderr_str);
if !chronicle_cid.is_empty() {
eprintln!("gsh: CID {}", &chronicle_cid[..40.min(chronicle_cid.len())]);
}
}
Ok(exit_code)
}

18
libgsh/Cargo.toml Normal file
View file

@ -0,0 +1,18 @@
[package]
name = "libgsh"
version.workspace = true
edition.workspace = true
description = "Governed shell library — AC validation, CR building, corpus gate"
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
reqwest = { workspace = true }
thiserror = { workspace = true }
sha2 = { workspace = true }
hex = { workspace = true }
chrono = { workspace = true }
dirs = { workspace = true }
[dev-dependencies]
tempfile = "3"

174
libgsh/src/ac.rs Normal file
View file

@ -0,0 +1,174 @@
//! Authorization Context validation (R-22, R-23, R-24).
use serde::Deserialize;
use thiserror::Error;
use crate::registry::ConsumedRegistry;
/// A pre-issued Authorization Context from the broker.
#[derive(Deserialize, Debug, Clone)]
pub struct AuthorizationContext {
pub context_id: String,
#[serde(default)]
pub issued_at: Option<String>,
#[serde(default)]
pub expires_at: Option<String>,
#[serde(default)]
pub operation: Option<AcOperation>,
#[serde(default)]
pub principal: Option<AcPrincipal>,
}
#[derive(Deserialize, Debug, Clone, Default)]
pub struct AcOperation {
#[serde(default)]
pub corpus_entry_cid: Option<String>,
#[serde(default)]
pub parameters_cid: Option<String>,
#[serde(default)]
pub playbook: Option<String>,
}
#[derive(Deserialize, Debug, Clone, Default)]
pub struct AcPrincipal {
#[serde(default)]
pub did: Option<String>,
#[serde(default)]
pub display_name: Option<String>,
}
#[derive(Debug, Error)]
pub enum AcValidationError {
#[error("AC parse error: {0}")]
ParseError(String),
#[error("AC missing context_id")]
MissingContextId,
#[error("AC expired at {0}")]
Expired(String),
#[error("corpus mismatch — AC expects {ac_corpus}, running in {actual_corpus}")]
CorpusMismatch {
ac_corpus: String,
actual_corpus: String,
},
#[error("AC already consumed (replay detected): {0}")]
Replay(String),
}
impl AcValidationError {
/// Map validation error to exit code.
pub fn exit_code(&self) -> i32 {
match self {
Self::CorpusMismatch { .. } => 3, // governance violation
_ => 2, // auth failure
}
}
}
/// Parse and validate a pre-issued AC.
///
/// Checks: JSON parse, context_id present, expiry, corpus match (R-23),
/// single-use (R-22) via registry.
pub fn validate_ac(
ac_json: &str,
corpus_cid: &str,
registry: &mut ConsumedRegistry,
) -> Result<AuthorizationContext, AcValidationError> {
// Parse:
let ac: AuthorizationContext =
serde_json::from_str(ac_json).map_err(|e| AcValidationError::ParseError(e.to_string()))?;
// Must have context_id:
if ac.context_id.is_empty() {
return Err(AcValidationError::MissingContextId);
}
// R-23: corpus_entry_cid must match:
if let Some(ref op) = ac.operation {
if let Some(ref ac_corpus) = op.corpus_entry_cid {
if !ac_corpus.is_empty()
&& ac_corpus != "sha256:ungoverned"
&& corpus_cid != "sha256:ungoverned"
&& ac_corpus != corpus_cid
{
return Err(AcValidationError::CorpusMismatch {
ac_corpus: ac_corpus.clone(),
actual_corpus: corpus_cid.to_string(),
});
}
}
}
// Expiry:
if let Some(ref expires) = ac.expires_at {
if let Ok(exp) = chrono::DateTime::parse_from_rfc3339(expires) {
if exp < chrono::Utc::now() {
return Err(AcValidationError::Expired(expires.clone()));
}
}
}
// R-22: single-use:
if registry.is_consumed(&ac.context_id) {
return Err(AcValidationError::Replay(ac.context_id.clone()));
}
registry.mark_consumed(&ac.context_id);
Ok(ac)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::registry::ConsumedRegistry;
fn temp_registry() -> ConsumedRegistry {
let dir = tempfile::tempdir().unwrap();
ConsumedRegistry::new(dir.into_path())
}
#[test]
fn test_valid_ac() {
let mut reg = temp_registry();
let ac_json = r#"{"context_id":"test-123","expires_at":"2099-01-01T00:00:00Z","operation":{"corpus_entry_cid":"sha256:ungoverned"}}"#;
let result = validate_ac(ac_json, "sha256:ungoverned", &mut reg);
assert!(result.is_ok());
assert_eq!(result.unwrap().context_id, "test-123");
}
#[test]
fn test_expired_ac() {
let mut reg = temp_registry();
let ac_json = r#"{"context_id":"expired","expires_at":"2020-01-01T00:00:00Z"}"#;
let result = validate_ac(ac_json, "sha256:ungoverned", &mut reg);
assert!(matches!(result, Err(AcValidationError::Expired(_))));
}
#[test]
fn test_corpus_mismatch() {
let mut reg = temp_registry();
let ac_json = r#"{"context_id":"mismatch","operation":{"corpus_entry_cid":"sha256:abc"}}"#;
let result = validate_ac(ac_json, "sha256:xyz", &mut reg);
assert!(matches!(result, Err(AcValidationError::CorpusMismatch { .. })));
}
#[test]
fn test_replay_detection() {
let mut reg = temp_registry();
let ac_json = r#"{"context_id":"replay-test","expires_at":"2099-01-01T00:00:00Z"}"#;
assert!(validate_ac(ac_json, "sha256:ungoverned", &mut reg).is_ok());
assert!(matches!(
validate_ac(ac_json, "sha256:ungoverned", &mut reg),
Err(AcValidationError::Replay(_))
));
}
#[test]
fn test_missing_context_id() {
let mut reg = temp_registry();
let ac_json = r#"{"context_id":""}"#;
assert!(matches!(
validate_ac(ac_json, "sha256:ungoverned", &mut reg),
Err(AcValidationError::MissingContextId)
));
}
}

54
libgsh/src/config.rs Normal file
View file

@ -0,0 +1,54 @@
//! Configuration from environment variables.
/// Consolidated gsh configuration from env vars.
pub struct GshConfig {
/// Pre-issued AC JSON string.
pub ac: Option<String>,
/// GSAP broker URL.
pub broker_url: Option<String>,
/// Agent DID.
pub agent_did: Option<String>,
/// Bearer auth token.
pub token: Option<String>,
/// Corpus CID this session is authorized for.
pub corpus_cid: String,
/// Session AC (from session-start).
pub session_ac: Option<String>,
/// Session ID.
pub session_id: Option<String>,
/// Session scope.
pub session_scope: Option<String>,
/// Chronicle session ID (from eBPF companion).
pub chronicle_session_id: Option<String>,
}
impl GshConfig {
/// Read configuration from environment variables.
pub fn from_env() -> Self {
Self {
ac: std::env::var("GSAP_AC").ok(),
broker_url: std::env::var("GSAP_BROKER_URL").ok(),
agent_did: std::env::var("GSAP_AGENT_DID").ok(),
token: std::env::var("GSAP_TOKEN").ok(),
corpus_cid: std::env::var("GSAP_CORPUS_CID")
.unwrap_or_else(|_| "sha256:ungoverned".into()),
session_ac: std::env::var("GSAP_SESSION_AC").ok(),
session_id: std::env::var("GSAP_SESSION_ID").ok(),
session_scope: std::env::var("GSAP_SESSION_SCOPE").ok(),
chronicle_session_id: std::env::var("CHRONICLE_SESSION_ID").ok(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_corpus_cid() {
// Clear any existing env:
std::env::remove_var("GSAP_CORPUS_CID");
let cfg = GshConfig::from_env();
assert_eq!(cfg.corpus_cid, "sha256:ungoverned");
}
}

82
libgsh/src/corpus.rs Normal file
View file

@ -0,0 +1,82 @@
//! Corpus directory gate — the live killswitch.
use std::path::Path;
/// Result of a corpus check.
#[derive(Debug)]
pub enum CorpusCheckResult {
/// Binary found in corpus — allowed.
Allowed,
/// Corpus is ungoverned — no check performed.
Ungoverned,
/// Corpus directory not found on this host (not an error, host may not have it mounted).
NotMounted,
/// Binary not in corpus directory — denied (killswitch active).
Denied { command: String, corpus_cid: String },
}
/// Check if a command is authorized in the corpus directory.
///
/// Returns Ok(result) always. Caller decides whether to block on Denied.
pub fn corpus_check(corpus_cid: &str, command: &str) -> CorpusCheckResult {
if corpus_cid == "sha256:ungoverned" {
return CorpusCheckResult::Ungoverned;
}
let corpus_dir = Path::new("/opt/substrate/corpus").join(corpus_cid);
if !corpus_dir.exists() {
return CorpusCheckResult::NotMounted;
}
// Extract command name (first word, basename only):
let cmd_name = command.split_whitespace().next().unwrap_or(command);
let cmd_name = Path::new(cmd_name)
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| cmd_name.to_string());
if corpus_dir.join(&cmd_name).exists() {
CorpusCheckResult::Allowed
} else {
CorpusCheckResult::Denied {
command: cmd_name,
corpus_cid: corpus_cid.to_string(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ungoverned_skips_check() {
assert!(matches!(
corpus_check("sha256:ungoverned", "anything"),
CorpusCheckResult::Ungoverned
));
}
#[test]
fn test_missing_corpus_dir() {
assert!(matches!(
corpus_check("sha256:nonexistent", "kubectl"),
CorpusCheckResult::NotMounted
));
}
#[test]
fn test_corpus_with_real_dir() {
let dir = tempfile::tempdir().unwrap();
let cid = "sha256:test-corpus";
let corpus_dir = dir.path().join(cid);
std::fs::create_dir_all(&corpus_dir).unwrap();
std::fs::write(corpus_dir.join("kubectl"), "").unwrap();
// Can't easily test with /opt/substrate/corpus, but the logic is straightforward.
// The unit test validates the command name extraction:
let cmd = "kubectl get pods -n test";
let name = cmd.split_whitespace().next().unwrap();
assert_eq!(name, "kubectl");
}
}

194
libgsh/src/cr.rs Normal file
View file

@ -0,0 +1,194 @@
//! Completion Receipt construction and posting.
use reqwest::blocking::Client;
use serde::{Deserialize, Serialize};
use std::time::Duration;
#[derive(Serialize)]
pub struct CrRequest {
pub context_id: String,
pub outcome: String,
pub completed_at: String,
pub chronicle_evidence: CrEvidence,
pub behavioral_attestation: CrAttestation,
pub ffc: serde_json::Value,
pub signature: serde_json::Value,
}
#[derive(Serialize)]
pub struct CrEvidence {
pub events: Vec<String>,
pub merkle_root: String,
}
#[derive(Serialize)]
pub struct CrAttestation {
pub status: String,
}
#[derive(Deserialize)]
pub struct CrResponse {
pub receipt_id: Option<String>,
pub chronicle_event_cid: Option<String>,
}
/// Result of posting a CR.
pub struct CrResult {
pub receipt_id: String,
pub chronicle_cid: String,
}
/// Build an HTTP client with optional bearer token.
pub fn build_client(token: &Option<String>) -> Result<Client, String> {
let mut headers = reqwest::header::HeaderMap::new();
if let Some(tok) = token {
headers.insert(
"Authorization",
format!("Bearer {}", tok)
.parse()
.map_err(|_| "Invalid token".to_string())?,
);
}
Client::builder()
.timeout(Duration::from_secs(30))
.default_headers(headers)
.build()
.map_err(|e| format!("HTTP client failed: {}", e))
}
/// Format a broker URL path.
pub fn broker_url(base: &str, path: &str) -> String {
format!(
"{}/{}",
base.trim_end_matches('/'),
path.trim_start_matches('/')
)
}
/// Post a Completion Receipt to the broker.
pub fn post_cr(client: &Client, base: &str, ac_id: &str, outcome: &str) -> CrResult {
let now = chrono::Utc::now().to_rfc3339();
let session_id = std::env::var("CHRONICLE_SESSION_ID").unwrap_or_default();
match client
.post(broker_url(base, "governance/complete/"))
.json(&CrRequest {
context_id: ac_id.into(),
outcome: outcome.into(),
completed_at: now,
chronicle_evidence: CrEvidence {
events: vec![],
merkle_root: String::new(),
},
behavioral_attestation: CrAttestation {
status: "unavailable".into(),
},
ffc: serde_json::json!({"did": "did:web:guildhouse.dev", "chronicle_session_id": session_id}),
signature: serde_json::json!({"value": "gsh"}),
})
.send()
{
Ok(r) if r.status().is_success() => {
let cr: CrResponse = r.json().unwrap_or(CrResponse {
receipt_id: None,
chronicle_event_cid: None,
});
CrResult {
receipt_id: cr.receipt_id.unwrap_or_default(),
chronicle_cid: cr.chronicle_event_cid.unwrap_or_default(),
}
}
Ok(r) => {
eprintln!("gsh: CR failed: {}", r.status());
CrResult {
receipt_id: String::new(),
chronicle_cid: String::new(),
}
}
Err(e) => {
eprintln!("gsh: CR error: {}", e);
CrResult {
receipt_id: String::new(),
chronicle_cid: String::new(),
}
}
}
}
/// Request an inline AC from the broker (fallback mode).
pub fn request_ac_inline(
client: &Client,
base: &str,
operation: &str,
command_hash: &str,
corpus_cid: &str,
) -> Result<String, String> {
#[derive(Serialize)]
struct AcRequest {
driver_id: String,
playbook: String,
corpus_entry_cid: String,
parameters_cid: String,
accord_template: String,
}
#[derive(Deserialize)]
struct AcResponse {
authorization_context: Option<AcCtx>,
}
#[derive(Deserialize)]
struct AcCtx {
context_id: String,
}
let resp = client
.post(broker_url(base, "governance/authorize/"))
.json(&AcRequest {
driver_id: "keycloak".into(),
playbook: format!(
"{}:{}",
operation,
&command_hash[..20.min(command_hash.len())]
),
corpus_entry_cid: corpus_cid.into(),
parameters_cid: command_hash.into(),
accord_template: "shell-exec".into(),
})
.send()
.map_err(|e| format!("Failed to reach broker: {}", e))?;
if !resp.status().is_success() {
return Err(format!(
"AC denied: {} — {}",
resp.status(),
resp.text().unwrap_or_default()
));
}
let ac: AcResponse = resp
.json()
.map_err(|e| format!("Invalid AC response: {}", e))?;
ac.authorization_context
.map(|c| c.context_id)
.filter(|id| !id.is_empty())
.ok_or_else(|| "No AC ID returned".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_broker_url() {
assert_eq!(
broker_url("http://localhost:8000", "governance/complete/"),
"http://localhost:8000/governance/complete/"
);
assert_eq!(
broker_url("http://localhost:8000/", "/governance/complete/"),
"http://localhost:8000/governance/complete/"
);
}
}

17
libgsh/src/lib.rs Normal file
View file

@ -0,0 +1,17 @@
pub mod ac;
pub mod config;
pub mod corpus;
pub mod cr;
pub mod registry;
pub use ac::{AcValidationError, AuthorizationContext};
pub use config::GshConfig;
pub use corpus::{corpus_check, CorpusCheckResult};
pub use cr::{post_cr, CrResult};
pub use registry::ConsumedRegistry;
/// Compute SHA-256 hash with "sha256:" prefix.
pub fn sha256_hash(data: &[u8]) -> String {
use sha2::{Digest, Sha256};
format!("sha256:{}", hex::encode(Sha256::digest(data)))
}

48
libgsh/src/registry.rs Normal file
View file

@ -0,0 +1,48 @@
//! Single-use AC context registry (R-22).
//! Filesystem-based: ~/.gsh/consumed/{context_id}
use std::path::PathBuf;
/// Tracks consumed AC context IDs on the filesystem.
pub struct ConsumedRegistry {
dir: PathBuf,
}
impl ConsumedRegistry {
pub fn new(dir: PathBuf) -> Self {
Self { dir }
}
/// Default registry location: ~/.gsh/consumed/
pub fn default_location() -> Self {
let dir = dirs::home_dir()
.map(|h| h.join(".gsh").join("consumed"))
.unwrap_or_else(|| PathBuf::from("/tmp/.gsh/consumed"));
Self { dir }
}
pub fn is_consumed(&self, context_id: &str) -> bool {
self.dir.join(context_id).exists()
}
pub fn mark_consumed(&mut self, context_id: &str) {
let _ = std::fs::create_dir_all(&self.dir);
let _ = std::fs::write(self.dir.join(context_id), "");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_consume_and_check() {
let dir = tempfile::tempdir().unwrap();
let mut reg = ConsumedRegistry::new(dir.path().to_path_buf());
assert!(!reg.is_consumed("ac-123"));
reg.mark_consumed("ac-123");
assert!(reg.is_consumed("ac-123"));
assert!(!reg.is_consumed("ac-456"));
}
}

View file

@ -1,655 +0,0 @@
//! gsh — Governed Shell
//!
//! GCAP-SPEC-SHELLBOUND-SDK-0001
//!
//! Three execution models:
//!
//! 1. Pre-issued AC (target model):
//! GSAP_AC='{"context_id":...}' gsh --exec "command"
//! Caller provides AC. gsh validates, executes, posts CR.
//! Used by: Bascule, SK plugin, CI/CD.
//!
//! 2. Inline AC request (backward compat):
//! GSAP_BROKER_URL=... gsh --exec "command"
//! gsh requests AC from broker. Fallback when no GSAP_AC.
//!
//! 3. Ungoverned (dev mode):
//! gsh --ungoverned --exec "command"
//! No AC, no CR, no corpus check. Just executes.
//!
//! Session mode works with both pre-issued and inline ACs.
//!
//! Environment:
//! GSAP_AC pre-issued AC JSON (preferred)
//! GSAP_BROKER_URL broker URL (for inline AC + CR posting)
//! GSAP_AGENT_DID agent DID (for inline AC requests)
//! GSAP_TOKEN Bearer auth token
//! GSAP_CORPUS_CID corpus this session is authorized for
//! GSAP_SESSION_AC set by session-start
//! GSAP_SESSION_ID set by session-start
//!
//! Exit codes:
//! 0 = command succeeded
//! 1 = command failed (non-zero exit)
//! 2 = authorization failure (AC invalid/expired/missing)
//! 3 = governance violation (corpus mismatch, command denied)
//! 125 = gsh internal error
use anyhow::{bail, Context, Result};
use clap::{Parser, Subcommand};
use reqwest::blocking::Client;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::path::Path;
use std::process;
use std::time::Duration;
use uuid::Uuid;
// ── CLI ───────────────────────────────────────────────────────
#[derive(Parser)]
#[command(name = "gsh", about = "Governed shell — GCAP machine mode")]
struct Args {
#[command(subcommand)]
command: Option<Cmd>,
/// Command to execute (via sh -c)
#[arg(long, short = 'e', global = true)]
exec: Option<String>,
/// Pre-issued AC JSON (alternative to GSAP_AC env)
#[arg(long, global = true)]
ac: Option<String>,
/// Run without governance (no AC, no CR, no corpus check)
#[arg(long, global = true)]
ungoverned: bool,
#[arg(long, global = true)]
broker_url: Option<String>,
#[arg(long, global = true)]
agent_did: Option<String>,
#[arg(long, default_value = "shell:exec", global = true)]
operation: String,
#[arg(long, global = true)]
json: bool,
#[arg(long, global = true)]
dry_run: bool,
}
#[derive(Subcommand)]
enum Cmd {
/// Start a governed session (one AC for many commands)
SessionStart {
#[arg(long, default_value = "shell:session")]
scope: String,
},
/// End a governed session
SessionEnd,
/// Show session status
SessionStatus,
}
// ── Pre-issued AC types ──────────────────────────────────────
#[derive(Deserialize, Debug)]
struct PreIssuedAc {
context_id: String,
#[serde(default)]
issued_at: Option<String>,
#[serde(default)]
expires_at: Option<String>,
#[serde(default)]
operation: Option<AcOperation>,
#[serde(default)]
principal: Option<AcPrincipal>,
}
#[derive(Deserialize, Debug, Default)]
struct AcOperation {
#[serde(default)]
corpus_entry_cid: Option<String>,
#[serde(default)]
parameters_cid: Option<String>,
#[serde(default)]
playbook: Option<String>,
}
#[derive(Deserialize, Debug, Default)]
struct AcPrincipal {
#[serde(default)]
did: Option<String>,
}
// ── Inline AC request types (broker format) ──────────────────
#[derive(Serialize)]
struct AcRequest {
driver_id: String,
playbook: String,
corpus_entry_cid: String,
parameters_cid: String,
accord_template: String,
}
#[derive(Deserialize)]
struct AcResponse {
#[allow(dead_code)]
status: Option<String>,
authorization_context: Option<AcResponseCtx>,
}
#[derive(Deserialize)]
struct AcResponseCtx {
context_id: String,
}
// ── CR types ─────────────────────────────────────────────────
#[derive(Serialize)]
struct CrRequest {
context_id: String,
outcome: String,
completed_at: String,
chronicle_evidence: CrEvidence,
behavioral_attestation: CrAttestation,
ffc: serde_json::Value,
signature: serde_json::Value,
}
#[derive(Serialize)]
struct CrEvidence {
events: Vec<String>,
merkle_root: String,
}
#[derive(Serialize)]
struct CrAttestation {
status: String,
}
#[derive(Deserialize)]
struct CrResponse {
receipt_id: Option<String>,
chronicle_event_cid: Option<String>,
}
// ── Output ───────────────────────────────────────────────────
#[derive(Serialize)]
struct GshOutput {
exit_code: i32,
stdout: String,
stderr: String,
ac_id: String,
ac_mode: String, // "pre-issued", "inline", "session", "ungoverned"
cr_id: String,
chronicle_cid: String,
command_hash: String,
run_id: String,
corpus_cid: String,
}
// ── Helpers ──────────────────────────────────────────────────
fn sha256_hash(data: &[u8]) -> String {
format!("sha256:{}", hex::encode(Sha256::digest(data)))
}
fn build_client(token: &Option<String>) -> Result<Client> {
let mut headers = reqwest::header::HeaderMap::new();
if let Some(tok) = token {
headers.insert(
"Authorization",
format!("Bearer {}", tok).parse().context("Invalid token")?,
);
}
Client::builder()
.timeout(Duration::from_secs(30))
.default_headers(headers)
.build()
.context("HTTP client failed")
}
fn broker_url(base: &str, path: &str) -> String {
format!(
"{}/{}",
base.trim_end_matches('/'),
path.trim_start_matches('/')
)
}
// ── AC Validation (R-22, R-23, R-24) ────────────────────────
fn validate_pre_issued_ac(ac_json: &str, corpus_cid: &str) -> Result<PreIssuedAc, i32> {
// Parse:
let ac: PreIssuedAc = serde_json::from_str(ac_json).map_err(|e| {
eprintln!("gsh: AC parse error: {}", e);
2 // auth failure
})?;
// Must have context_id:
if ac.context_id.is_empty() {
eprintln!("gsh: AC missing context_id");
return Err(2);
}
// R-23: corpus_entry_cid must match (if specified in AC):
if let Some(ref op) = ac.operation {
if let Some(ref ac_corpus) = op.corpus_entry_cid {
if !ac_corpus.is_empty()
&& ac_corpus != "sha256:ungoverned"
&& corpus_cid != "sha256:ungoverned"
&& ac_corpus != corpus_cid
{
eprintln!(
"gsh: corpus mismatch — AC expects {}, running in {}",
ac_corpus, corpus_cid
);
return Err(3); // governance violation
}
}
}
// Expiry check:
if let Some(ref expires) = ac.expires_at {
if let Ok(exp) = chrono::DateTime::parse_from_rfc3339(expires) {
if exp < chrono::Utc::now() {
eprintln!("gsh: AC expired at {}", expires);
return Err(2);
}
}
}
// R-22: single-use check (filesystem registry):
let registry_dir = dirs::home_dir()
.map(|h| h.join(".gsh").join("consumed"))
.unwrap_or_else(|| std::path::PathBuf::from("/tmp/.gsh/consumed"));
let consumed_path = registry_dir.join(&ac.context_id);
if consumed_path.exists() {
eprintln!("gsh: AC already consumed (replay detected): {}", ac.context_id);
return Err(2);
}
// Mark as consumed:
let _ = std::fs::create_dir_all(&registry_dir);
let _ = std::fs::write(&consumed_path, "");
Ok(ac)
}
// ── Corpus Directory Gate ────────────────────────────────────
fn corpus_check(corpus_cid: &str, command: &str) -> Result<(), i32> {
if corpus_cid == "sha256:ungoverned" {
return Ok(()); // ungoverned corpus — no check
}
let corpus_dir = Path::new("/opt/substrate/corpus").join(corpus_cid);
if !corpus_dir.exists() {
// Corpus directory doesn't exist on this host — allow but warn
eprintln!("gsh: corpus directory not found (host may not have corpus mounted)");
return Ok(());
}
// Extract the command name (first word):
let cmd_name = command.split_whitespace().next().unwrap_or(command);
let cmd_name = Path::new(cmd_name)
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| cmd_name.to_string());
let binary_path = corpus_dir.join(&cmd_name);
if !binary_path.exists() {
eprintln!(
"gsh: command '{}' not in corpus {} (killswitch active)",
cmd_name, corpus_cid
);
return Err(3); // governance violation
}
Ok(())
}
// ── Inline AC Request (fallback) ─────────────────────────────
fn request_ac_inline(
client: &Client,
base: &str,
operation: &str,
command_hash: &str,
corpus_cid: &str,
) -> Result<String> {
let resp = client
.post(broker_url(base, "governance/authorize/"))
.json(&AcRequest {
driver_id: "keycloak".into(),
playbook: format!(
"{}:{}",
operation,
&command_hash[..20.min(command_hash.len())]
),
corpus_entry_cid: corpus_cid.into(),
parameters_cid: command_hash.into(),
accord_template: "shell-exec".into(),
})
.send()
.context("Failed to reach broker")?;
if !resp.status().is_success() {
bail!(
"AC denied: {} — {}",
resp.status(),
resp.text().unwrap_or_default()
);
}
let ac: AcResponse = resp.json().context("Invalid AC response")?;
ac.authorization_context
.map(|c| c.context_id)
.filter(|id| !id.is_empty())
.context("No AC ID returned")
}
fn post_cr(client: &Client, base: &str, ac_id: &str, outcome: &str) -> (String, String) {
let now = chrono::Utc::now().to_rfc3339();
let session_id = std::env::var("CHRONICLE_SESSION_ID").unwrap_or_default();
match client
.post(broker_url(base, "governance/complete/"))
.json(&CrRequest {
context_id: ac_id.into(),
outcome: outcome.into(),
completed_at: now,
chronicle_evidence: CrEvidence {
events: vec![],
merkle_root: String::new(),
},
behavioral_attestation: CrAttestation {
status: "unavailable".into(),
},
ffc: serde_json::json!({"did": "did:web:guildhouse.dev", "chronicle_session_id": session_id}),
signature: serde_json::json!({"value": "gsh"}),
})
.send()
{
Ok(r) if r.status().is_success() => {
let cr: CrResponse = r.json().unwrap_or(CrResponse {
receipt_id: None,
chronicle_event_cid: None,
});
(
cr.receipt_id.unwrap_or_default(),
cr.chronicle_event_cid.unwrap_or_default(),
)
}
Ok(r) => {
eprintln!("gsh: CR failed: {}", r.status());
(String::new(), String::new())
}
Err(e) => {
eprintln!("gsh: CR error: {}", e);
(String::new(), String::new())
}
}
}
// ── Main ─────────────────────────────────────────────────────
fn main() {
let args = Args::parse();
match run(args) {
Ok(code) => process::exit(code),
Err(e) => {
let msg = format!("{:#}", e);
eprintln!("gsh: {}", msg);
// Map governance exit codes from error message:
if msg.contains("exit 2") {
process::exit(2); // auth failure
} else if msg.contains("exit 3") {
process::exit(3); // governance violation
} else {
process::exit(125); // gsh internal error
}
}
}
}
fn run(args: Args) -> Result<i32> {
let corpus = std::env::var("GSAP_CORPUS_CID").unwrap_or_else(|_| "sha256:ungoverned".into());
let token = std::env::var("GSAP_TOKEN").ok();
// Ungoverned mode — no governance, just execute:
if args.ungoverned {
if let Some(ref exec) = args.exec {
let output = process::Command::new("sh")
.arg("-c")
.arg(exec)
.output()
.context("exec failed")?;
let code = output.status.code().unwrap_or(1);
if args.json {
println!(
"{}",
serde_json::to_string_pretty(&GshOutput {
exit_code: code,
stdout: String::from_utf8_lossy(&output.stdout).into(),
stderr: String::from_utf8_lossy(&output.stderr).into(),
ac_id: String::new(),
ac_mode: "ungoverned".into(),
cr_id: String::new(),
chronicle_cid: String::new(),
command_hash: sha256_hash(exec.as_bytes()),
run_id: Uuid::new_v4().to_string(),
corpus_cid: corpus,
})?
);
} else {
print!("{}", String::from_utf8_lossy(&output.stdout));
eprint!("{}", String::from_utf8_lossy(&output.stderr));
}
return Ok(code);
}
bail!("--ungoverned requires --exec");
}
// Route subcommands (session-start/end/status):
if let Some(cmd) = &args.command {
let base = args
.broker_url
.clone()
.or_else(|| std::env::var("GSAP_BROKER_URL").ok())
.context("GSAP_BROKER_URL not set")?;
let client = build_client(&token)?;
return match cmd {
Cmd::SessionStart { scope } => {
let hash = sha256_hash(format!("session:{}", scope).as_bytes());
eprintln!("gsh: starting session (scope: {})", scope);
let ac_id = request_ac_inline(&client, &base, scope, &hash, &corpus)?;
let session_id = Uuid::new_v4().to_string();
eprintln!("gsh: session AC — {}", &ac_id[..8.min(ac_id.len())]);
println!("export GSAP_SESSION_AC=\"{}\";", ac_id);
println!("export GSAP_SESSION_ID=\"{}\";", session_id);
println!("export GSAP_SESSION_SCOPE=\"{}\";", scope);
Ok(0)
}
Cmd::SessionEnd => {
let ac_id = std::env::var("GSAP_SESSION_AC")
.context("No session active (GSAP_SESSION_AC not set)")?;
let (_, cid) = post_cr(&client, &base, &ac_id, "completed");
eprintln!("gsh: session closed");
if !cid.is_empty() {
eprintln!("gsh: session CID {}", &cid[..40.min(cid.len())]);
}
println!("unset GSAP_SESSION_AC GSAP_SESSION_ID GSAP_SESSION_SCOPE;");
Ok(0)
}
Cmd::SessionStatus => {
match std::env::var("GSAP_SESSION_AC") {
Ok(ac) => {
println!("Session active: {}", &ac[..8.min(ac.len())]);
if let Ok(sid) = std::env::var("GSAP_SESSION_ID") {
println!("Session ID: {}", sid);
}
if let Ok(scope) = std::env::var("GSAP_SESSION_SCOPE") {
println!("Scope: {}", scope);
}
println!("Corpus: {}", corpus);
}
Err(_) => {
println!("No session active.\nStart: eval \"$(gsh session-start)\"");
}
}
Ok(0)
}
};
}
// --exec mode:
let exec = args
.exec
.as_ref()
.context("Provide --exec 'command' or a subcommand. Try: gsh --help")?;
let run_id = Uuid::new_v4().to_string();
let command_hash = sha256_hash(exec.as_bytes());
// ── Determine AC mode ────────────────────────────────────
//
// Priority:
// 1. Pre-issued AC (GSAP_AC env or --ac flag)
// 2. Session AC (GSAP_SESSION_AC env)
// 3. Inline request (fallback)
let pre_issued_ac = args
.ac
.clone()
.or_else(|| std::env::var("GSAP_AC").ok());
let (ac_id, ac_mode) = if let Some(ac_json) = pre_issued_ac {
// Mode 1: Pre-issued AC — validate and consume
let ac = validate_pre_issued_ac(&ac_json, &corpus).map_err(|code| {
// Return the exit code as an error
anyhow::anyhow!("AC validation failed (exit {})", code)
})?;
eprintln!(
"gsh: pre-issued AC — {}",
&ac.context_id[..8.min(ac.context_id.len())]
);
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())
} else if let Ok(session_ac) = std::env::var("GSAP_SESSION_AC") {
// Mode 2: Session AC — reuse
(session_ac, "session".to_string())
} else {
// Mode 3: Inline request — fallback
let base = args
.broker_url
.clone()
.or_else(|| std::env::var("GSAP_BROKER_URL").ok())
.context("No AC provided. Set GSAP_AC, GSAP_SESSION_AC, or GSAP_BROKER_URL")?;
let client = build_client(&token)?;
eprintln!("gsh: requesting AC for '{}'", exec);
let id = request_ac_inline(&client, &base, &args.operation, &command_hash, &corpus)?;
eprintln!("gsh: AC issued — {}", &id[..8.min(id.len())]);
(id, "inline".to_string())
};
// ── Corpus gate ──────────────────────────────────────────
if let Err(code) = corpus_check(&corpus, exec) {
return Ok(code);
}
if args.dry_run {
eprintln!("gsh: dry-run — skipping exec");
if args.json {
println!(
"{}",
serde_json::to_string_pretty(&GshOutput {
exit_code: 0,
stdout: String::new(),
stderr: String::new(),
ac_id,
ac_mode,
cr_id: String::new(),
chronicle_cid: String::new(),
command_hash,
run_id,
corpus_cid: corpus,
})?
);
}
return Ok(0);
}
// ── Execute ──────────────────────────────────────────────
let output = process::Command::new("sh")
.arg("-c")
.arg(exec)
.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();
// ── Post CR ──────────────────────────────────────────────
let outcome = if exit_code == 0 {
"completed"
} else {
"failed"
};
let base = args
.broker_url
.or_else(|| std::env::var("GSAP_BROKER_URL").ok());
let (cr_id, chronicle_cid) = if let Some(base) = base {
let client = build_client(&token)?;
let (id, cid) = post_cr(&client, &base, &ac_id, outcome);
if ac_mode == "session" && id.is_empty() && cid.is_empty() {
eprintln!("gsh: session CR not recorded (broker session support pending)");
}
(id, cid)
} else {
eprintln!("gsh: no GSAP_BROKER_URL — CR not posted");
(String::new(), String::new())
};
// ── Output ───────────────────────────────────────────────
if args.json {
println!(
"{}",
serde_json::to_string_pretty(&GshOutput {
exit_code,
stdout: stdout_str,
stderr: stderr_str,
ac_id,
ac_mode,
cr_id,
chronicle_cid,
command_hash,
run_id,
corpus_cid: corpus,
})?
);
} else {
print!("{}", stdout_str);
eprint!("{}", stderr_str);
if !chronicle_cid.is_empty() {
eprintln!(
"gsh: CID {}",
&chronicle_cid[..40.min(chronicle_cid.len())]
);
}
}
Ok(exit_code)
}