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:
parent
919d8accde
commit
63a6c0c520
10 changed files with 997 additions and 7 deletions
298
Cargo.lock
generated
298
Cargo.lock
generated
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
238
gsh/src/human.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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());
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
142
libgsh/src/classifier.rs
Normal 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
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
84
libgsh/src/session.rs
Normal 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
173
scripts/bootstrap-jumphost.sh
Executable 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."
|
||||||
Loading…
Reference in a new issue