diff --git a/src/main.rs b/src/main.rs index e12398e..ba930bc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,19 +1,30 @@ -//! gsh — Governed Shell (machine mode) +//! gsh — Governed Shell //! //! GCAP-SPEC-SHELLBOUND-SDK-0001 //! -//! Usage: -//! gsh --exec "echo hello" -//! gsh --exec "hcloud server list" --json +//! Two execution models: +//! +//! 1. Per-invocation (machine mode): +//! gsh --exec "command" +//! One AC per command. For single governed ops. +//! +//! 2. Session mode: +//! eval "$(gsh session-start)" +//! gsh --exec "cmd1" # reuses session AC +//! gsh --exec "cmd2" +//! gsh session-end +//! One AC for the session. N CRs. //! //! 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) +//! GSAP_BROKER_URL required +//! GSAP_AGENT_DID required +//! GSAP_TOKEN optional Bearer auth +//! GSAP_CORPUS_CID optional +//! GSAP_SESSION_AC set by session-start +//! GSAP_SESSION_ID set by session-start use anyhow::{bail, Context, Result}; -use clap::Parser; +use clap::{Parser, Subcommand}; use reqwest::blocking::Client; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -21,35 +32,48 @@ 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 to execute (via sh -c) - #[arg(long, short = 'e')] - exec: String, + #[command(subcommand)] + command: Option, - /// Override broker URL (default: $GSAP_BROKER_URL) - #[arg(long)] + /// Command to execute (via sh -c) + #[arg(long, short = 'e', global = true)] + exec: Option, + + #[arg(long, global = true)] broker_url: Option, - /// Override agent DID (default: $GSAP_AGENT_DID) - #[arg(long)] + #[arg(long, global = true)] agent_did: Option, - /// Operation label (default: shell:exec) - #[arg(long, default_value = "shell:exec")] + #[arg(long, default_value = "shell:exec", global = true)] operation: String, - /// JSON output with CIDs - #[arg(long)] + #[arg(long, global = true)] json: bool, - /// Request AC but skip execution - #[arg(long)] + #[arg(long, global = true)] dry_run: bool, } -// ── GSAP broker request/response types ────────────────────── +#[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, +} + +// ── GSAP types (match broker's expected format) ─────────────── #[derive(Serialize)] struct AcRequest { @@ -62,12 +86,13 @@ struct AcRequest { #[derive(Deserialize)] struct AcResponse { + #[allow(dead_code)] status: Option, - authorization_context: Option, + authorization_context: Option, } #[derive(Deserialize)] -struct AcContext { +struct AcCtx { context_id: String, } @@ -105,206 +130,195 @@ struct GshOutput { stdout: String, stderr: String, ac_id: String, + session_ac: bool, cr_id: String, chronicle_cid: String, command_hash: String, run_id: String, } -// ── Main ──────────────────────────────────────────────────── +// ── Helpers ─────────────────────────────────────────────────── + +fn sha256_hash(data: &[u8]) -> String { + format!("sha256:{}", hex::encode(Sha256::digest(data))) +} + +fn build_client(token: &Option) -> Result { + 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('/')) +} + +fn request_ac(client: &Client, base: &str, operation: &str, command_hash: &str, corpus_cid: &str) -> Result { + 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(); + 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"}), + 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) => { - eprintln!("gsh: {:#}", e); - process::exit(125); - } + Err(e) => { eprintln!("gsh: {:#}", e); process::exit(125); } } } fn run(args: Args) -> Result { - let broker_url = args - .broker_url + let base = args.broker_url.clone() .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 corpus = std::env::var("GSAP_CORPUS_CID").unwrap_or_else(|_| "sha256:ungoverned".into()); let token = std::env::var("GSAP_TOKEN").ok(); + + // Route subcommands: + if let Some(cmd) = &args.command { + 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(&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); } + } + 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 client = build_client(&token)?; let run_id = Uuid::new_v4().to_string(); + let command_hash = sha256_hash(exec.as_bytes()); - // 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())]); + // Session mode or per-invocation: + let (ac_id, using_session) = match std::env::var("GSAP_SESSION_AC") { + Ok(session_ac) => (session_ac, true), + Err(_) => { + eprintln!("gsh: requesting AC for '{}'", exec); + let id = request_ac(&client, &base, &args.operation, &command_hash, &corpus)?; + eprintln!("gsh: AC issued — {}", &id[..8.min(id.len())]); + (id, false) + } + }; if args.dry_run { - eprintln!("gsh: dry-run — skipping execution"); + 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, - cr_id: String::new(), - chronicle_cid: String::new(), - command_hash, - run_id, - })? - ); + println!("{}", serde_json::to_string_pretty(&GshOutput { + exit_code: 0, stdout: String::new(), stderr: String::new(), + ac_id, session_ac: using_session, 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")?; - + // 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" + // Post CR (in session mode, this may fail on "already consumed" — that's a broker gap, not gsh's fault): + let outcome = if exit_code == 0 { "completed" } else { "failed" }; + let (cr_id, chronicle_cid) = if !using_session { + post_cr(&client, &base, &ac_id, outcome) } else { - "failed" + // Session mode: post CR but accept failure gracefully + // (broker currently marks AC consumed after first CR) + let (id, cid) = post_cr(&client, &base, &ac_id, outcome); + if id.is_empty() && cid.is_empty() { + eprintln!("gsh: session CR not recorded (broker session support pending)"); + } + (id, cid) }; - 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 ────────────────────────────────────────────── - + // 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, - })? - ); + println!("{}", serde_json::to_string_pretty(&GshOutput { + exit_code, stdout: stdout_str, stderr: stderr_str, + ac_id, session_ac: using_session, cr_id, chronicle_cid, command_hash, run_id, + })?); } else { print!("{}", stdout_str); eprint!("{}", stderr_str);