//! gsh — Governed Shell (machine mode) //! //! GCAP-SPEC-SHELLBOUND-SDK-0001 //! //! Usage: //! gsh --exec "echo hello" //! gsh --exec "hcloud server list" --json //! //! Environment: //! GSAP_BROKER_URL http://fastapi-gsap:8000 //! GSAP_AGENT_DID did:web:bxnet.../agent/platform-ops //! GSAP_TOKEN Bearer token for broker auth (optional) //! GSAP_CORPUS_CID Corpus entry CID (optional) use anyhow::{bail, Context, Result}; use clap::Parser; use reqwest::blocking::Client; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::process; use std::time::Duration; use uuid::Uuid; #[derive(Parser)] #[command(name = "gsh", about = "Governed shell — GCAP machine mode")] struct Args { /// Command to execute (via sh -c) #[arg(long, short = 'e')] exec: String, /// Override broker URL (default: $GSAP_BROKER_URL) #[arg(long)] broker_url: Option, /// Override agent DID (default: $GSAP_AGENT_DID) #[arg(long)] agent_did: Option, /// Operation label (default: shell:exec) #[arg(long, default_value = "shell:exec")] operation: String, /// JSON output with CIDs #[arg(long)] json: bool, /// Request AC but skip execution #[arg(long)] dry_run: bool, } // ── GSAP broker request/response types ────────────────────── #[derive(Serialize)] struct AcRequest { driver_id: String, playbook: String, corpus_entry_cid: String, parameters_cid: String, accord_template: String, } #[derive(Deserialize)] struct AcResponse { status: Option, authorization_context: Option, } #[derive(Deserialize)] struct AcContext { context_id: String, } #[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, merkle_root: String, } #[derive(Serialize)] struct CrAttestation { status: String, } #[derive(Deserialize)] struct CrResponse { receipt_id: Option, chronicle_event_cid: Option, } #[derive(Serialize)] struct GshOutput { exit_code: i32, stdout: String, stderr: String, ac_id: String, cr_id: String, chronicle_cid: String, command_hash: String, run_id: String, } // ── Main ──────────────────────────────────────────────────── fn main() { let args = Args::parse(); match run(args) { Ok(code) => process::exit(code), Err(e) => { eprintln!("gsh: {:#}", e); process::exit(125); } } } fn run(args: Args) -> Result { let broker_url = args .broker_url .or_else(|| std::env::var("GSAP_BROKER_URL").ok()) .context("GSAP_BROKER_URL not set")?; let agent_did = args .agent_did .or_else(|| std::env::var("GSAP_AGENT_DID").ok()) .context("GSAP_AGENT_DID not set")?; let corpus_cid = std::env::var("GSAP_CORPUS_CID").unwrap_or_else(|_| "sha256:ungoverned".into()); let token = std::env::var("GSAP_TOKEN").ok(); let run_id = Uuid::new_v4().to_string(); // Hash the command — the broker sees WHAT will be executed let command_hash = format!( "sha256:{}", hex::encode(Sha256::digest(args.exec.as_bytes())) ); // Build client let mut headers = reqwest::header::HeaderMap::new(); if let Some(ref tok) = token { headers.insert( "Authorization", format!("Bearer {}", tok) .parse() .context("Invalid GSAP_TOKEN")?, ); } let client = Client::builder() .timeout(Duration::from_secs(30)) .default_headers(headers) .build() .context("HTTP client build failed")?; // ── Request AC ────────────────────────────────────────── eprintln!("gsh: requesting AC for '{}'", args.exec); let ac_resp = client .post(format!( "{}/governance/authorize/", broker_url.trim_end_matches('/') )) .json(&AcRequest { driver_id: "keycloak".into(), playbook: format!("{}:{}", args.operation, &command_hash[..20]), corpus_entry_cid: corpus_cid, parameters_cid: command_hash.clone(), accord_template: "shell-exec".into(), }) .send() .context("Failed to reach GSAP broker")?; if !ac_resp.status().is_success() { let status = ac_resp.status(); let body = ac_resp.text().unwrap_or_default(); bail!("AC denied: {} — {}", status, body); } let ac: AcResponse = ac_resp.json().context("Invalid AC response")?; let ac_id = ac .authorization_context .as_ref() .map(|c| c.context_id.clone()) .unwrap_or_default(); if ac_id.is_empty() { bail!( "AC not authorized: status={:?}", ac.status ); } eprintln!("gsh: AC issued — {}", &ac_id[..8.min(ac_id.len())]); if args.dry_run { eprintln!("gsh: dry-run — skipping execution"); if args.json { println!( "{}", serde_json::to_string_pretty(&GshOutput { exit_code: 0, stdout: String::new(), stderr: String::new(), ac_id, cr_id: String::new(), chronicle_cid: String::new(), command_hash, run_id, })? ); } return Ok(0); } // ── Execute ───────────────────────────────────────────── let output = process::Command::new("sh") .arg("-c") .arg(&args.exec) .output() .context("Failed to execute command")?; 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 now = chrono::Utc::now().to_rfc3339(); let (cr_id, chronicle_cid) = match client .post(format!( "{}/governance/complete/", broker_url.trim_end_matches('/') )) .json(&CrRequest { context_id: ac_id.clone(), 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"}), signature: serde_json::json!({"value": "gsh-machine-mode"}), }) .send() { Ok(resp) if resp.status().is_success() => { let cr: CrResponse = resp.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(resp) => { eprintln!("gsh: CR failed: {}", resp.status()); (String::new(), String::new()) } Err(e) => { eprintln!("gsh: CR error: {}", e); (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, cr_id, chronicle_cid, command_hash, run_id, })? ); } 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) }