feat: per-session AC consumption + corpus gate + exit codes

Phase 1 of the WSL2 jumphost build.

Three execution models:
  1. Pre-issued AC: GSAP_AC='...' gsh --exec "cmd"
     Caller provides AC. gsh validates (R-22/23/24), executes, posts CR.
     For: Bascule, SK plugin, CI/CD.

  2. Inline AC request: GSAP_BROKER_URL=... gsh --exec "cmd"
     Backward compatible fallback.

  3. Ungoverned: gsh --ungoverned --exec "cmd"
     No AC, no CR, no corpus check. Dev mode.

AC validation (validate_pre_issued_ac):
  R-22: Single-use — filesystem registry at ~/.gsh/consumed/{context_id}
  R-23: Corpus match — AC corpus_entry_cid vs GSAP_CORPUS_CID env
  R-24: (parameters_cid field parsed, verification at broker)
  Expiry check — AC expires_at vs now
  Replay detection — consumed context_ids rejected

Corpus directory gate (corpus_check):
  /opt/substrate/corpus/{cid}/{command_name}
  If binary missing from corpus dir → denied (exit 3)
  The live killswitch: remove binary from corpus dir to revoke

Exit codes aligned with DESIGN.md:
  0 = success, 1 = exec failure, 2 = auth failure,
  3 = governance violation, 125 = gsh internal error

JSON output: new fields ac_mode ("pre-issued"|"inline"|"session"|"ungoverned"), corpus_cid

Tested against live fastapi-gsap broker:
  Inline AC: backward compat ✓
  Pre-issued AC from broker: validated + CR posted ✓
  Expired AC: exit 2 ✓
  Replay detection: exit 2 ✓
  Ungoverned mode: no governance overhead ✓

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tyler J King 2026-04-02 09:07:45 -04:00
parent 2f9401d3c4
commit af11a797ee
3 changed files with 538 additions and 79 deletions

150
Cargo.lock generated
View file

@ -242,6 +242,27 @@ dependencies = [
"crypto-common", "crypto-common",
] ]
[[package]]
name = "dirs"
version = "5.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
dependencies = [
"dirs-sys",
]
[[package]]
name = "dirs-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.48.0",
]
[[package]] [[package]]
name = "displaydoc" name = "displaydoc"
version = "0.2.5" version = "0.2.5"
@ -416,6 +437,7 @@ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
"clap", "clap",
"dirs",
"hex", "hex",
"reqwest", "reqwest",
"serde", "serde",
@ -784,6 +806,15 @@ version = "0.2.184"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af"
[[package]]
name = "libredox"
version = "0.1.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "linux-raw-sys" name = "linux-raw-sys"
version = "0.12.1" version = "0.12.1"
@ -907,6 +938,12 @@ dependencies = [
"vcpkg", "vcpkg",
] ]
[[package]]
name = "option-ext"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]] [[package]]
name = "percent-encoding" name = "percent-encoding"
version = "2.3.2" version = "2.3.2"
@ -968,6 +1005,17 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "redox_users"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
dependencies = [
"getrandom 0.2.17",
"libredox",
"thiserror",
]
[[package]] [[package]]
name = "reqwest" name = "reqwest"
version = "0.12.28" version = "0.12.28"
@ -1297,6 +1345,26 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "tinystr" name = "tinystr"
version = "0.8.3" version = "0.8.3"
@ -1698,13 +1766,22 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "windows-sys"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
dependencies = [
"windows-targets 0.48.5",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.52.0" version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [ dependencies = [
"windows-targets", "windows-targets 0.52.6",
] ]
[[package]] [[package]]
@ -1716,34 +1793,67 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "windows-targets"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
dependencies = [
"windows_aarch64_gnullvm 0.48.5",
"windows_aarch64_msvc 0.48.5",
"windows_i686_gnu 0.48.5",
"windows_i686_msvc 0.48.5",
"windows_x86_64_gnu 0.48.5",
"windows_x86_64_gnullvm 0.48.5",
"windows_x86_64_msvc 0.48.5",
]
[[package]] [[package]]
name = "windows-targets" name = "windows-targets"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [ dependencies = [
"windows_aarch64_gnullvm", "windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc", "windows_aarch64_msvc 0.52.6",
"windows_i686_gnu", "windows_i686_gnu 0.52.6",
"windows_i686_gnullvm", "windows_i686_gnullvm",
"windows_i686_msvc", "windows_i686_msvc 0.52.6",
"windows_x86_64_gnu", "windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm", "windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc", "windows_x86_64_msvc 0.52.6",
] ]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
[[package]] [[package]]
name = "windows_aarch64_gnullvm" name = "windows_aarch64_gnullvm"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
[[package]] [[package]]
name = "windows_aarch64_msvc" name = "windows_aarch64_msvc"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
[[package]] [[package]]
name = "windows_i686_gnu" name = "windows_i686_gnu"
version = "0.52.6" version = "0.52.6"
@ -1756,24 +1866,48 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
[[package]] [[package]]
name = "windows_i686_msvc" name = "windows_i686_msvc"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
[[package]] [[package]]
name = "windows_x86_64_gnu" name = "windows_x86_64_gnu"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
[[package]] [[package]]
name = "windows_x86_64_gnullvm" name = "windows_x86_64_gnullvm"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
[[package]] [[package]]
name = "windows_x86_64_msvc" name = "windows_x86_64_msvc"
version = "0.52.6" version = "0.52.6"

