feat: gsh human mode — interactive governed shell with reedline

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) <noreply@anthropic.com>
This commit is contained in:
Tyler J King 2026-04-02 15:44:34 -04:00
parent 919d8accde
commit 63a6c0c520
10 changed files with 997 additions and 7 deletions

298
Cargo.lock generated
View file

@ -73,6 +73,17 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" 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]] [[package]]
name = "autocfg" name = "autocfg"
version = "1.5.0" version = "1.5.0"
@ -90,6 +101,9 @@ name = "bitflags"
version = "2.11.0" version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
dependencies = [
"serde_core",
]
[[package]] [[package]]
name = "block-buffer" name = "block-buffer"
@ -137,6 +151,7 @@ dependencies = [
"iana-time-zone", "iana-time-zone",
"js-sys", "js-sys",
"num-traits", "num-traits",
"serde",
"wasm-bindgen", "wasm-bindgen",
"windows-link", "windows-link",
] ]
@ -187,6 +202,16 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" 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]] [[package]]
name = "core-foundation" name = "core-foundation"
version = "0.9.4" version = "0.9.4"
@ -222,6 +247,32 @@ dependencies = [
"libc", "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]] [[package]]
name = "crypto-common" name = "crypto-common"
version = "0.1.7" version = "0.1.7"
@ -274,6 +325,12 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "either"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]] [[package]]
name = "encoding_rs" name = "encoding_rs"
version = "0.8.35" version = "0.8.35"
@ -305,6 +362,17 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" 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]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.9" version = "0.1.9"
@ -435,8 +503,12 @@ name = "gsh"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"atty",
"chrono",
"clap", "clap",
"colored",
"libgsh", "libgsh",
"reedline",
"serde", "serde",
"serde_json", "serde_json",
"uuid", "uuid",
@ -482,6 +554,15 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 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]] [[package]]
name = "hex" name = "hex"
version = "0.4.3" version = "0.4.3"
@ -772,6 +853,15 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itertools"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569"
dependencies = [
"either",
]
[[package]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.18" version = "1.0.18"
@ -790,6 +880,12 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]] [[package]]
name = "leb128fmt" name = "leb128fmt"
version = "0.1.0" version = "0.1.0"
@ -826,6 +922,12 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "linux-raw-sys"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
[[package]] [[package]]
name = "linux-raw-sys" name = "linux-raw-sys"
version = "0.12.1" version = "0.12.1"
@ -838,6 +940,15 @@ version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" 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]] [[package]]
name = "log" name = "log"
version = "0.4.29" version = "0.4.29"
@ -863,6 +974,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
dependencies = [ dependencies = [
"libc", "libc",
"log",
"wasi", "wasi",
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
@ -884,6 +996,15 @@ dependencies = [
"tempfile", "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]] [[package]]
name = "num-traits" name = "num-traits"
version = "0.2.19" version = "0.2.19"
@ -955,6 +1076,29 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" 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]] [[package]]
name = "percent-encoding" name = "percent-encoding"
version = "2.3.2" version = "2.3.2"
@ -1016,6 +1160,15 @@ 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_syscall"
version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags",
]
[[package]] [[package]]
name = "redox_users" name = "redox_users"
version = "0.4.6" version = "0.4.6"
@ -1027,6 +1180,26 @@ dependencies = [
"thiserror 1.0.69", "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]] [[package]]
name = "reqwest" name = "reqwest"
version = "0.12.28" version = "0.12.28"
@ -1083,6 +1256,19 @@ dependencies = [
"windows-sys 0.52.0", "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]] [[package]]
name = "rustix" name = "rustix"
version = "1.1.4" version = "1.1.4"
@ -1092,7 +1278,7 @@ dependencies = [
"bitflags", "bitflags",
"errno", "errno",
"libc", "libc",
"linux-raw-sys", "linux-raw-sys 0.12.1",
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
@ -1150,6 +1336,12 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "security-framework" name = "security-framework"
version = "3.7.0" version = "3.7.0"
@ -1251,6 +1443,37 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" 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]] [[package]]
name = "slab" name = "slab"
version = "0.4.12" version = "0.4.12"
@ -1279,12 +1502,40 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" 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]] [[package]]
name = "strsim" name = "strsim"
version = "0.11.1" version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 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]] [[package]]
name = "subtle" name = "subtle"
version = "2.6.1" version = "2.6.1"
@ -1352,7 +1603,7 @@ dependencies = [
"fastrand", "fastrand",
"getrandom 0.4.2", "getrandom 0.4.2",
"once_cell", "once_cell",
"rustix", "rustix 1.1.4",
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
@ -1535,6 +1786,18 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" 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]] [[package]]
name = "unicode-xid" name = "unicode-xid"
version = "0.2.6" version = "0.2.6"
@ -1594,6 +1857,15 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "vte"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "want" name = "want"
version = "0.3.1" version = "0.3.1"
@ -1726,6 +1998,28 @@ dependencies = [
"wasm-bindgen", "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]] [[package]]
name = "windows-core" name = "windows-core"
version = "0.62.2" version = "0.62.2"

View file

@ -20,3 +20,6 @@ hex = "0.4"
uuid = { version = "1", features = ["v4"] } uuid = { version = "1", features = ["v4"] }
chrono = "0.4" chrono = "0.4"
dirs = "5" dirs = "5"
reedline = "0.38"
colored = "2"
atty = "0.2"

View file

@ -15,3 +15,7 @@ anyhow = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }
chrono = { workspace = true }
reedline = { workspace = true }
colored = { workspace = true }
atty = { workspace = true }

238
gsh/src/human.rs Normal file
View file

@ -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<String>,
token: &Option<String>,
) -> 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
}
}
}

