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>
This commit is contained in:
parent
3a2ed1ed42
commit
eab034f0cc
4 changed files with 2302 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
target/
|
||||
1964
Cargo.lock
generated
Normal file
1964
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
20
Cargo.toml
Normal file
20
Cargo.toml
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
[package]
|
||||
name = "gsh"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Governed shell — GCAP-SPEC-SHELLBOUND-SDK-0001"
|
||||
|
||||
[[bin]]
|
||||
name = "gsh"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
reqwest = { version = "0.12", features = ["json", "blocking"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
anyhow = "1"
|
||||
chrono = "0.4"
|
||||
317
src/main.rs
Normal file
317
src/main.rs
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
//! 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)
|
||||
}
|
||||
Loading…
Reference in a new issue