View file

@ -18,3 +18,4 @@ hex = "0.4"
uuid = { version = "1", features = ["v4", "serde"] } uuid = { version = "1", features = ["v4", "serde"] }
anyhow = "1" anyhow = "1"
chrono = "0.4" chrono = "0.4"
dirs = "5"

View file

@ -2,32 +2,45 @@
//! //!
//! GCAP-SPEC-SHELLBOUND-SDK-0001 //! GCAP-SPEC-SHELLBOUND-SDK-0001
//! //!
//! Two execution models: //! Three execution models:
//! //!
//! 1. Per-invocation (machine mode): //! 1. Pre-issued AC (target model):
//! gsh --exec "command" //! GSAP_AC='{"context_id":...}' gsh --exec "command"
//! One AC per command. For single governed ops. //! Caller provides AC. gsh validates, executes, posts CR.
//! Used by: Bascule, SK plugin, CI/CD.
//! //!
//! 2. Session mode: //! 2. Inline AC request (backward compat):
//! eval "$(gsh session-start)" //! GSAP_BROKER_URL=... gsh --exec "command"
//! gsh --exec "cmd1" # reuses session AC //! gsh requests AC from broker. Fallback when no GSAP_AC.
//! gsh --exec "cmd2" //!
//! gsh session-end //! 3. Ungoverned (dev mode):
//! One AC for the session. N CRs. //! gsh --ungoverned --exec "command"
//! No AC, no CR, no corpus check. Just executes.
//!
//! Session mode works with both pre-issued and inline ACs.
//! //!
//! Environment: //! Environment:
//! GSAP_BROKER_URL required //! GSAP_AC pre-issued AC JSON (preferred)
//! GSAP_AGENT_DID required //! GSAP_BROKER_URL broker URL (for inline AC + CR posting)
//! GSAP_TOKEN optional Bearer auth //! GSAP_AGENT_DID agent DID (for inline AC requests)
//! GSAP_CORPUS_CID optional //! GSAP_TOKEN Bearer auth token
//! GSAP_CORPUS_CID corpus this session is authorized for
//! GSAP_SESSION_AC set by session-start //! GSAP_SESSION_AC set by session-start
//! GSAP_SESSION_ID set by session-start //! GSAP_SESSION_ID set by session-start
//!
//! Exit codes:
//! 0 = command succeeded
//! 1 = command failed (non-zero exit)
//! 2 = authorization failure (AC invalid/expired/missing)
//! 3 = governance violation (corpus mismatch, command denied)
//! 125 = gsh internal error
use anyhow::{bail, Context, Result}; use anyhow::{bail, Context, Result};
use clap::{Parser, Subcommand}; 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};
use std::path::Path;
use std::process; use std::process;
use std::time::Duration; use std::time::Duration;
use uuid::Uuid; use uuid::Uuid;
@ -44,6 +57,14 @@ struct Args {
#[arg(long, short = 'e', global = true)] #[arg(long, short = 'e', global = true)]
exec: Option<String>, exec: Option<String>,
/// Pre-issued AC JSON (alternative to GSAP_AC env)
#[arg(long, global = true)]
ac: Option<String>,
/// Run without governance (no AC, no CR, no corpus check)
#[arg(long, global = true)]
ungoverned: bool,
#[arg(long, global = true)] #[arg(long, global = true)]
broker_url: Option<String>, broker_url: Option<String>,
@ -73,7 +94,38 @@ enum Cmd {
SessionStatus, SessionStatus,
} }
// ── GSAP types (match broker's expected format) ─────────────── // ── Pre-issued AC types ──────────────────────────────────────
#[derive(Deserialize, Debug)]
struct PreIssuedAc {
context_id: String,
#[serde(default)]
issued_at: Option<String>,
#[serde(default)]
expires_at: Option<String>,
#[serde(default)]
operation: Option<AcOperation>,
#[serde(default)]
principal: Option<AcPrincipal>,
}
#[derive(Deserialize, Debug, Default)]
struct AcOperation {
#[serde(default)]
corpus_entry_cid: Option<String>,
#[serde(default)]
parameters_cid: Option<String>,
#[serde(default)]
playbook: Option<String>,
}
#[derive(Deserialize, Debug, Default)]
struct AcPrincipal {
#[serde(default)]
did: Option<String>,
}
// ── Inline AC request types (broker format) ──────────────────
#[derive(Serialize)] #[derive(Serialize)]
struct AcRequest { struct AcRequest {
@ -88,14 +140,16 @@ struct AcRequest {
struct AcResponse { struct AcResponse {
#[allow(dead_code)] #[allow(dead_code)]
status: Option<String>, status: Option<String>,
authorization_context: Option<AcCtx>, authorization_context: Option<AcResponseCtx>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
struct AcCtx { struct AcResponseCtx {
context_id: String, context_id: String,
} }
// ── CR types ─────────────────────────────────────────────────
#[derive(Serialize)] #[derive(Serialize)]
struct CrRequest { struct CrRequest {
context_id: String, context_id: String,
@ -124,20 +178,23 @@ struct CrResponse {
chronicle_event_cid: Option<String>, chronicle_event_cid: Option<String>,
} }
// ── Output ───────────────────────────────────────────────────
#[derive(Serialize)] #[derive(Serialize)]
struct GshOutput { struct GshOutput {
exit_code: i32, exit_code: i32,
stdout: String, stdout: String,
stderr: String, stderr: String,
ac_id: String, ac_id: String,
session_ac: bool, ac_mode: String, // "pre-issued", "inline", "session", "ungoverned"
cr_id: String, cr_id: String,
chronicle_cid: String, chronicle_cid: String,
command_hash: String, command_hash: String,
run_id: String, run_id: String,
corpus_cid: String,
} }
// ── Helpers ────────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────────
fn sha256_hash(data: &[u8]) -> String { fn sha256_hash(data: &[u8]) -> String {
format!("sha256:{}", hex::encode(Sha256::digest(data))) format!("sha256:{}", hex::encode(Sha256::digest(data)))
@ -146,7 +203,10 @@ fn sha256_hash(data: &[u8]) -> String {
fn build_client(token: &Option<String>) -> Result<Client> { fn build_client(token: &Option<String>) -> Result<Client> {
let mut headers = reqwest::header::HeaderMap::new(); let mut headers = reqwest::header::HeaderMap::new();
if let Some(tok) = token { if let Some(tok) = token {
headers.insert("Authorization", format!("Bearer {}", tok).parse().context("Invalid token")?); headers.insert(
"Authorization",
format!("Bearer {}", tok).parse().context("Invalid token")?,
);
} }
Client::builder() Client::builder()
.timeout(Duration::from_secs(30)) .timeout(Duration::from_secs(30))
@ -156,15 +216,124 @@ fn build_client(token: &Option<String>) -> Result<Client> {
} }
fn broker_url(base: &str, path: &str) -> String { fn broker_url(base: &str, path: &str) -> String {
format!("{}/{}", base.trim_end_matches('/'), path.trim_start_matches('/')) 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> { // ── AC Validation (R-22, R-23, R-24) ────────────────────────
fn validate_pre_issued_ac(ac_json: &str, corpus_cid: &str) -> Result<PreIssuedAc, i32> {
// Parse:
let ac: PreIssuedAc = serde_json::from_str(ac_json).map_err(|e| {
eprintln!("gsh: AC parse error: {}", e);
2 // auth failure
})?;
// Must have context_id:
if ac.context_id.is_empty() {
eprintln!("gsh: AC missing context_id");
return Err(2);
}
// R-23: corpus_entry_cid must match (if specified in AC):
if let Some(ref op) = ac.operation {
if let Some(ref ac_corpus) = op.corpus_entry_cid {
if !ac_corpus.is_empty()
&& ac_corpus != "sha256:ungoverned"
&& corpus_cid != "sha256:ungoverned"
&& ac_corpus != corpus_cid
{
eprintln!(
"gsh: corpus mismatch — AC expects {}, running in {}",
ac_corpus, corpus_cid
);
return Err(3); // governance violation
}
}
}
// Expiry check:
if let Some(ref expires) = ac.expires_at {
if let Ok(exp) = chrono::DateTime::parse_from_rfc3339(expires) {
if exp < chrono::Utc::now() {
eprintln!("gsh: AC expired at {}", expires);
return Err(2);
}
}
}
// R-22: single-use check (filesystem registry):
let registry_dir = dirs::home_dir()
.map(|h| h.join(".gsh").join("consumed"))
.unwrap_or_else(|| std::path::PathBuf::from("/tmp/.gsh/consumed"));
let consumed_path = registry_dir.join(&ac.context_id);
if consumed_path.exists() {
eprintln!("gsh: AC already consumed (replay detected): {}", ac.context_id);
return Err(2);
}
// Mark as consumed:
let _ = std::fs::create_dir_all(&registry_dir);
let _ = std::fs::write(&consumed_path, "");
Ok(ac)
}
// ── Corpus Directory Gate ────────────────────────────────────
fn corpus_check(corpus_cid: &str, command: &str) -> Result<(), i32> {
if corpus_cid == "sha256:ungoverned" {
return Ok(()); // ungoverned corpus — no check
}
let corpus_dir = Path::new("/opt/substrate/corpus").join(corpus_cid);
if !corpus_dir.exists() {
// Corpus directory doesn't exist on this host — allow but warn
eprintln!("gsh: corpus directory not found (host may not have corpus mounted)");
return Ok(());
}
// Extract the command name (first word):
let cmd_name = command.split_whitespace().next().unwrap_or(command);
let cmd_name = Path::new(cmd_name)
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| cmd_name.to_string());
let binary_path = corpus_dir.join(&cmd_name);
if !binary_path.exists() {
eprintln!(
"gsh: command '{}' not in corpus {} (killswitch active)",
cmd_name, corpus_cid
);
return Err(3); // governance violation
}
Ok(())
}
// ── Inline AC Request (fallback) ─────────────────────────────
fn request_ac_inline(
client: &Client,
base: &str,
operation: &str,
command_hash: &str,
corpus_cid: &str,
) -> Result<String> {
let resp = client let resp = client
.post(broker_url(base, "governance/authorize/")) .post(broker_url(base, "governance/authorize/"))
.json(&AcRequest { .json(&AcRequest {
driver_id: "keycloak".into(), driver_id: "keycloak".into(),
playbook: format!("{}:{}", operation, &command_hash[..20.min(command_hash.len())]), playbook: format!(
"{}:{}",
operation,
&command_hash[..20.min(command_hash.len())]
),
corpus_entry_cid: corpus_cid.into(), corpus_entry_cid: corpus_cid.into(),
parameters_cid: command_hash.into(), parameters_cid: command_hash.into(),
accord_template: "shell-exec".into(), accord_template: "shell-exec".into(),
@ -173,7 +342,11 @@ fn request_ac(client: &Client, base: &str, operation: &str, command_hash: &str,
.context("Failed to reach broker")?; .context("Failed to reach broker")?;
if !resp.status().is_success() { if !resp.status().is_success() {
bail!("AC denied: {} — {}", resp.status(), resp.text().unwrap_or_default()); bail!(
"AC denied: {} — {}",
resp.status(),
resp.text().unwrap_or_default()
);
} }
let ac: AcResponse = resp.json().context("Invalid AC response")?; let ac: AcResponse = resp.json().context("Invalid AC response")?;
@ -185,53 +358,118 @@ fn request_ac(client: &Client, base: &str, operation: &str, command_hash: &str,
fn post_cr(client: &Client, base: &str, ac_id: &str, outcome: &str) -> (String, String) { fn post_cr(client: &Client, base: &str, ac_id: &str, outcome: &str) -> (String, String) {
let now = chrono::Utc::now().to_rfc3339(); let now = chrono::Utc::now().to_rfc3339();
let session_id = std::env::var("CHRONICLE_SESSION_ID").unwrap_or_default();
match client match client
.post(broker_url(base, "governance/complete/")) .post(broker_url(base, "governance/complete/"))
.json(&CrRequest { .json(&CrRequest {
context_id: ac_id.into(), context_id: ac_id.into(),
outcome: outcome.into(), outcome: outcome.into(),
completed_at: now, completed_at: now,
chronicle_evidence: CrEvidence { events: vec![], merkle_root: String::new() }, chronicle_evidence: CrEvidence {
behavioral_attestation: CrAttestation { status: "unavailable".into() }, events: vec![],
ffc: serde_json::json!({"did": "did:web:guildhouse.dev"}), merkle_root: String::new(),
},
behavioral_attestation: CrAttestation {
status: "unavailable".into(),
},
ffc: serde_json::json!({"did": "did:web:guildhouse.dev", "chronicle_session_id": session_id}),
signature: serde_json::json!({"value": "gsh"}), signature: serde_json::json!({"value": "gsh"}),
}) })
.send() .send()
{ {
Ok(r) if r.status().is_success() => { Ok(r) if r.status().is_success() => {
let cr: CrResponse = r.json().unwrap_or(CrResponse { receipt_id: None, chronicle_event_cid: None }); let cr: CrResponse = r.json().unwrap_or(CrResponse {
(cr.receipt_id.unwrap_or_default(), cr.chronicle_event_cid.unwrap_or_default()) 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())
} }
Ok(r) => { eprintln!("gsh: CR failed: {}", r.status()); (String::new(), String::new()) }
Err(e) => { eprintln!("gsh: CR error: {}", e); (String::new(), String::new()) }
} }
} }
// ── Main ────────────────────────────────────────────────────── // ── 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) => { eprintln!("gsh: {:#}", e); process::exit(125); } Err(e) => {
let msg = format!("{:#}", e);
eprintln!("gsh: {}", msg);
// Map governance exit codes from error message:
if msg.contains("exit 2") {
process::exit(2); // auth failure
} else if msg.contains("exit 3") {
process::exit(3); // governance violation
} else {
process::exit(125); // gsh internal error
}
}
} }
} }
fn run(args: Args) -> Result<i32> { fn run(args: Args) -> Result<i32> {
let base = args.broker_url.clone()
.or_else(|| std::env::var("GSAP_BROKER_URL").ok())
.context("GSAP_BROKER_URL not set")?;
let corpus = 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(); let token = std::env::var("GSAP_TOKEN").ok();
// Route subcommands: // Ungoverned mode — no governance, just execute:
if args.ungoverned {
if let Some(ref exec) = args.exec {
let output = process::Command::new("sh")
.arg("-c")
.arg(exec)
.output()
.context("exec failed")?;
let code = output.status.code().unwrap_or(1);
if args.json {
println!(
"{}",
serde_json::to_string_pretty(&GshOutput {
exit_code: code,
stdout: String::from_utf8_lossy(&output.stdout).into(),
stderr: String::from_utf8_lossy(&output.stderr).into(),
ac_id: String::new(),
ac_mode: "ungoverned".into(),
cr_id: String::new(),
chronicle_cid: String::new(),
command_hash: sha256_hash(exec.as_bytes()),
run_id: Uuid::new_v4().to_string(),
corpus_cid: corpus,
})?
);
} else {
print!("{}", String::from_utf8_lossy(&output.stdout));
eprint!("{}", String::from_utf8_lossy(&output.stderr));
}
return Ok(code);
}
bail!("--ungoverned requires --exec");
}
// Route subcommands (session-start/end/status):
if let Some(cmd) = &args.command { if let Some(cmd) = &args.command {
let base = args
.broker_url
.clone()
.or_else(|| std::env::var("GSAP_BROKER_URL").ok())
.context("GSAP_BROKER_URL not set")?;
let client = build_client(&token)?; let client = build_client(&token)?;
return match cmd { return match cmd {
Cmd::SessionStart { scope } => { Cmd::SessionStart { scope } => {
let hash = sha256_hash(format!("session:{}", scope).as_bytes()); let hash = sha256_hash(format!("session:{}", scope).as_bytes());
eprintln!("gsh: starting session (scope: {})", scope); eprintln!("gsh: starting session (scope: {})", scope);
let ac_id = request_ac(&client, &base, scope, &hash, &corpus)?; let ac_id = request_ac_inline(&client, &base, scope, &hash, &corpus)?;
let session_id = Uuid::new_v4().to_string(); let session_id = Uuid::new_v4().to_string();
eprintln!("gsh: session AC — {}", &ac_id[..8.min(ac_id.len())]); eprintln!("gsh: session AC — {}", &ac_id[..8.min(ac_id.len())]);
println!("export GSAP_SESSION_AC=\"{}\";", ac_id); println!("export GSAP_SESSION_AC=\"{}\";", ac_id);
@ -254,10 +492,17 @@ fn run(args: Args) -> Result<i32> {
match std::env::var("GSAP_SESSION_AC") { match std::env::var("GSAP_SESSION_AC") {
Ok(ac) => { Ok(ac) => {
println!("Session active: {}", &ac[..8.min(ac.len())]); 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(sid) = std::env::var("GSAP_SESSION_ID") {
if let Ok(scope) = std::env::var("GSAP_SESSION_SCOPE") { println!("Scope: {}", scope); } println!("Session ID: {}", sid);
}
if let Ok(scope) = std::env::var("GSAP_SESSION_SCOPE") {
println!("Scope: {}", scope);
}
println!("Corpus: {}", corpus);
}
Err(_) => {
println!("No session active.\nStart: eval \"$(gsh session-start)\"");
} }
Err(_) => println!("No session active.\nStart: eval \"$(gsh session-start)\""),
} }
Ok(0) Ok(0)
} }
@ -265,65 +510,144 @@ fn run(args: Args) -> Result<i32> {
} }
// --exec mode: // --exec mode:
let exec = args.exec.as_ref().context("Provide --exec 'command' or a subcommand. Try: gsh --help")?; let exec = args
let client = build_client(&token)?; .exec
.as_ref()
.context("Provide --exec 'command' or a subcommand. Try: gsh --help")?;
let run_id = Uuid::new_v4().to_string(); let run_id = Uuid::new_v4().to_string();
let command_hash = sha256_hash(exec.as_bytes()); let command_hash = sha256_hash(exec.as_bytes());
// Session mode or per-invocation: // ── Determine AC mode ────────────────────────────────────
let (ac_id, using_session) = match std::env::var("GSAP_SESSION_AC") { //
Ok(session_ac) => (session_ac, true), // Priority:
Err(_) => { // 1. Pre-issued AC (GSAP_AC env or --ac flag)
eprintln!("gsh: requesting AC for '{}'", exec); // 2. Session AC (GSAP_SESSION_AC env)
let id = request_ac(&client, &base, &args.operation, &command_hash, &corpus)?; // 3. Inline request (fallback)
eprintln!("gsh: AC issued — {}", &id[..8.min(id.len())]);
(id, false) let pre_issued_ac = args
.ac
.clone()
.or_else(|| std::env::var("GSAP_AC").ok());
let (ac_id, ac_mode) = if let Some(ac_json) = pre_issued_ac {
// Mode 1: Pre-issued AC — validate and consume
let ac = validate_pre_issued_ac(&ac_json, &corpus).map_err(|code| {
// Return the exit code as an error
anyhow::anyhow!("AC validation failed (exit {})", code)
})?;
eprintln!(
"gsh: pre-issued AC — {}",
&ac.context_id[..8.min(ac.context_id.len())]
);
if let Some(ref p) = ac.principal {
if let Some(ref did) = p.did {
eprintln!("gsh: principal — {}", did);
} }
}
(ac.context_id, "pre-issued".to_string())
} else if let Ok(session_ac) = std::env::var("GSAP_SESSION_AC") {
// Mode 2: Session AC — reuse
(session_ac, "session".to_string())
} else {
// Mode 3: Inline request — fallback
let base = args
.broker_url
.clone()
.or_else(|| std::env::var("GSAP_BROKER_URL").ok())
.context("No AC provided. Set GSAP_AC, GSAP_SESSION_AC, or GSAP_BROKER_URL")?;
let client = build_client(&token)?;
eprintln!("gsh: requesting AC for '{}'", exec);
let id = request_ac_inline(&client, &base, &args.operation, &command_hash, &corpus)?;
eprintln!("gsh: AC issued — {}", &id[..8.min(id.len())]);
(id, "inline".to_string())
}; };
// ── Corpus gate ──────────────────────────────────────────
if let Err(code) = corpus_check(&corpus, exec) {
return Ok(code);
}
if args.dry_run { if args.dry_run {
eprintln!("gsh: dry-run — skipping exec"); eprintln!("gsh: dry-run — skipping exec");
if args.json { if args.json {
println!("{}", serde_json::to_string_pretty(&GshOutput { println!(
exit_code: 0, stdout: String::new(), stderr: String::new(), "{}",
ac_id, session_ac: using_session, cr_id: String::new(), serde_json::to_string_pretty(&GshOutput {
chronicle_cid: String::new(), command_hash, run_id, exit_code: 0,
})?); stdout: String::new(),
stderr: String::new(),
ac_id,
ac_mode,
cr_id: String::new(),
chronicle_cid: String::new(),
command_hash,
run_id,
corpus_cid: corpus,
})?
);
} }
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(exec)
.output()
.context("exec failed")?;
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 (in session mode, this may fail on "already consumed" — that's a broker gap, not gsh's fault): // ── Post CR ──────────────────────────────────────────────
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 {
// Session mode: post CR but accept failure gracefully "failed"
// (broker currently marks AC consumed after first CR) };
let base = args
.broker_url
.or_else(|| std::env::var("GSAP_BROKER_URL").ok());
let (cr_id, chronicle_cid) = if let Some(base) = base {
let client = build_client(&token)?;
let (id, cid) = post_cr(&client, &base, &ac_id, outcome); let (id, cid) = post_cr(&client, &base, &ac_id, outcome);
if id.is_empty() && cid.is_empty() { if ac_mode == "session" && id.is_empty() && cid.is_empty() {
eprintln!("gsh: session CR not recorded (broker session support pending)"); eprintln!("gsh: session CR not recorded (broker session support pending)");
} }
(id, cid) (id, cid)
} else {
eprintln!("gsh: no GSAP_BROKER_URL — CR not posted");
(String::new(), String::new())
}; };
// Output: // ── Output ───────────────────────────────────────────────
if args.json { if args.json {
println!("{}", serde_json::to_string_pretty(&GshOutput { println!(
exit_code, stdout: stdout_str, stderr: stderr_str, "{}",
ac_id, session_ac: using_session, cr_id, chronicle_cid, command_hash, run_id, serde_json::to_string_pretty(&GshOutput {
})?); exit_code,
stdout: stdout_str,
stderr: stderr_str,
ac_id,
ac_mode,
cr_id,
chronicle_cid,
command_hash,
run_id,
corpus_cid: corpus,
})?
);
} else { } else {
print!("{}", stdout_str); print!("{}", stdout_str);
eprint!("{}", stderr_str); eprint!("{}", stderr_str);
if !chronicle_cid.is_empty() { if !chronicle_cid.is_empty() {
eprintln!("gsh: CID {}", &chronicle_cid[..40.min(chronicle_cid.len())]); eprintln!(
"gsh: CID {}",
&chronicle_cid[..40.min(chronicle_cid.len())]
);
} }
} }