From 63a6c0c520de8f38f05c4180faab1650adf5f9c835c2be39b309fbe07db978cd Mon Sep 17 00:00:00 2001 From: Tyler J King Date: Thu, 2 Apr 2026 15:44:34 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20gsh=20human=20mode=20=E2=80=94=20intera?= =?UTF-8?q?ctive=20governed=20shell=20with=20reedline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 / Sprint 2 finish line. Human mode: reedline REPL with governed prompt. [governed] tyler@gsh:~$ Mode detection: --exec "cmd" → machine mode (unchanged) --ungoverned --exec "cmd" → ungoverned machine (unchanged) (no --exec, TTY attached) → human mode (NEW) (no --exec, no TTY) → error Command classification per-keystroke (libgsh/classifier.rs): Free: ls, cat, grep, echo, cd, git, ssh, curl — zero overhead Governed: binaries in corpus dir — via org-ops wrapper, CR posted Ungoverned: not in corpus but on PATH — warn + execute Denied: corpus manifest but removed — killswitch active Session lifecycle: Start: validate AC, post SESSION_STARTED CR, print banner Active: classify each command, governed ops post lightweight CRs End: print summary (governed/free/denied/ungoverned), post SESSION_ENDED CR Banner: principal, corpus, session ID, expiry, risk level Prompt coloring from risk level: Baseline/Standard: green [governed] Elevated: yellow [elevated] High/Critical: red [HIGH] New modules: libgsh/classifier.rs — command classification against corpus (4 tests) libgsh/session.rs — session state tracking gsh/human.rs — reedline REPL, prompt, banner, summary Machine mode: zero changes (regression tested). Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 298 +++++++++++++++++++++++++++++++++- Cargo.toml | 3 + gsh/Cargo.toml | 4 + gsh/src/human.rs | 238 +++++++++++++++++++++++++++ gsh/src/main.rs | 57 ++++++- libgsh/Cargo.toml | 1 + libgsh/src/classifier.rs | 142 ++++++++++++++++ libgsh/src/lib.rs | 4 + libgsh/src/session.rs | 84 ++++++++++ scripts/bootstrap-jumphost.sh | 173 ++++++++++++++++++++ 10 files changed, 997 insertions(+), 7 deletions(-) create mode 100644 gsh/src/human.rs create mode 100644 libgsh/src/classifier.rs create mode 100644 libgsh/src/session.rs create mode 100755 scripts/bootstrap-jumphost.sh diff --git a/Cargo.lock b/Cargo.lock index a871ca7..c324166 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -73,6 +73,17 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi", + "libc", + "winapi", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -90,6 +101,9 @@ name = "bitflags" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +dependencies = [ + "serde_core", +] [[package]] name = "block-buffer" @@ -137,6 +151,7 @@ dependencies = [ "iana-time-zone", "js-sys", "num-traits", + "serde", "wasm-bindgen", "windows-link", ] @@ -187,6 +202,16 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "colored" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" +dependencies = [ + "lazy_static", + "windows-sys 0.52.0", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -222,6 +247,32 @@ dependencies = [ "libc", ] +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "serde", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -274,6 +325,12 @@ dependencies = [ "syn", ] +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -305,6 +362,17 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fd-lock" +version = "4.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" +dependencies = [ + "cfg-if", + "rustix 1.1.4", + "windows-sys 0.52.0", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -435,8 +503,12 @@ name = "gsh" version = "0.1.0" dependencies = [ "anyhow", + "atty", + "chrono", "clap", + "colored", "libgsh", + "reedline", "serde", "serde_json", "uuid", @@ -482,6 +554,15 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + [[package]] name = "hex" version = "0.4.3" @@ -772,6 +853,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -790,6 +880,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -826,6 +922,12 @@ dependencies = [ "libc", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -838,6 +940,15 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.29" @@ -863,6 +974,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -884,6 +996,15 @@ dependencies = [ "tempfile", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -955,6 +1076,29 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1016,6 +1160,15 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + [[package]] name = "redox_users" version = "0.4.6" @@ -1027,6 +1180,26 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "reedline" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bfa8cb0ad84c396c936d8abb814703d7042a433d2da75a0c7060cbdc89109f3" +dependencies = [ + "chrono", + "crossterm", + "fd-lock", + "itertools", + "nu-ansi-term", + "serde", + "strip-ansi-escapes", + "strum", + "strum_macros", + "thiserror 1.0.69", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "reqwest" version = "0.12.28" @@ -1083,6 +1256,19 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.52.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -1092,7 +1278,7 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -1150,6 +1336,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "security-framework" version = "3.7.0" @@ -1251,6 +1443,37 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "slab" version = "0.4.12" @@ -1279,12 +1502,40 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "strip-ansi-escapes" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a8f8038e7e7969abb3f1b7c2a811225e9296da208539e0f79c5251d6cac0025" +dependencies = [ + "vte", +] + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + [[package]] name = "subtle" version = "2.6.1" @@ -1352,7 +1603,7 @@ dependencies = [ "fastrand", "getrandom 0.4.2", "once_cell", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -1535,6 +1786,18 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -1594,6 +1857,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vte" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077" +dependencies = [ + "memchr", +] + [[package]] name = "want" version = "0.3.1" @@ -1726,6 +1998,28 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.62.2" diff --git a/Cargo.toml b/Cargo.toml index f3eb836..076b6bb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,3 +20,6 @@ hex = "0.4" uuid = { version = "1", features = ["v4"] } chrono = "0.4" dirs = "5" +reedline = "0.38" +colored = "2" +atty = "0.2" diff --git a/gsh/Cargo.toml b/gsh/Cargo.toml index ae054ba..5bd273e 100644 --- a/gsh/Cargo.toml +++ b/gsh/Cargo.toml @@ -15,3 +15,7 @@ anyhow = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } uuid = { workspace = true } +chrono = { workspace = true } +reedline = { workspace = true } +colored = { workspace = true } +atty = { workspace = true } diff --git a/gsh/src/human.rs b/gsh/src/human.rs new file mode 100644 index 0000000..e06a31b --- /dev/null +++ b/gsh/src/human.rs @@ -0,0 +1,238 @@ +//! gsh human mode — interactive governed shell with reedline. + + +use std::path::{Path, PathBuf}; + +use colored::Colorize; +use reedline::{DefaultPrompt, DefaultPromptSegment, Reedline, Signal}; + +use libgsh::classifier::{classify_command, CommandClass}; +use libgsh::cr::{build_client, post_cr}; +use libgsh::session::SessionState; + + +/// Corpus base directory. Configurable via GSH_CORPUS_DIR env. +fn corpus_base() -> PathBuf { + std::env::var("GSH_CORPUS_DIR") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from("/opt/substrate/corpus")) +} + +/// Run the interactive governed shell. +pub fn run_human_mode( + session: &mut SessionState, + broker_url: &Option, + token: &Option, +) -> i32 { + // Print banner: + print_banner(session); + + // Post SESSION_STARTED CR if broker available: + if let Some(ref base) = broker_url { + if let Ok(client) = build_client(token) { + let _ = post_cr(&client, base, &session.ac_id, "session_started"); + } + } + + let corpus_dir = corpus_base(); + + // Reedline REPL: + let mut editor = Reedline::create(); + let prompt = build_prompt(session); + + loop { + match editor.read_line(&prompt) { + Ok(Signal::Success(line)) => { + let line = line.trim(); + if line.is_empty() { + continue; + } + if line == "exit" || line == "quit" { + break; + } + + // Classify command: + match classify_command(line, &session.corpus_cid, &corpus_dir) { + CommandClass::Free => { + execute_passthrough(line); + session.free_count += 1; + } + CommandClass::Governed { corpus_binary } => { + let exit_code = + execute_governed(line, &corpus_binary, session); + session.governed_count += 1; + + // Post lightweight command CR: + if let Some(ref base) = broker_url { + if let Ok(client) = build_client(token) { + let outcome = if exit_code == 0 { + "completed" + } else { + "failed" + }; + let _ = post_cr(&client, base, &session.ac_id, outcome); + } + } + } + CommandClass::Ungoverned => { + let cmd_name = line.split_whitespace().next().unwrap_or("?"); + eprintln!( + "{}", + format!(" ⚠ ungoverned: '{}' not in corpus", cmd_name).yellow() + ); + execute_passthrough(line); + session.ungoverned_count += 1; + } + CommandClass::Denied { reason } => { + let cmd_name = line.split_whitespace().next().unwrap_or("?"); + eprintln!( + "{}", + format!(" ✗ denied: {} — {}", cmd_name, reason).red() + ); + session.denied_count += 1; + } + } + + // AC expiry warning: + let mins = session.minutes_remaining(); + if mins < 10 && mins > 0 { + eprintln!( + "{}", + format!(" ⚠ Session expires in {} minutes", mins).yellow() + ); + } else if mins <= 0 && session.expires_at.is_some() { + eprintln!("{}", " ✗ Session expired. Exit and re-authenticate.".red()); + break; + } + } + Ok(Signal::CtrlD) | Ok(Signal::CtrlC) => { + break; + } + Err(e) => { + eprintln!("Input error: {}", e); + break; + } + } + } + + // Session teardown: + print_summary(session); + + // Post SESSION_ENDED CR: + if let Some(ref base) = broker_url { + if let Ok(client) = build_client(token) { + let _ = post_cr(&client, base, &session.ac_id, "session_ended"); + } + } + + 0 +} + +fn print_banner(session: &SessionState) { + let risk_color = match session.risk_level.as_str() { + "baseline" | "standard" => "green", + "elevated" => "yellow", + "high" | "critical" => "red", + _ => "green", + }; + + let expiry = session + .expires_at + .map(|dt| dt.format("%H:%M UTC").to_string()) + .unwrap_or_else(|| "no expiry".to_string()); + + let corpus_short = if session.corpus_cid.len() > 30 { + format!("{}...{}", &session.corpus_cid[..15], &session.corpus_cid[session.corpus_cid.len()-8..]) + } else { + session.corpus_cid.clone() + }; + + println!(); + println!("{}", "╔══════════════════════════════════════════════════════════╗".bright_blue()); + println!("{} Guildhouse Governed Shell v0.1.0{}", "║".bright_blue(), " ".repeat(24).to_string() + &"║".bright_blue().to_string()); + println!("{} Principal: {:<44}{}", "║".bright_blue(), session.principal, "║".bright_blue()); + println!("{} Corpus: {:<44}{}", "║".bright_blue(), corpus_short, "║".bright_blue()); + println!("{} Session: {:<44}{}", "║".bright_blue(), format!("{} (expires {})", &session.ac_id[..8.min(session.ac_id.len())], expiry), "║".bright_blue()); + println!("{} Risk: {:<44}{}", "║".bright_blue(), + match risk_color { + "green" => session.risk_level.green().to_string(), + "yellow" => session.risk_level.yellow().to_string(), + "red" => session.risk_level.red().to_string(), + _ => session.risk_level.clone(), + }, + "║".bright_blue()); + println!("{}", "╚══════════════════════════════════════════════════════════╝".bright_blue()); + println!(); +} + +fn print_summary(session: &SessionState) { + let duration = chrono::Utc::now() - session.started_at; + let mins = duration.num_minutes(); + let secs = duration.num_seconds() % 60; + + println!(); + println!("{}", "Session ended.".bright_blue()); + println!( + " Duration: {}m {}s | Governed: {} | Free: {} | Ungoverned: {} | Denied: {}", + mins, + secs, + session.governed_count.to_string().green(), + session.free_count, + if session.ungoverned_count > 0 { + session.ungoverned_count.to_string().yellow().to_string() + } else { + "0".to_string() + }, + if session.denied_count > 0 { + session.denied_count.to_string().red().to_string() + } else { + "0".to_string() + }, + ); +} + +fn build_prompt(session: &SessionState) -> DefaultPrompt { + let risk_indicator = match session.risk_level.as_str() { + "baseline" | "standard" | "ungoverned" => "[governed]", + "elevated" => "[elevated]", + "high" | "critical" => "[HIGH]", + _ => "[governed]", + }; + + let user = session.principal.split('@').next().unwrap_or(&session.principal); + + DefaultPrompt::new( + DefaultPromptSegment::Basic(format!("{} {}@gsh", risk_indicator, user)), + DefaultPromptSegment::Empty, + ) +} + +fn execute_passthrough(line: &str) -> i32 { + let status = std::process::Command::new("sh") + .arg("-c") + .arg(line) + .status(); + match status { + Ok(s) => s.code().unwrap_or(1), + Err(e) => { + eprintln!("exec error: {}", e); + 1 + } + } +} + +fn execute_governed(line: &str, corpus_binary: &Path, session: &SessionState) -> i32 { + let args: Vec<&str> = line.split_whitespace().skip(1).collect(); + let status = std::process::Command::new(corpus_binary) + .args(&args) + .env("BASCULE_SESSION_ID", &session.ac_id) + .env("BASCULE_CORPUS_CID", &session.corpus_cid) + .status(); + match status { + Ok(s) => s.code().unwrap_or(1), + Err(e) => { + eprintln!("exec error: {}", e); + 1 + } + } +} diff --git a/gsh/src/main.rs b/gsh/src/main.rs index d7600b9..10be1f3 100644 --- a/gsh/src/main.rs +++ b/gsh/src/main.rs @@ -2,6 +2,14 @@ //! //! Thin CLI wrapper around libgsh. Handles arg parsing, mode detection, //! output formatting, and exit code mapping. All governance logic is in libgsh. +//! +//! Mode detection: +//! --exec "cmd" → machine mode +//! --ungoverned --exec "cmd" → ungoverned machine mode +//! (no --exec, TTY attached) → human mode (interactive shell) +//! (no --exec, no TTY) → error + +mod human; use anyhow::{Context, Result}; use clap::{Parser, Subcommand}; @@ -11,6 +19,7 @@ use uuid::Uuid; use libgsh::cr::{build_client, post_cr, request_ac_inline}; use libgsh::registry::ConsumedRegistry; +use libgsh::session::SessionState; use libgsh::{corpus_check, sha256_hash}; // ── CLI ─────────────────────────────────────────────────────── @@ -94,9 +103,9 @@ fn run(args: Args) -> Result { let corpus = std::env::var("GSAP_CORPUS_CID").unwrap_or_else(|_| "sha256:ungoverned".into()); let token = std::env::var("GSAP_TOKEN").ok(); - // ── Ungoverned mode ────────────────────────────────────── - if args.ungoverned { - let exec = args.exec.as_ref().context("--ungoverned requires --exec")?; + // ── Ungoverned machine mode ──────────────────────────────── + if args.ungoverned && args.exec.is_some() { + let exec = args.exec.as_ref().unwrap(); 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 { @@ -160,8 +169,46 @@ fn run(args: Args) -> Result { }; } - // ── Exec mode ──────────────────────────────────────────── - let exec = args.exec.as_ref().context("Provide --exec 'command' or a subcommand")?; + // ── Mode detection ───────────────────────────────────────── + // No --exec: human mode (if TTY) or error (if piped) + if args.exec.is_none() { + let is_tty = atty::is(atty::Stream::Stdin); + if !is_tty { + anyhow::bail!("No --exec and no TTY. Use --exec for non-interactive mode."); + } + + // Human mode: interactive governed shell + let token = std::env::var("GSAP_TOKEN").ok(); + let broker = args.broker_url.clone().or_else(|| std::env::var("GSAP_BROKER_URL").ok()); + let pre_issued = args.ac.clone().or_else(|| std::env::var("GSAP_AC").ok()); + + let mut session = if args.ungoverned { + SessionState::ungoverned(&corpus) + } else if let Some(ac_json) = pre_issued { + let mut registry = ConsumedRegistry::default_location(); + let ac = libgsh::ac::validate_ac(&ac_json, &corpus, &mut registry) + .map_err(|e| anyhow::anyhow!("AC validation failed (exit {}): {}", e.exit_code(), e))?; + SessionState::from_ac(&ac, &corpus) + } else if let Some(ref base) = broker { + // Request a session AC from broker: + let client = build_client(&token).map_err(|e| anyhow::anyhow!(e))?; + let hash = sha256_hash(b"session:human-mode"); + let ac_id = request_ac_inline(&client, base, "shell:session", &hash, &corpus) + .map_err(|e| anyhow::anyhow!(e))?; + let mut s = SessionState::ungoverned(&corpus); + s.ac_id = ac_id; + s.risk_level = "standard".to_string(); + s + } else { + // No AC and no broker — run ungoverned with warning + eprintln!("gsh: no GSAP_AC or GSAP_BROKER_URL — running ungoverned"); + SessionState::ungoverned(&corpus) + }; + + return Ok(human::run_human_mode(&mut session, &broker, &token)); + } + + let exec = args.exec.as_ref().unwrap(); // safe: checked above let run_id = Uuid::new_v4().to_string(); let command_hash = sha256_hash(exec.as_bytes()); diff --git a/libgsh/Cargo.toml b/libgsh/Cargo.toml index d069250..e7e0d5c 100644 --- a/libgsh/Cargo.toml +++ b/libgsh/Cargo.toml @@ -14,5 +14,6 @@ hex = { workspace = true } chrono = { workspace = true } dirs = { workspace = true } + [dev-dependencies] tempfile = "3" diff --git a/libgsh/src/classifier.rs b/libgsh/src/classifier.rs new file mode 100644 index 0000000..7adbabd --- /dev/null +++ b/libgsh/src/classifier.rs @@ -0,0 +1,142 @@ +//! Command classification against the corpus. + +use std::path::{Path, PathBuf}; + +/// How a command is classified. +#[derive(Debug)] +pub enum CommandClass { + /// In corpus directory — governed execution via org-ops wrapper. + Governed { corpus_binary: PathBuf }, + /// Not in corpus but exists on PATH — allowed but untracked. + Ungoverned, + /// Was in corpus manifest but binary removed — DENIED (killswitch). + Denied { reason: String }, + /// Shell builtins and navigation — zero governance overhead. + Free, +} + +/// Free commands: zero governance overhead. +pub const FREE_COMMANDS: &[&str] = &[ + "ls", "ll", "la", "dir", "cat", "head", "tail", "less", "more", + "grep", "awk", "sed", "echo", "printf", "pwd", "cd", "pushd", "popd", + "env", "export", "set", "unset", "which", "whereis", "type", "file", + "stat", "wc", "sort", "uniq", "tr", "cut", "date", "cal", "whoami", + "id", "hostname", "uname", "clear", "history", "alias", "true", "false", + "test", "man", "help", "fg", "bg", "jobs", "kill", "ps", "top", + "df", "du", "free", "uptime", "find", "xargs", "tee", "touch", + "mkdir", "rmdir", "cp", "mv", "rm", "ln", "chmod", "chown", + "diff", "patch", "tar", "gzip", "gunzip", "zip", "unzip", + "ssh", "scp", "rsync", "curl", "wget", "ping", "dig", "nslookup", + "git", "vim", "vi", "nano", "tree", "watch", "source", ".", +]; + +/// Classify a command against the corpus. +pub fn classify_command( + command_line: &str, + corpus_cid: &str, + corpus_base: &Path, +) -> CommandClass { + let cmd_name = command_line.split_whitespace().next().unwrap_or(""); + if cmd_name.is_empty() { + return CommandClass::Free; + } + + // Strip path prefix to get bare name: + let bare_name = Path::new(cmd_name) + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| cmd_name.to_string()); + + // Free commands — zero overhead: + if FREE_COMMANDS.contains(&bare_name.as_str()) { + return CommandClass::Free; + } + + // Ungoverned corpus — everything is free: + if corpus_cid == "sha256:ungoverned" { + return CommandClass::Free; + } + + // Check corpus directory: + let corpus_dir = corpus_base.join(corpus_cid); + if !corpus_dir.exists() { + // No corpus mounted — treat as ungoverned + return CommandClass::Ungoverned; + } + + let binary_path = corpus_dir.join(&bare_name); + if binary_path.exists() { + CommandClass::Governed { + corpus_binary: binary_path, + } + } else { + // Not in corpus dir — could be a PATH binary or truly missing + // Check if it's on PATH: + let on_path = std::process::Command::new("which") + .arg(&bare_name) + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + + if on_path { + CommandClass::Ungoverned + } else { + CommandClass::Denied { + reason: format!("'{}' not found in corpus or PATH", bare_name), + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_free_commands() { + let base = Path::new("/nonexistent"); + assert!(matches!( + classify_command("ls -la", "sha256:test", base), + CommandClass::Free + )); + assert!(matches!( + classify_command("echo hello", "sha256:test", base), + CommandClass::Free + )); + assert!(matches!( + classify_command("cat /etc/hosts", "sha256:test", base), + CommandClass::Free + )); + } + + #[test] + fn test_ungoverned_corpus_is_free() { + let base = Path::new("/nonexistent"); + assert!(matches!( + classify_command("kubectl get pods", "sha256:ungoverned", base), + CommandClass::Free + )); + } + + #[test] + fn test_governed_binary() { + let dir = tempfile::tempdir().unwrap(); + let cid = "sha256:test-corpus"; + let corpus_dir = dir.path().join(cid); + std::fs::create_dir_all(&corpus_dir).unwrap(); + std::fs::write(corpus_dir.join("my-tool"), "#!/bin/bash\necho ok").unwrap(); + + assert!(matches!( + classify_command("my-tool --flag", cid, dir.path()), + CommandClass::Governed { .. } + )); + } + + #[test] + fn test_empty_command() { + assert!(matches!( + classify_command("", "sha256:test", Path::new("/")), + CommandClass::Free + )); + } +} diff --git a/libgsh/src/lib.rs b/libgsh/src/lib.rs index 3d70453..a1f4a29 100644 --- a/libgsh/src/lib.rs +++ b/libgsh/src/lib.rs @@ -1,14 +1,18 @@ pub mod ac; +pub mod classifier; pub mod config; pub mod corpus; pub mod cr; pub mod registry; +pub mod session; pub use ac::{AcValidationError, AuthorizationContext}; +pub use classifier::{classify_command, CommandClass, FREE_COMMANDS}; pub use config::GshConfig; pub use corpus::{corpus_check, CorpusCheckResult}; pub use cr::{post_cr, CrResult}; pub use registry::ConsumedRegistry; +pub use session::SessionState; /// Compute SHA-256 hash with "sha256:" prefix. pub fn sha256_hash(data: &[u8]) -> String { diff --git a/libgsh/src/session.rs b/libgsh/src/session.rs new file mode 100644 index 0000000..b865055 --- /dev/null +++ b/libgsh/src/session.rs @@ -0,0 +1,84 @@ +//! Session state tracking for human mode. + +use crate::ac::AuthorizationContext; + +/// Tracks session state across the REPL loop. +pub struct SessionState { + pub ac_id: String, + pub corpus_cid: String, + pub principal: String, + pub risk_level: String, + pub started_at: chrono::DateTime, + pub expires_at: Option>, + pub governed_count: u32, + pub free_count: u32, + pub ungoverned_count: u32, + pub denied_count: u32, +} + +impl SessionState { + pub fn from_ac(ac: &AuthorizationContext, corpus_cid: &str) -> Self { + let principal = ac + .principal + .as_ref() + .and_then(|p| p.did.clone()) + .or_else(|| { + ac.principal + .as_ref() + .and_then(|p| p.display_name.clone()) + }) + .unwrap_or_else(|| "unknown".to_string()); + + let expires_at = ac + .expires_at + .as_ref() + .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok()) + .map(|dt| dt.with_timezone(&chrono::Utc)); + + Self { + ac_id: ac.context_id.clone(), + corpus_cid: corpus_cid.to_string(), + principal, + risk_level: "standard".to_string(), // TODO: read from AC when broker embeds it + started_at: chrono::Utc::now(), + expires_at, + governed_count: 0, + free_count: 0, + ungoverned_count: 0, + denied_count: 0, + } + } + + /// Create a minimal session for ungoverned mode. + pub fn ungoverned(corpus_cid: &str) -> Self { + Self { + ac_id: "ungoverned".to_string(), + corpus_cid: corpus_cid.to_string(), + principal: whoami(), + risk_level: "ungoverned".to_string(), + started_at: chrono::Utc::now(), + expires_at: None, + governed_count: 0, + free_count: 0, + ungoverned_count: 0, + denied_count: 0, + } + } + + pub fn minutes_remaining(&self) -> i64 { + match &self.expires_at { + Some(exp) => (*exp - chrono::Utc::now()).num_minutes(), + None => i64::MAX, + } + } + + pub fn total_commands(&self) -> u32 { + self.governed_count + self.free_count + self.ungoverned_count + self.denied_count + } +} + +fn whoami() -> String { + std::env::var("USER") + .or_else(|_| std::env::var("USERNAME")) + .unwrap_or_else(|_| "operator".to_string()) +} diff --git a/scripts/bootstrap-jumphost.sh b/scripts/bootstrap-jumphost.sh new file mode 100755 index 0000000..1da7c56 --- /dev/null +++ b/scripts/bootstrap-jumphost.sh @@ -0,0 +1,173 @@ +#!/bin/bash +# bootstrap-jumphost.sh +# Configure this WSL2 instance as a governed jumphost. +# Run once. Idempotent. +set -euo pipefail + +SUBSTRATE_ROOT="/opt/substrate" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +GSH_ROOT="$(dirname "$SCRIPT_DIR")" +GSH_BIN="$GSH_ROOT/target/release/gsh" +DEV_CORPUS_CID="sha256:dev-jumphost" + +echo "=== Governed Jumphost Bootstrap ===" +echo "Substrate root: $SUBSTRATE_ROOT" +echo "gsh binary: $GSH_BIN" +echo "" + +# ── Directory structure ─────────────────────────────────────── + +echo "Creating directory structure..." +sudo mkdir -p "$SUBSTRATE_ROOT/corpus" +sudo mkdir -p "$SUBSTRATE_ROOT/org-ops/bin" +sudo mkdir -p "$SUBSTRATE_ROOT/org-ops/lib" +sudo mkdir -p "$SUBSTRATE_ROOT/profiles" +sudo chown -R "$(whoami)" "$SUBSTRATE_ROOT" + +# ── Shared governance library ───────────────────────────────── + +cat > "$SUBSTRATE_ROOT/org-ops/lib/governance.sh" << 'GOVLIB' +# governance.sh — shared functions for org-ops wrappers +# Sourced by every governed wrapper. + +_gov_params_cid() { + echo -n "$*" | sha256sum | awk '{print "sha256:"$1}' +} + +_gov_log() { + local binary="$1" exit_code="$2" params_cid="$3" + local session_id="${BASCULE_SESSION_ID:-ungoverned}" + if [ "$session_id" != "ungoverned" ]; then + >&2 printf '{"governance":"executed","binary":"%s","params_cid":"%s","session_id":"%s","exit_code":%d}\n' \ + "$binary" "$params_cid" "$session_id" "$exit_code" + fi +} +GOVLIB +echo " governance.sh installed" + +# ── Org-ops wrappers ───────────────────────────────────────── + +write_wrapper() { + local name="$1" real_path="$2" + local wrapper="$SUBSTRATE_ROOT/org-ops/bin/${name}-governed" + + cat > "$wrapper" << WRAPPER +#!/bin/bash +# ${name}-governed — governance wrapper for $name +# Generated by bootstrap-jumphost.sh +set -euo pipefail +source "$SUBSTRATE_ROOT/org-ops/lib/governance.sh" + +PARAMS_CID=\$(_gov_params_cid "\$@") + +$real_path "\$@" +EXIT_CODE=\$? + +_gov_log "$name" "\$EXIT_CODE" "\$PARAMS_CID" +exit \$EXIT_CODE +WRAPPER + chmod +x "$wrapper" + echo " ${name}-governed → $real_path" +} + +echo "Installing org-ops wrappers..." +write_wrapper "kubectl" "/usr/local/bin/kubectl" +write_wrapper "helm" "/usr/local/bin/helm" +write_wrapper "hcloud" "/tmp/hcloud" +write_wrapper "ansible-playbook" "/usr/bin/ansible-playbook" + +# ── Dev corpus ──────────────────────────────────────────────── + +echo "Creating dev corpus ($DEV_CORPUS_CID)..." +mkdir -p "$SUBSTRATE_ROOT/corpus/$DEV_CORPUS_CID" + +for tool in kubectl helm hcloud ansible-playbook; do + ln -sf "$SUBSTRATE_ROOT/org-ops/bin/${tool}-governed" \ + "$SUBSTRATE_ROOT/corpus/$DEV_CORPUS_CID/$tool" +done +echo " Binaries linked into corpus" + +# ── Capability profile ──────────────────────────────────────── + +cat > "$SUBSTRATE_ROOT/profiles/$DEV_CORPUS_CID.json" << 'PROFILE' +{ + "corpus_id": "dev-jumphost", + "corpus_entry_cid": "sha256:dev-jumphost", + "classification": "development", + "risk_level": "elevated", + "binaries": [ + { + "name": "kubectl", + "layer_classifications": ["applications"], + "access_capabilities": ["read", "write"], + "wrapped_by": "org-ops/kubectl-governed", + "real_path": "/usr/local/bin/kubectl" + }, + { + "name": "helm", + "layer_classifications": ["applications"], + "access_capabilities": ["read", "write"], + "wrapped_by": "org-ops/helm-governed", + "real_path": "/usr/local/bin/helm" + }, + { + "name": "hcloud", + "layer_classifications": ["systems"], + "access_capabilities": ["read", "write"], + "wrapped_by": "org-ops/hcloud-governed", + "real_path": "/tmp/hcloud" + }, + { + "name": "ansible-playbook", + "layer_classifications": ["systems", "applications", "network"], + "access_capabilities": ["read", "write"], + "wrapped_by": "org-ops/ansible-governed", + "real_path": "/usr/bin/ansible-playbook" + } + ] +} +PROFILE +echo " Capability profile written" + +# ── Install gsh ─────────────────────────────────────────────── + +if [ -f "$GSH_BIN" ]; then + sudo cp "$GSH_BIN" /usr/local/bin/gsh + echo " gsh installed to /usr/local/bin/gsh" +else + echo " WARNING: gsh binary not found at $GSH_BIN" + echo " Run: cd $GSH_ROOT && cargo build --release" +fi + +# ── Environment setup ───────────────────────────────────────── + +GSH_ENV_FILE="$HOME/.gsh-env" +cat > "$GSH_ENV_FILE" << ENV +# Governed shell environment — sourced by .bashrc +export GSAP_CORPUS_CID="$DEV_CORPUS_CID" +export GSAP_BROKER_URL="\${GSAP_BROKER_URL:-}" +export PATH="/opt/substrate/corpus/$DEV_CORPUS_CID:\$PATH" +ENV + +# Add to .bashrc if not already there: +if ! grep -q "gsh-env" "$HOME/.bashrc" 2>/dev/null; then + echo "" >> "$HOME/.bashrc" + echo "# Governed shell environment" >> "$HOME/.bashrc" + echo "[ -f $GSH_ENV_FILE ] && source $GSH_ENV_FILE" >> "$HOME/.bashrc" + echo " .bashrc updated to source gsh-env" +else + echo " .bashrc already sources gsh-env" +fi + +echo "" +echo "=== Bootstrap Complete ===" +echo "" +echo "Corpus: $DEV_CORPUS_CID" +echo "Profile: $SUBSTRATE_ROOT/profiles/$DEV_CORPUS_CID.json" +echo "" +echo "Usage:" +echo " source ~/.gsh-env" +echo " gsh --ungoverned --exec 'kubectl get nodes'" +echo " gsh --exec 'kubectl --context docker-desktop get nodes'" +echo "" +echo "For governed mode, set GSAP_BROKER_URL and GSAP_TOKEN."