feat: session mode — one AC for N commands

Per-invocation AC is the primitive for single governed ops.
Session mode is for scripts, pipelines, and interactive shells.

Per-invocation (unchanged):
  gsh --exec "cmd"  →  1 AC + 1 CR per command

Session mode (new):
  eval "$(gsh session-start --scope shell:session)"
  gsh --exec "cmd1"  # reuses session AC
  gsh --exec "cmd2"
  eval "$(gsh session-end)"

Detection: GSAP_SESSION_AC in environment.
Subcommands: session-start, session-end, session-status

Known gap: broker currently marks AC consumed after first CR.
Session commands 2+ get 404 on CR. This is a broker-side fix
(needs session AC type). gsh handles it gracefully.

Tested against live fastapi-gsap Spoke on Hetzner.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tyler J King 2026-04-01 19:17:36 -04:00
parent eab034f0cc
commit 2f9401d3c4

View file

@ -1,19 +1,30 @@
//! gsh — Governed Shell (machine mode) //! gsh — Governed Shell
//! //!
//! GCAP-SPEC-SHELLBOUND-SDK-0001 //! GCAP-SPEC-SHELLBOUND-SDK-0001
//! //!
//! Usage: //! Two execution models:
//! gsh --exec "echo hello" //!
//! gsh --exec "hcloud server list" --json //! 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: //! Environment:
//! GSAP_BROKER_URL http://fastapi-gsap:8000 //! GSAP_BROKER_URL required
//! GSAP_AGENT_DID did:web:bxnet.../agent/platform-ops //! GSAP_AGENT_DID required
//! GSAP_TOKEN Bearer token for broker auth (optional) //! GSAP_TOKEN optional Bearer auth
//! GSAP_CORPUS_CID Corpus entry CID (optional) //! GSAP_CORPUS_CID optional
//! GSAP_SESSION_AC set by session-start
//! GSAP_SESSION_ID set by session-start
use anyhow::{bail, Context, Result}; use anyhow::{bail, Context, Result};
use clap::Parser; use clap::{Parser, Subcommand};
use reqwest::blocking::Client; use reqwest::blocking::Client;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
@ -21,35 +32,48 @@ use std::process;
use std::time::Duration; use std::time::Duration;
use uuid::Uuid; use uuid::Uuid;
// ── CLI ───────────────────────────────────────────────────────
#[derive(Parser)] #[derive(Parser)]
#[command(name = "gsh", about = "Governed shell — GCAP machine mode")] #[command(name = "gsh", about = "Governed shell — GCAP machine mode")]
struct Args { struct Args {
/// Command to execute (via sh -c) #[command(subcommand)]
#[arg(long, short = 'e')] command: Option<Cmd>,
exec: String,
/// Override broker URL (default: $GSAP_BROKER_URL) /// Command to execute (via sh -c)
#[arg(long)] #[arg(long, short = 'e', global = true)]
exec: Option<String>,
#[arg(long, global = true)]
broker_url: Option<String>, broker_url: Option<String>,
/// Override agent DID (default: $GSAP_AGENT_DID) #[arg(long, global = true)]
#[arg(long)]
agent_did: Option<String>, agent_did: Option<String>,
/// Operation label (default: shell:exec) #[arg(long, default_value = "shell:exec", global = true)]
#[arg(long, default_value = "shell:exec")]
operation: String, operation: String,
/// JSON output with CIDs #[arg(long, global = true)]
#[arg(long)]
json: bool, json: bool,
/// Request AC but skip execution #[arg(long, global = true)]
#[arg(long)]
dry_run: bool, 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)] #[derive(Serialize)]
struct AcRequest { struct AcRequest {
@ -62,12 +86,13 @@ struct AcRequest {
#[derive(Deserialize)] #[derive(Deserialize)]
struct AcResponse { struct AcResponse {
#[allow(dead_code)]
status: Option<String>, status: Option<String>,
authorization_context: Option<AcContext>, authorization_context: Option<AcCtx>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
struct AcContext { struct AcCtx {
context_id: String, context_id: String,
} }
@ -105,206 +130,195 @@ struct GshOutput {
stdout: String, stdout: String,
stderr: String, stderr: String,
ac_id: String, ac_id: String,
session_ac: bool,
cr_id: String, cr_id: String,
chronicle_cid: String, chronicle_cid: String,
command_hash: String, command_hash: String,
run_id: String, run_id: String,
} }
// ── Main ──────────────────────────────────────────────────── // ── 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('/'))
}
fn request_ac(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();
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() { fn main() {
let args = Args::parse(); let args = Args::parse();
match run(args) { match run(args) {
Ok(code) => process::exit(code), Ok(code) => process::exit(code),
Err(e) => { Err(e) => { eprintln!("gsh: {:#}", e); process::exit(125); }
eprintln!("gsh: {:#}", e);
process::exit(125);
}
} }
} }
fn run(args: Args) -> Result<i32> { fn run(args: Args) -> Result<i32> {
let broker_url = args let base = args.broker_url.clone()
.broker_url
.or_else(|| std::env::var("GSAP_BROKER_URL").ok()) .or_else(|| std::env::var("GSAP_BROKER_URL").ok())
.context("GSAP_BROKER_URL not set")?; .context("GSAP_BROKER_URL not set")?;
let corpus = std::env::var("GSAP_CORPUS_CID").unwrap_or_else(|_| "sha256:ungoverned".into());
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 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 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 // Session mode or per-invocation:
let command_hash = format!( let (ac_id, using_session) = match std::env::var("GSAP_SESSION_AC") {
"sha256:{}", Ok(session_ac) => (session_ac, true),
hex::encode(Sha256::digest(args.exec.as_bytes())) Err(_) => {
); eprintln!("gsh: requesting AC for '{}'", exec);
let id = request_ac(&client, &base, &args.operation, &command_hash, &corpus)?;
// Build client eprintln!("gsh: AC issued — {}", &id[..8.min(id.len())]);
let mut headers = reqwest::header::HeaderMap::new(); (id, false)
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 { if args.dry_run {
eprintln!("gsh: dry-run — skipping execution"); eprintln!("gsh: dry-run — skipping exec");
if args.json { if args.json {
println!( println!("{}", serde_json::to_string_pretty(&GshOutput {
"{}", exit_code: 0, stdout: String::new(), stderr: String::new(),
serde_json::to_string_pretty(&GshOutput { ac_id, session_ac: using_session, cr_id: String::new(),
exit_code: 0, chronicle_cid: String::new(), command_hash, run_id,
stdout: String::new(), })?);
stderr: String::new(),
ac_id,
cr_id: String::new(),
chronicle_cid: String::new(),
command_hash,
run_id,
})?
);
} }
return Ok(0); return Ok(0);
} }
// ── Execute ───────────────────────────────────────────── // Execute:
let output = process::Command::new("sh").arg("-c").arg(exec).output().context("exec failed")?;
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 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();
// ── Post CR ───────────────────────────────────────────── // 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 outcome = if exit_code == 0 { let (cr_id, chronicle_cid) = if !using_session {
"completed" post_cr(&client, &base, &ac_id, outcome)
} else { } 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(); // Output:
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 { if args.json {
println!( println!("{}", serde_json::to_string_pretty(&GshOutput {
"{}", exit_code, stdout: stdout_str, stderr: stderr_str,
serde_json::to_string_pretty(&GshOutput { ac_id, session_ac: using_session, cr_id, chronicle_cid, command_hash, run_id,
exit_code, })?);
stdout: stdout_str,
stderr: stderr_str,
ac_id,
cr_id,
chronicle_cid,
command_hash,
run_id,
})?
);
} else { } else {
print!("{}", stdout_str); print!("{}", stdout_str);
eprint!("{}", stderr_str); eprint!("{}", stderr_str);