View file

@ -2,6 +2,14 @@
//! //!
//! Thin CLI wrapper around libgsh. Handles arg parsing, mode detection, //! Thin CLI wrapper around libgsh. Handles arg parsing, mode detection,
//! output formatting, and exit code mapping. All governance logic is in libgsh. //! 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 anyhow::{Context, Result};
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
@ -11,6 +19,7 @@ use uuid::Uuid;
use libgsh::cr::{build_client, post_cr, request_ac_inline}; use libgsh::cr::{build_client, post_cr, request_ac_inline};
use libgsh::registry::ConsumedRegistry; use libgsh::registry::ConsumedRegistry;
use libgsh::session::SessionState;
use libgsh::{corpus_check, sha256_hash}; use libgsh::{corpus_check, sha256_hash};
// ── CLI ─────────────────────────────────────────────────────── // ── CLI ───────────────────────────────────────────────────────
@ -94,9 +103,9 @@ fn run(args: Args) -> Result<i32> {
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();
// ── Ungoverned mode ────────────────────────────────────── // ── Ungoverned machine mode ────────────────────────────────
if args.ungoverned { if args.ungoverned && args.exec.is_some() {
let exec = args.exec.as_ref().context("--ungoverned requires --exec")?; let exec = args.exec.as_ref().unwrap();
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 code = output.status.code().unwrap_or(1); let code = output.status.code().unwrap_or(1);
if args.json { if args.json {
@ -160,8 +169,46 @@ fn run(args: Args) -> Result<i32> {
}; };
} }
// ── Exec mode ──────────────────────────────────────────── // ── Mode detection ─────────────────────────────────────────
let exec = args.exec.as_ref().context("Provide --exec 'command' or a subcommand")?; // 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 run_id = Uuid::new_v4().to_string();
let command_hash = sha256_hash(exec.as_bytes()); let command_hash = sha256_hash(exec.as_bytes());

View file

@ -14,5 +14,6 @@ hex = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
dirs = { workspace = true } dirs = { workspace = true }
[dev-dependencies] [dev-dependencies]
tempfile = "3" tempfile = "3"

142
libgsh/src/classifier.rs Normal file
View file

@ -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
));
}
}

View file

@ -1,14 +1,18 @@
pub mod ac; pub mod ac;
pub mod classifier;
pub mod config; pub mod config;
pub mod corpus; pub mod corpus;
pub mod cr; pub mod cr;
pub mod registry; pub mod registry;
pub mod session;
pub use ac::{AcValidationError, AuthorizationContext}; pub use ac::{AcValidationError, AuthorizationContext};
pub use classifier::{classify_command, CommandClass, FREE_COMMANDS};
pub use config::GshConfig; pub use config::GshConfig;
pub use corpus::{corpus_check, CorpusCheckResult}; pub use corpus::{corpus_check, CorpusCheckResult};
pub use cr::{post_cr, CrResult}; pub use cr::{post_cr, CrResult};
pub use registry::ConsumedRegistry; pub use registry::ConsumedRegistry;
pub use session::SessionState;
/// Compute SHA-256 hash with "sha256:" prefix. /// Compute SHA-256 hash with "sha256:" prefix.
pub fn sha256_hash(data: &[u8]) -> String { pub fn sha256_hash(data: &[u8]) -> String {

84
libgsh/src/session.rs Normal file
View file

@ -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<chrono::Utc>,
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
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())
}

173
scripts/bootstrap-jumphost.sh Executable file
View file

@ -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."