gsh/src/main.rs
Tyler J King eab034f0cc feat: gsh machine mode — first governed shell execution
~200 lines of Rust. Every command: AC → exec → CR → CID.

Usage:
  gsh --exec "echo hello"
  gsh --exec "hcloud server list" --json
  gsh --exec "ansible-playbook site.yml" --dry-run

Flow:
  1. SHA-256 hash the command
  2. POST /governance/authorize/ → AC ID
  3. exec(sh, -c, command) → capture stdout/stderr/exit
  4. POST /governance/complete/ → receipt + Chronicle CID
  5. Print stdout (passthrough) or JSON (structured)
  6. Exit with command's exit code

Environment:
  GSAP_BROKER_URL   http://fastapi-gsap:8000
  GSAP_AGENT_DID    did:web:bxnet.../agent/platform-ops
  GSAP_TOKEN        Bearer token (optional)
  GSAP_CORPUS_CID   sha256:{image_digest} (optional)

Tested against live fastapi-gsap Spoke broker on Hetzner:
  dry-run: AC only ✓
  live exec: stdout passthrough + CID ✓
  JSON mode: ac_id + cr_id + chronicle_cid ✓
  exit code: 42 passed through ✓

The command_hash in the AC request means the broker knows
WHAT will be executed before authorizing. Not just "was
this agent allowed" but "was this exact command authorized."

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 19:01:22 -04:00

317 lines
8.7 KiB
Rust

//! 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<String>,
/// Override agent DID (default: $GSAP_AGENT_DID)
#[arg(long)]
agent_did: Option<String>,
/// 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<String>,
authorization_context: Option<AcContext>,
}
#[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<String>,
merkle_root: String,
}
#[derive(Serialize)]
struct CrAttestation {
status: String,
}
#[derive(Deserialize)]
struct CrResponse {
receipt_id: Option<String>,
chronicle_event_cid: Option<String>,
}
#[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<i32> {
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)
}