Compare commits
6 commits
d0b9ca0e6a
...
7c84854222
| Author | SHA256 | Date | |
|---|---|---|---|
| 7c84854222 | |||
| 061e2206ea | |||
| 88840ae620 | |||
| f810537581 | |||
| 91f027ae61 | |||
| 13b393a7f1 |
18 changed files with 1396 additions and 62 deletions
51
CLAUDE.md
Normal file
51
CLAUDE.md
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
# CLAUDE.md — gsh
|
||||||
|
|
||||||
|
The governed shell. Operator-facing CLI that bascule launches after
|
||||||
|
OIDC → DID derivation; the binary that consumes `GSH_*` env vars,
|
||||||
|
loads the AC, validates the corpus, and runs governed commands.
|
||||||
|
Substrate-level (carries the Guildhouse brand intentionally per
|
||||||
|
`TODO.md` Layout Principle).
|
||||||
|
|
||||||
|
## Crates
|
||||||
|
|
||||||
|
- **gsh** — The governed shell binary itself (`GCAP-SPEC-SHELLBOUND-
|
||||||
|
SDK-0001`). Consumes `GSH_DID`, `GSH_ACCORD_HASH`, `GSH_SHELL_CLASS`,
|
||||||
|
`GSH_POSTURE_LEVEL`, `GSH_CAPABILITY_SET` from bascule + propagates
|
||||||
|
to subprocesses via `org-ops-core::context::GshContext` headers.
|
||||||
|
- **libgsh** — Library: AC validation, capability-request building,
|
||||||
|
corpus gate. The shell binary's reusable surface.
|
||||||
|
|
||||||
|
## Cross-workspace dependencies
|
||||||
|
|
||||||
|
**Consumes:** `guildhouse-did` (Did parsing/derivation),
|
||||||
|
`bascule-workspace/bascule-core` (`AuthorizationContext` shape),
|
||||||
|
`org-ops-core` (env-var contract for child-process governance
|
||||||
|
threading), `forge-core::shell_context` (re-exported
|
||||||
|
`CorpusCapabilityCeiling` for runtime intersection).
|
||||||
|
|
||||||
|
**Consumed by:** the operator's interactive session — invoked by
|
||||||
|
bascule-shell after OIDC auth, by `dev-environment/bascule-local.toml`
|
||||||
|
locally on WSL2.
|
||||||
|
|
||||||
|
## Build / Test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CARGO_TARGET_DIR=target-tking cargo build --workspace
|
||||||
|
CARGO_TARGET_DIR=target-tking cargo test --workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architectural notes
|
||||||
|
|
||||||
|
- **`GSH_*` env contract** is the load-bearing interop with bascule
|
||||||
|
(which sets them) and forge-fuse (which reads them via
|
||||||
|
`forge-fuse::ShellContext::from_env`). The contract:
|
||||||
|
`GSH_DID`, `GSH_ACCORD_HASH`, `GSH_SHELL_CLASS=Application|System`,
|
||||||
|
`GSH_POSTURE_LEVEL` (1..=5 DEFCON), `GSH_CAPABILITY_SET` (hex
|
||||||
|
bitmask `0x{:08x}`).
|
||||||
|
- **Phase 3 forge-fuse (shell-IS-session)** consumes these env vars
|
||||||
|
to compute the per-mount effective capability via
|
||||||
|
`manifest_cap ∩ shell_cap ∩ corpus_cap`.
|
||||||
|
- **Local dev environment** at `dev-environment/` shows the WSL2 +
|
||||||
|
Bascule + gsh + Hetzner Keycloak + Entra federation flow end-to-end.
|
||||||
|
- The brand-bound `gsh` name (vs a `substrate-shell`) is preserved
|
||||||
|
intentionally — revisit when the substrate brand solidifies.
|
||||||
362
Cargo.lock
generated
362
Cargo.lock
generated
|
|
@ -67,6 +67,17 @@ version = "1.0.102"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-trait"
|
||||||
|
version = "0.1.89"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "atomic-waker"
|
name = "atomic-waker"
|
||||||
version = "1.1.2"
|
version = "1.1.2"
|
||||||
|
|
@ -90,12 +101,34 @@ version = "1.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base-x"
|
||||||
|
version = "0.2.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4cbbc9d0964165b47557570cce6c952866c2678457aca742aafc9fb771d30270"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base256emoji"
|
||||||
|
version = "1.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b5e9430d9a245a77c92176e649af6e275f20839a48389859d1661e9a128d077c"
|
||||||
|
dependencies = [
|
||||||
|
"const-str",
|
||||||
|
"match-lookup",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "base64"
|
name = "base64"
|
||||||
version = "0.22.1"
|
version = "0.22.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base64ct"
|
||||||
|
version = "1.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "2.11.0"
|
version = "2.11.0"
|
||||||
|
|
@ -142,6 +175,12 @@ version = "1.0.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg_aliases"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chrono"
|
name = "chrono"
|
||||||
version = "0.4.44"
|
version = "0.4.44"
|
||||||
|
|
@ -156,6 +195,33 @@ dependencies = [
|
||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ciborium"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e"
|
||||||
|
dependencies = [
|
||||||
|
"ciborium-io",
|
||||||
|
"ciborium-ll",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ciborium-io"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ciborium-ll"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9"
|
||||||
|
dependencies = [
|
||||||
|
"ciborium-io",
|
||||||
|
"half",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap"
|
name = "clap"
|
||||||
version = "4.6.0"
|
version = "4.6.0"
|
||||||
|
|
@ -212,6 +278,18 @@ dependencies = [
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "const-oid"
|
||||||
|
version = "0.9.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "const-str"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2f421161cb492475f1661ddc9815a745a1c894592070661180fdec3d4872e9c3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "core-foundation"
|
name = "core-foundation"
|
||||||
version = "0.9.4"
|
version = "0.9.4"
|
||||||
|
|
@ -273,6 +351,12 @@ dependencies = [
|
||||||
"winapi",
|
"winapi",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crunchy"
|
||||||
|
version = "0.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crypto-common"
|
name = "crypto-common"
|
||||||
version = "0.1.7"
|
version = "0.1.7"
|
||||||
|
|
@ -283,6 +367,69 @@ dependencies = [
|
||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "curve25519-dalek"
|
||||||
|
version = "4.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures",
|
||||||
|
"curve25519-dalek-derive",
|
||||||
|
"digest",
|
||||||
|
"fiat-crypto",
|
||||||
|
"rustc_version",
|
||||||
|
"subtle",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "curve25519-dalek-derive"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "data-encoding"
|
||||||
|
version = "2.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "data-encoding-macro"
|
||||||
|
version = "0.1.20"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3259c913752a86488b501ed8680446a5ed2d5aeac6e596cb23ba3800768ea32c"
|
||||||
|
dependencies = [
|
||||||
|
"data-encoding",
|
||||||
|
"data-encoding-macro-internal",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "data-encoding-macro-internal"
|
||||||
|
version = "0.1.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090"
|
||||||
|
dependencies = [
|
||||||
|
"data-encoding",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "der"
|
||||||
|
version = "0.7.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
|
||||||
|
dependencies = [
|
||||||
|
"const-oid",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "digest"
|
name = "digest"
|
||||||
version = "0.10.7"
|
version = "0.10.7"
|
||||||
|
|
@ -325,6 +472,32 @@ dependencies = [
|
||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ed25519"
|
||||||
|
version = "2.2.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
|
||||||
|
dependencies = [
|
||||||
|
"pkcs8",
|
||||||
|
"serde",
|
||||||
|
"signature",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ed25519-dalek"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
|
||||||
|
dependencies = [
|
||||||
|
"curve25519-dalek",
|
||||||
|
"ed25519",
|
||||||
|
"rand_core",
|
||||||
|
"serde",
|
||||||
|
"sha2",
|
||||||
|
"subtle",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "either"
|
name = "either"
|
||||||
version = "1.15.0"
|
version = "1.15.0"
|
||||||
|
|
@ -373,6 +546,12 @@ dependencies = [
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fiat-crypto"
|
||||||
|
version = "0.2.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "find-msvc-tools"
|
name = "find-msvc-tools"
|
||||||
version = "0.1.9"
|
version = "0.1.9"
|
||||||
|
|
@ -511,9 +690,28 @@ dependencies = [
|
||||||
"reedline",
|
"reedline",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"substrate-ipc",
|
||||||
|
"tokio",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "guildhouse-did"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"chrono",
|
||||||
|
"ed25519-dalek",
|
||||||
|
"hex",
|
||||||
|
"multibase",
|
||||||
|
"percent-encoding",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "h2"
|
name = "h2"
|
||||||
version = "0.4.13"
|
version = "0.4.13"
|
||||||
|
|
@ -533,6 +731,17 @@ dependencies = [
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "half"
|
||||||
|
version = "2.7.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"crunchy",
|
||||||
|
"zerocopy",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.15.5"
|
version = "0.15.5"
|
||||||
|
|
@ -904,13 +1113,17 @@ version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"dirs",
|
"dirs",
|
||||||
|
"guildhouse-did",
|
||||||
"hex",
|
"hex",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
|
"substrate-ipc",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -955,12 +1168,32 @@ version = "0.4.29"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "match-lookup"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "757aee279b8bdbb9f9e676796fd459e4207a1f986e87886700abf589f5abf771"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memchr"
|
name = "memchr"
|
||||||
version = "2.8.0"
|
version = "2.8.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memoffset"
|
||||||
|
version = "0.9.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
|
||||||
|
dependencies = [
|
||||||
|
"autocfg",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mime"
|
name = "mime"
|
||||||
version = "0.3.17"
|
version = "0.3.17"
|
||||||
|
|
@ -979,6 +1212,18 @@ dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "multibase"
|
||||||
|
version = "0.9.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8694bb4835f452b0e3bb06dbebb1d6fc5385b6ca1caf2e55fd165c042390ec77"
|
||||||
|
dependencies = [
|
||||||
|
"base-x",
|
||||||
|
"base256emoji",
|
||||||
|
"data-encoding",
|
||||||
|
"data-encoding-macro",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "native-tls"
|
name = "native-tls"
|
||||||
version = "0.2.18"
|
version = "0.2.18"
|
||||||
|
|
@ -996,6 +1241,19 @@ dependencies = [
|
||||||
"tempfile",
|
"tempfile",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nix"
|
||||||
|
version = "0.29.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"cfg-if",
|
||||||
|
"cfg_aliases",
|
||||||
|
"libc",
|
||||||
|
"memoffset",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nu-ansi-term"
|
name = "nu-ansi-term"
|
||||||
version = "0.50.3"
|
version = "0.50.3"
|
||||||
|
|
@ -1111,6 +1369,16 @@ version = "0.2.17"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pkcs8"
|
||||||
|
version = "0.10.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
|
||||||
|
dependencies = [
|
||||||
|
"der",
|
||||||
|
"spki",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pkg-config"
|
name = "pkg-config"
|
||||||
version = "0.3.32"
|
version = "0.3.32"
|
||||||
|
|
@ -1160,6 +1428,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 = "rand_core"
|
||||||
|
version = "0.6.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.2.17",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redox_syscall"
|
name = "redox_syscall"
|
||||||
version = "0.5.18"
|
version = "0.5.18"
|
||||||
|
|
@ -1256,6 +1533,15 @@ dependencies = [
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustc_version"
|
||||||
|
version = "0.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
|
||||||
|
dependencies = [
|
||||||
|
"semver",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustix"
|
name = "rustix"
|
||||||
version = "0.38.44"
|
version = "0.38.44"
|
||||||
|
|
@ -1474,6 +1760,15 @@ dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "signature"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||||
|
dependencies = [
|
||||||
|
"rand_core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "slab"
|
name = "slab"
|
||||||
version = "0.4.12"
|
version = "0.4.12"
|
||||||
|
|
@ -1496,6 +1791,16 @@ dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "spki"
|
||||||
|
version = "0.7.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
|
||||||
|
dependencies = [
|
||||||
|
"base64ct",
|
||||||
|
"der",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "stable_deref_trait"
|
name = "stable_deref_trait"
|
||||||
version = "1.2.1"
|
version = "1.2.1"
|
||||||
|
|
@ -1536,6 +1841,17 @@ dependencies = [
|
||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "substrate-ipc"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"ciborium",
|
||||||
|
"nix",
|
||||||
|
"serde",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "subtle"
|
name = "subtle"
|
||||||
version = "2.6.1"
|
version = "2.6.1"
|
||||||
|
|
@ -1666,11 +1982,25 @@ dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"libc",
|
"libc",
|
||||||
"mio",
|
"mio",
|
||||||
|
"parking_lot",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"signal-hook-registry",
|
||||||
"socket2",
|
"socket2",
|
||||||
|
"tokio-macros",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-macros"
|
||||||
|
version = "2.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-native-tls"
|
name = "tokio-native-tls"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
|
|
@ -1756,9 +2086,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"tracing-attributes",
|
||||||
"tracing-core",
|
"tracing-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tracing-attributes"
|
||||||
|
version = "0.1.31"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tracing-core"
|
name = "tracing-core"
|
||||||
version = "0.1.36"
|
version = "0.1.36"
|
||||||
|
|
@ -2355,6 +2697,26 @@ dependencies = [
|
||||||
"synstructure",
|
"synstructure",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerocopy"
|
||||||
|
version = "0.8.48"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
|
||||||
|
dependencies = [
|
||||||
|
"zerocopy-derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerocopy-derive"
|
||||||
|
version = "0.8.48"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerofrom"
|
name = "zerofrom"
|
||||||
version = "0.1.7"
|
version = "0.1.7"
|
||||||
|
|
|
||||||
|
|
@ -23,3 +23,6 @@ dirs = "5"
|
||||||
reedline = "0.38"
|
reedline = "0.38"
|
||||||
colored = "2"
|
colored = "2"
|
||||||
atty = "0.2"
|
atty = "0.2"
|
||||||
|
tracing = "0.1"
|
||||||
|
substrate-ipc = { path = "../substrate/crates/substrate-ipc" }
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
# gsh
|
# gsh
|
||||||
|
|
||||||
gsh — the GCAP governed shell. Human and machine modes. Chronicle-attributed execution.
|
gsh — the GCAP governed shell. Human and machine modes. Chronicle-attributed execution.
|
||||||
|
|
||||||
|
**Status (2026-04-28):** Active development. Design is mature ([DESIGN.md](DESIGN.md)). The architectural anchor is the shell type system (per [DESIGN-SHELL-ARCHITECTURE-2026-04-28.md](../DESIGN-SHELL-ARCHITECTURE-2026-04-28.md)); gsh is the canonical consumer of the type system, built on libgsh.
|
||||||
|
|
@ -19,3 +19,5 @@ chrono = { workspace = true }
|
||||||
reedline = { workspace = true }
|
reedline = { workspace = true }
|
||||||
colored = { workspace = true }
|
colored = { workspace = true }
|
||||||
atty = { workspace = true }
|
atty = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
substrate-ipc = { workspace = true }
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ pub fn run_human_mode(
|
||||||
// Classify command:
|
// Classify command:
|
||||||
match classify_command(line, &session.corpus_cid, &corpus_dir) {
|
match classify_command(line, &session.corpus_cid, &corpus_dir) {
|
||||||
CommandClass::Free => {
|
CommandClass::Free => {
|
||||||
execute_passthrough(line);
|
execute_passthrough(line, session);
|
||||||
session.free_count += 1;
|
session.free_count += 1;
|
||||||
}
|
}
|
||||||
CommandClass::Governed { corpus_binary } => {
|
CommandClass::Governed { corpus_binary } => {
|
||||||
|
|
@ -80,7 +80,7 @@ pub fn run_human_mode(
|
||||||
"{}",
|
"{}",
|
||||||
format!(" ⚠ ungoverned: '{}' not in corpus", cmd_name).yellow()
|
format!(" ⚠ ungoverned: '{}' not in corpus", cmd_name).yellow()
|
||||||
);
|
);
|
||||||
execute_passthrough(line);
|
execute_passthrough(line, session);
|
||||||
session.ungoverned_count += 1;
|
session.ungoverned_count += 1;
|
||||||
}
|
}
|
||||||
CommandClass::Denied { reason } => {
|
CommandClass::Denied { reason } => {
|
||||||
|
|
@ -234,11 +234,11 @@ fn build_prompt(session: &SessionState) -> DefaultPrompt {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn execute_passthrough(line: &str) -> i32 {
|
fn execute_passthrough(line: &str, session: &SessionState) -> i32 {
|
||||||
let status = std::process::Command::new("sh")
|
let mut cmd = std::process::Command::new("sh");
|
||||||
.arg("-c")
|
cmd.arg("-c").arg(line);
|
||||||
.arg(line)
|
session.apply_governance_env(&mut cmd);
|
||||||
.status();
|
let status = cmd.status();
|
||||||
match status {
|
match status {
|
||||||
Ok(s) => s.code().unwrap_or(1),
|
Ok(s) => s.code().unwrap_or(1),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|
@ -250,11 +250,12 @@ fn execute_passthrough(line: &str) -> i32 {
|
||||||
|
|
||||||
fn execute_governed(line: &str, corpus_binary: &Path, session: &SessionState) -> i32 {
|
fn execute_governed(line: &str, corpus_binary: &Path, session: &SessionState) -> i32 {
|
||||||
let args: Vec<&str> = line.split_whitespace().skip(1).collect();
|
let args: Vec<&str> = line.split_whitespace().skip(1).collect();
|
||||||
let status = std::process::Command::new(corpus_binary)
|
let mut cmd = std::process::Command::new(corpus_binary);
|
||||||
.args(&args)
|
cmd.args(&args)
|
||||||
.env("BASCULE_SESSION_ID", &session.ac_id)
|
.env("BASCULE_SESSION_ID", &session.ac_id)
|
||||||
.env("BASCULE_CORPUS_CID", &session.corpus_cid)
|
.env("BASCULE_CORPUS_CID", &session.corpus_cid);
|
||||||
.status();
|
session.apply_governance_env(&mut cmd);
|
||||||
|
let status = cmd.status();
|
||||||
match status {
|
match status {
|
||||||
Ok(s) => s.code().unwrap_or(1),
|
Ok(s) => s.code().unwrap_or(1),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,19 @@ enum Cmd {
|
||||||
},
|
},
|
||||||
SessionEnd,
|
SessionEnd,
|
||||||
SessionStatus,
|
SessionStatus,
|
||||||
|
/// Register an app shell with substrate-fabric (systemd ExecStartPre).
|
||||||
|
Register {
|
||||||
|
/// Service name for the shell registration.
|
||||||
|
#[arg(long)]
|
||||||
|
service_name: String,
|
||||||
|
/// Path to the fabric Unix socket.
|
||||||
|
#[arg(long, default_value = "/run/substrate/fabric.sock")]
|
||||||
|
fabric_socket: String,
|
||||||
|
/// Directory to write shell.env into. Defaults to
|
||||||
|
/// /run/substrate/shells/{service_name}/
|
||||||
|
#[arg(long)]
|
||||||
|
env_dir: Option<String>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
|
|
@ -125,8 +138,18 @@ fn run(args: Args) -> Result<i32> {
|
||||||
return Ok(code);
|
return Ok(code);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Session subcommands ──────────────────────────────────
|
// ── Subcommands ─────────────────────────────────────────
|
||||||
if let Some(cmd) = &args.command {
|
if let Some(cmd) = &args.command {
|
||||||
|
// Register is handled separately — it doesn't need a broker.
|
||||||
|
if let Cmd::Register {
|
||||||
|
service_name,
|
||||||
|
fabric_socket,
|
||||||
|
env_dir,
|
||||||
|
} = cmd
|
||||||
|
{
|
||||||
|
return run_register(service_name, fabric_socket, env_dir.as_deref());
|
||||||
|
}
|
||||||
|
|
||||||
let base = args.broker_url.clone()
|
let base = args.broker_url.clone()
|
||||||
.or_else(|| std::env::var("GSAP_BROKER_URL").ok())
|
.or_else(|| std::env::var("GSAP_BROKER_URL").ok())
|
||||||
.context("GSAP_BROKER_URL not set")?;
|
.context("GSAP_BROKER_URL not set")?;
|
||||||
|
|
@ -166,6 +189,7 @@ fn run(args: Args) -> Result<i32> {
|
||||||
}
|
}
|
||||||
Ok(0)
|
Ok(0)
|
||||||
}
|
}
|
||||||
|
Cmd::Register { .. } => unreachable!("handled above"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -215,7 +239,11 @@ fn run(args: Args) -> Result<i32> {
|
||||||
// Determine AC mode:
|
// Determine AC mode:
|
||||||
let pre_issued = args.ac.clone().or_else(|| std::env::var("GSAP_AC").ok());
|
let pre_issued = args.ac.clone().or_else(|| std::env::var("GSAP_AC").ok());
|
||||||
|
|
||||||
let (ac_id, ac_mode) = if let Some(ac_json) = pre_issued {
|
// Retain the full AC struct for pre-issued mode so the governance-env
|
||||||
|
// contract (`GSH_DID`/`GSH_ACCORD_HASH`/...) can be threaded into the
|
||||||
|
// child process at the exec site below. Session and inline modes only
|
||||||
|
// surface an ID; their governance fields stay un-exported.
|
||||||
|
let (ac_id, ac_mode, ac_struct) = if let Some(ac_json) = pre_issued {
|
||||||
let mut registry = ConsumedRegistry::default_location();
|
let mut registry = ConsumedRegistry::default_location();
|
||||||
let ac = libgsh::ac::validate_ac(&ac_json, &corpus, &mut registry)
|
let ac = libgsh::ac::validate_ac(&ac_json, &corpus, &mut registry)
|
||||||
.map_err(|e| anyhow::anyhow!("AC validation failed (exit {}): {}", e.exit_code(), e))?;
|
.map_err(|e| anyhow::anyhow!("AC validation failed (exit {}): {}", e.exit_code(), e))?;
|
||||||
|
|
@ -223,9 +251,9 @@ fn run(args: Args) -> Result<i32> {
|
||||||
if let Some(ref p) = ac.principal {
|
if let Some(ref p) = ac.principal {
|
||||||
if let Some(ref did) = p.did { eprintln!("gsh: principal — {}", did); }
|
if let Some(ref did) = p.did { eprintln!("gsh: principal — {}", did); }
|
||||||
}
|
}
|
||||||
(ac.context_id, "pre-issued".to_string())
|
(ac.context_id.clone(), "pre-issued".to_string(), Some(ac))
|
||||||
} else if let Ok(session_ac) = std::env::var("GSAP_SESSION_AC") {
|
} else if let Ok(session_ac) = std::env::var("GSAP_SESSION_AC") {
|
||||||
(session_ac, "session".to_string())
|
(session_ac, "session".to_string(), None)
|
||||||
} else {
|
} else {
|
||||||
let base = args.broker_url.clone()
|
let base = args.broker_url.clone()
|
||||||
.or_else(|| std::env::var("GSAP_BROKER_URL").ok())
|
.or_else(|| std::env::var("GSAP_BROKER_URL").ok())
|
||||||
|
|
@ -235,7 +263,7 @@ fn run(args: Args) -> Result<i32> {
|
||||||
let id = request_ac_inline(&client, &base, &args.operation, &command_hash, &corpus)
|
let id = request_ac_inline(&client, &base, &args.operation, &command_hash, &corpus)
|
||||||
.map_err(|e| anyhow::anyhow!(e))?;
|
.map_err(|e| anyhow::anyhow!(e))?;
|
||||||
eprintln!("gsh: AC issued — {}", &id[..8.min(id.len())]);
|
eprintln!("gsh: AC issued — {}", &id[..8.min(id.len())]);
|
||||||
(id, "inline".to_string())
|
(id, "inline".to_string(), None)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Corpus gate:
|
// Corpus gate:
|
||||||
|
|
@ -244,10 +272,40 @@ fn run(args: Args) -> Result<i32> {
|
||||||
eprintln!("gsh: command '{}' not in corpus {} (killswitch active)", command, corpus_cid);
|
eprintln!("gsh: command '{}' not in corpus {} (killswitch active)", command, corpus_cid);
|
||||||
return Ok(3);
|
return Ok(3);
|
||||||
}
|
}
|
||||||
|
libgsh::CorpusCheckResult::ContentMismatch {
|
||||||
|
command,
|
||||||
|
corpus_cid,
|
||||||
|
actual_cid,
|
||||||
|
path,
|
||||||
|
} => {
|
||||||
|
eprintln!(
|
||||||
|
"gsh: command '{}' content does not match CID {} (found {}, path {}): execution denied (tamper signal)",
|
||||||
|
command,
|
||||||
|
corpus_cid,
|
||||||
|
actual_cid,
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
return Ok(3);
|
||||||
|
}
|
||||||
|
libgsh::CorpusCheckResult::ReadFailed {
|
||||||
|
command,
|
||||||
|
corpus_cid,
|
||||||
|
path,
|
||||||
|
detail,
|
||||||
|
} => {
|
||||||
|
eprintln!(
|
||||||
|
"gsh: command '{}' in corpus {} could not be read for hash verification ({}); execution denied fail-closed (path {})",
|
||||||
|
command,
|
||||||
|
corpus_cid,
|
||||||
|
detail,
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
return Ok(3);
|
||||||
|
}
|
||||||
libgsh::CorpusCheckResult::NotMounted => {
|
libgsh::CorpusCheckResult::NotMounted => {
|
||||||
eprintln!("gsh: corpus directory not found (host may not have corpus mounted)");
|
eprintln!("gsh: corpus directory not found (host may not have corpus mounted)");
|
||||||
}
|
}
|
||||||
_ => {}
|
libgsh::CorpusCheckResult::Allowed | libgsh::CorpusCheckResult::Ungoverned => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
if args.dry_run {
|
if args.dry_run {
|
||||||
|
|
@ -263,7 +321,12 @@ fn run(args: Args) -> Result<i32> {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute:
|
// Execute:
|
||||||
let output = process::Command::new("sh").arg("-c").arg(exec).output().context("exec failed")?;
|
let mut command = process::Command::new("sh");
|
||||||
|
command.arg("-c").arg(exec);
|
||||||
|
if let Some(ref ac) = ac_struct {
|
||||||
|
libgsh::governance_env::apply_from_ac(&mut command, ac);
|
||||||
|
}
|
||||||
|
let output = command.output().context("exec failed")?;
|
||||||
let exit_code = output.status.code().unwrap_or(1);
|
let exit_code = output.status.code().unwrap_or(1);
|
||||||
let stdout_str = String::from_utf8_lossy(&output.stdout).to_string();
|
let stdout_str = String::from_utf8_lossy(&output.stdout).to_string();
|
||||||
let stderr_str = String::from_utf8_lossy(&output.stderr).to_string();
|
let stderr_str = String::from_utf8_lossy(&output.stderr).to_string();
|
||||||
|
|
@ -300,3 +363,24 @@ fn run(args: Args) -> Result<i32> {
|
||||||
|
|
||||||
Ok(exit_code)
|
Ok(exit_code)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn run_register(service_name: &str, fabric_socket: &str, env_dir: Option<&str>) -> Result<i32> {
|
||||||
|
let rt = tokio::runtime::Runtime::new().context("creating tokio runtime")?;
|
||||||
|
let env = rt.block_on(libgsh::register::register_app_shell(
|
||||||
|
service_name,
|
||||||
|
Some(fabric_socket),
|
||||||
|
))?;
|
||||||
|
|
||||||
|
let dir = match env_dir {
|
||||||
|
Some(d) => std::path::PathBuf::from(d),
|
||||||
|
None => std::path::PathBuf::from(format!("/run/substrate/shells/{service_name}")),
|
||||||
|
};
|
||||||
|
|
||||||
|
let path = libgsh::register::write_shell_env(&dir, &env)?;
|
||||||
|
eprintln!(
|
||||||
|
"gsh: registered shell {} — env at {}",
|
||||||
|
env.shell_id,
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
Ok(0)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ edition.workspace = true
|
||||||
description = "Governed shell library — AC validation, CR building, corpus gate"
|
description = "Governed shell library — AC validation, CR building, corpus gate"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
guildhouse-did = { path = "../../guildhouse-did" }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
reqwest = { workspace = true }
|
reqwest = { workspace = true }
|
||||||
|
|
@ -13,6 +14,9 @@ sha2 = { workspace = true }
|
||||||
hex = { workspace = true }
|
hex = { workspace = true }
|
||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
dirs = { workspace = true }
|
dirs = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
substrate-ipc = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|
|
||||||
|
|
@ -1,39 +1,59 @@
|
||||||
//! Authorization Context validation (R-22, R-23, R-24).
|
//! Authorization Context validation (R-22, R-23, R-24).
|
||||||
|
|
||||||
use serde::Deserialize;
|
use guildhouse_did::Did;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
use crate::registry::ConsumedRegistry;
|
use crate::registry::ConsumedRegistry;
|
||||||
|
|
||||||
/// A pre-issued Authorization Context from the broker.
|
/// A pre-issued Authorization Context from the broker.
|
||||||
#[derive(Deserialize, Debug, Clone)]
|
///
|
||||||
|
/// Phase 1 of org-ops-core CLI standardization (2026-05-03) added the
|
||||||
|
/// governance-context fields (`accord_hash`, `shell_class`,
|
||||||
|
/// `capability_set`, `posture_level`) so child processes spawned inside
|
||||||
|
/// gsh can read the operator's accord/posture/capability scope from
|
||||||
|
/// discrete `GSH_*` env vars without re-parsing the AC blob. All new
|
||||||
|
/// fields are optional; existing AC producers keep working unchanged.
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
pub struct AuthorizationContext {
|
pub struct AuthorizationContext {
|
||||||
pub context_id: String,
|
pub context_id: String,
|
||||||
#[serde(default)]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub issued_at: Option<String>,
|
pub issued_at: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub expires_at: Option<String>,
|
pub expires_at: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub operation: Option<AcOperation>,
|
pub operation: Option<AcOperation>,
|
||||||
#[serde(default)]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub principal: Option<AcPrincipal>,
|
pub principal: Option<AcPrincipal>,
|
||||||
|
/// SHA-256 of the governing accord; threaded into `GSH_ACCORD_HASH`.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub accord_hash: Option<String>,
|
||||||
|
/// `"Application"` | `"System"` (or future variants); threaded into `GSH_SHELL_CLASS`.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub shell_class: Option<String>,
|
||||||
|
/// Capability bitmask; threaded into `GSH_CAPABILITY_SET` as `0x{:08x}`.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub capability_set: Option<u32>,
|
||||||
|
/// Posture / DEFCON level (1..=5); threaded into `GSH_POSTURE_LEVEL`.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub posture_level: Option<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Debug, Clone, Default)]
|
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
|
||||||
pub struct AcOperation {
|
pub struct AcOperation {
|
||||||
#[serde(default)]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub corpus_entry_cid: Option<String>,
|
pub corpus_entry_cid: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub parameters_cid: Option<String>,
|
pub parameters_cid: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub playbook: Option<String>,
|
pub playbook: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Debug, Clone, Default)]
|
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
|
||||||
pub struct AcPrincipal {
|
pub struct AcPrincipal {
|
||||||
#[serde(default)]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub did: Option<String>,
|
pub did: Option<Did>,
|
||||||
#[serde(default)]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub display_name: Option<String>,
|
pub display_name: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -171,4 +191,49 @@ mod tests {
|
||||||
Err(AcValidationError::MissingContextId)
|
Err(AcValidationError::MissingContextId)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_governance_fields_round_trip() {
|
||||||
|
// AC with all new governance fields populated.
|
||||||
|
let ac_json = r#"{
|
||||||
|
"context_id":"gov-ctx-1",
|
||||||
|
"expires_at":"2099-01-01T00:00:00Z",
|
||||||
|
"principal":{"did":"did:web:guildhouse.dev:user:tking","display_name":"tking"},
|
||||||
|
"accord_hash":"sha256:abcd1234",
|
||||||
|
"shell_class":"Application",
|
||||||
|
"capability_set":7,
|
||||||
|
"posture_level":4
|
||||||
|
}"#;
|
||||||
|
let ac: AuthorizationContext = serde_json::from_str(ac_json).unwrap();
|
||||||
|
assert_eq!(ac.accord_hash.as_deref(), Some("sha256:abcd1234"));
|
||||||
|
assert_eq!(ac.shell_class.as_deref(), Some("Application"));
|
||||||
|
assert_eq!(ac.capability_set, Some(7));
|
||||||
|
assert_eq!(ac.posture_level, Some(4));
|
||||||
|
|
||||||
|
// Re-serialize and parse again; round-trip must preserve the values.
|
||||||
|
let reserialized = serde_json::to_string(&ac).unwrap();
|
||||||
|
let parsed: AuthorizationContext = serde_json::from_str(&reserialized).unwrap();
|
||||||
|
assert_eq!(parsed.accord_hash, ac.accord_hash);
|
||||||
|
assert_eq!(parsed.shell_class, ac.shell_class);
|
||||||
|
assert_eq!(parsed.capability_set, ac.capability_set);
|
||||||
|
assert_eq!(parsed.posture_level, ac.posture_level);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_governance_fields_absent_back_compat() {
|
||||||
|
// Legacy AC blob with no governance fields parses cleanly.
|
||||||
|
let ac_json = r#"{"context_id":"legacy","operation":{"corpus_entry_cid":"sha256:ungoverned"}}"#;
|
||||||
|
let ac: AuthorizationContext = serde_json::from_str(ac_json).unwrap();
|
||||||
|
assert!(ac.accord_hash.is_none());
|
||||||
|
assert!(ac.shell_class.is_none());
|
||||||
|
assert!(ac.capability_set.is_none());
|
||||||
|
assert!(ac.posture_level.is_none());
|
||||||
|
|
||||||
|
// Round-trip serialize: with skip_serializing_if, omitted fields stay omitted.
|
||||||
|
let reserialized = serde_json::to_string(&ac).unwrap();
|
||||||
|
assert!(!reserialized.contains("accord_hash"));
|
||||||
|
assert!(!reserialized.contains("shell_class"));
|
||||||
|
assert!(!reserialized.contains("capability_set"));
|
||||||
|
assert!(!reserialized.contains("posture_level"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
129
libgsh/src/agent_api.rs
Normal file
129
libgsh/src/agent_api.rs
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
//! Agent shell registration API.
|
||||||
|
//!
|
||||||
|
//! Unix socket server that accepts RegisterAgentShell requests from
|
||||||
|
//! callers wanting to spawn governed agent sub-shells. Validates
|
||||||
|
//! capability attenuation (requested ⊆ parent) before forwarding
|
||||||
|
//! to substrate-fabric.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use thiserror::Error;
|
||||||
|
use tokio::net::UnixStream;
|
||||||
|
|
||||||
|
use substrate_ipc::fabric_api::{FabricRequest, FabricResponse};
|
||||||
|
use substrate_ipc::wire;
|
||||||
|
|
||||||
|
const DEFAULT_FABRIC_SOCKET: &str = "/run/substrate/fabric.sock";
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum AgentError {
|
||||||
|
#[error("capability widening: requested 0x{requested:08x} exceeds parent 0x{parent:08x}")]
|
||||||
|
CapabilityWidening { requested: u32, parent: u32 },
|
||||||
|
#[error("connecting to fabric socket at {path}: {source}")]
|
||||||
|
Connect {
|
||||||
|
path: String,
|
||||||
|
source: std::io::Error,
|
||||||
|
},
|
||||||
|
#[error("fabric IPC: {0}")]
|
||||||
|
Wire(#[from] wire::WireError),
|
||||||
|
#[error("registration denied: {reason}")]
|
||||||
|
Denied { reason: String },
|
||||||
|
#[error("fabric error: {0}")]
|
||||||
|
FabricError(String),
|
||||||
|
#[error("reading cgroup: {0}")]
|
||||||
|
Cgroup(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AgentShellEnv {
|
||||||
|
pub shell_id: String,
|
||||||
|
pub env_vars: HashMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate that requested capabilities are a subset of parent's.
|
||||||
|
pub fn validate_attenuation(
|
||||||
|
requested: u32,
|
||||||
|
parent: u32,
|
||||||
|
) -> Result<(), AgentError> {
|
||||||
|
if requested & !parent != 0 {
|
||||||
|
return Err(AgentError::CapabilityWidening {
|
||||||
|
requested,
|
||||||
|
parent,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register an agent shell via substrate-fabric.
|
||||||
|
///
|
||||||
|
/// Validates capability attenuation before forwarding. The caller
|
||||||
|
/// must be in the parent shell's cgroup subtree.
|
||||||
|
pub async fn register_agent_shell(
|
||||||
|
parent_shell_id: &str,
|
||||||
|
parent_capabilities: u32,
|
||||||
|
requested_capabilities: u32,
|
||||||
|
fabric_socket: Option<&str>,
|
||||||
|
) -> Result<AgentShellEnv, AgentError> {
|
||||||
|
validate_attenuation(requested_capabilities, parent_capabilities)?;
|
||||||
|
|
||||||
|
let cgroup_path = substrate_ipc::cgroup::get_self_cgroup_path()
|
||||||
|
.map_err(|e| AgentError::Cgroup(e.to_string()))?;
|
||||||
|
|
||||||
|
let sock_path = fabric_socket.unwrap_or(DEFAULT_FABRIC_SOCKET);
|
||||||
|
let stream = UnixStream::connect(sock_path)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AgentError::Connect {
|
||||||
|
path: sock_path.into(),
|
||||||
|
source: e,
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let (mut rd, mut wr) = tokio::io::split(stream);
|
||||||
|
|
||||||
|
let req = FabricRequest::RegisterAgentShell {
|
||||||
|
parent_shell_id: parent_shell_id.into(),
|
||||||
|
requested_capabilities,
|
||||||
|
cgroup_path,
|
||||||
|
};
|
||||||
|
wire::send_msg(&mut wr, &req).await?;
|
||||||
|
|
||||||
|
let resp: FabricResponse = wire::recv_msg(&mut rd).await?;
|
||||||
|
match resp {
|
||||||
|
FabricResponse::ShellCreated { shell_id, env_vars } => {
|
||||||
|
Ok(AgentShellEnv { shell_id, env_vars })
|
||||||
|
}
|
||||||
|
FabricResponse::Denied { reason } => Err(AgentError::Denied { reason }),
|
||||||
|
FabricResponse::Error(msg) => Err(AgentError::FabricError(msg)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attenuation_allows_subset() {
|
||||||
|
validate_attenuation(0x01, 0x03).unwrap(); // READ ⊆ READ|PROPOSE
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attenuation_allows_equal() {
|
||||||
|
validate_attenuation(0x03, 0x03).unwrap(); // exact match
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attenuation_allows_empty() {
|
||||||
|
validate_attenuation(0x00, 0x0F).unwrap(); // nothing requested
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attenuation_rejects_widening() {
|
||||||
|
let err = validate_attenuation(0x04, 0x03).unwrap_err(); // MUTATE not in READ|PROPOSE
|
||||||
|
assert!(matches!(err, AgentError::CapabilityWidening { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attenuation_rejects_superset() {
|
||||||
|
let err = validate_attenuation(0x0F, 0x03).unwrap_err(); // ALL not in READ|PROPOSE
|
||||||
|
assert!(matches!(err, AgentError::CapabilityWidening { .. }));
|
||||||
|
}
|
||||||
|
}
|
||||||
40
libgsh/src/chronicle_events.rs
Normal file
40
libgsh/src/chronicle_events.rs
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
//! Chronicle-shaped event schemas for CID verification (execution path).
|
||||||
|
//!
|
||||||
|
//! Stable `event_type` constants emitted via structured tracing with
|
||||||
|
//! `target: "chronicle"`. The field shape matches what the post-io_uring
|
||||||
|
//! substrate-chronicle emission API is expected to require, so migrating
|
||||||
|
//! to direct Chronicle emission is a mechanical translation once that API
|
||||||
|
//! stabilizes: replace `tracing::warn!(target: "chronicle", ...)` with the
|
||||||
|
//! emitter call using the same field names.
|
||||||
|
//!
|
||||||
|
//! # Event envelope
|
||||||
|
//!
|
||||||
|
//! Common fields every event carries:
|
||||||
|
//!
|
||||||
|
//! - `event_type`: &'static str — one of the constants below.
|
||||||
|
//! - `claimed_cid`: &str — the CID under which the binary is authorized.
|
||||||
|
//! - `actual_cid`: &str — the hash of the binary as it appears on disk
|
||||||
|
//! (omitted when hashing failed outright).
|
||||||
|
//! - `context`: &str — binary path (and where available, session id).
|
||||||
|
//! - `actor`: &str — "gsh" (this process). SPIFFE SVID at execution time
|
||||||
|
//! is not currently carried; once the mutation schema grows an identity
|
||||||
|
//! field, this becomes the workload identity.
|
||||||
|
//! - `severity`: &str — "error" (mismatches are security events).
|
||||||
|
//! - `detail`: Display — the underlying error string or extra context.
|
||||||
|
//!
|
||||||
|
//! # Event types
|
||||||
|
|
||||||
|
/// The binary at the corpus-directory path hashes to a different CID than
|
||||||
|
/// the one the session was authorized under. This is the tamper signal:
|
||||||
|
/// either the corpus directory was modified after admission, or the
|
||||||
|
/// directory name never matched its content and the admission layer did
|
||||||
|
/// not catch it. Either way, execution is denied.
|
||||||
|
pub const CID_MISMATCH_EXECUTION_CONTENT_MISMATCH: &str =
|
||||||
|
"cid_mismatch_execution_content_mismatch";
|
||||||
|
|
||||||
|
/// The file at the expected path exists but could not be read for
|
||||||
|
/// hashing (IO error, permissions, truncation). Execution is denied
|
||||||
|
/// fail-closed; a read we cannot verify is a verification we cannot
|
||||||
|
/// complete.
|
||||||
|
pub const CID_MISMATCH_EXECUTION_READ_FAILED: &str =
|
||||||
|
"cid_mismatch_execution_read_failed";
|
||||||
|
|
@ -1,13 +1,15 @@
|
||||||
//! Configuration from environment variables.
|
//! Configuration from environment variables.
|
||||||
|
|
||||||
|
use guildhouse_did::Did;
|
||||||
|
|
||||||
/// Consolidated gsh configuration from env vars.
|
/// Consolidated gsh configuration from env vars.
|
||||||
pub struct GshConfig {
|
pub struct GshConfig {
|
||||||
/// Pre-issued AC JSON string.
|
/// Pre-issued AC JSON string.
|
||||||
pub ac: Option<String>,
|
pub ac: Option<String>,
|
||||||
/// GSAP broker URL.
|
/// GSAP broker URL.
|
||||||
pub broker_url: Option<String>,
|
pub broker_url: Option<String>,
|
||||||
/// Agent DID.
|
/// Agent DID. None when `GSAP_AGENT_DID` is unset or doesn't parse as a DID.
|
||||||
pub agent_did: Option<String>,
|
pub agent_did: Option<Did>,
|
||||||
/// Bearer auth token.
|
/// Bearer auth token.
|
||||||
pub token: Option<String>,
|
pub token: Option<String>,
|
||||||
/// Corpus CID this session is authorized for.
|
/// Corpus CID this session is authorized for.
|
||||||
|
|
@ -28,7 +30,9 @@ impl GshConfig {
|
||||||
Self {
|
Self {
|
||||||
ac: std::env::var("GSAP_AC").ok(),
|
ac: std::env::var("GSAP_AC").ok(),
|
||||||
broker_url: std::env::var("GSAP_BROKER_URL").ok(),
|
broker_url: std::env::var("GSAP_BROKER_URL").ok(),
|
||||||
agent_did: std::env::var("GSAP_AGENT_DID").ok(),
|
agent_did: std::env::var("GSAP_AGENT_DID")
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| Did::parse(&s).ok()),
|
||||||
token: std::env::var("GSAP_TOKEN").ok(),
|
token: std::env::var("GSAP_TOKEN").ok(),
|
||||||
corpus_cid: std::env::var("GSAP_CORPUS_CID")
|
corpus_cid: std::env::var("GSAP_CORPUS_CID")
|
||||||
.unwrap_or_else(|_| "sha256:ungoverned".into()),
|
.unwrap_or_else(|_| "sha256:ungoverned".into()),
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,35 @@
|
||||||
//! Corpus directory gate — the live killswitch.
|
//! Corpus directory gate — the live killswitch.
|
||||||
|
//!
|
||||||
|
//! Verifies two properties before a binary is allowed to execute:
|
||||||
|
//!
|
||||||
|
//! 1. The binary name is present in the corpus directory keyed by CID
|
||||||
|
//! (existing directory-name check).
|
||||||
|
//! 2. The binary's on-disk SHA-256 matches the CID its directory is
|
||||||
|
//! named for (content-verification added as part of the CID-content
|
||||||
|
//! verification audit fix).
|
||||||
|
//!
|
||||||
|
//! Property 2 closes the gap where an attacker with write access to the
|
||||||
|
//! corpus directory could plant a malicious binary under a legitimate
|
||||||
|
//! CID and have it execute with that CID's privileges. With content
|
||||||
|
//! verification, the corpus directory can still be tampered with, but
|
||||||
|
//! the tampered binary will not run.
|
||||||
|
//!
|
||||||
|
//! Mismatches emit Chronicle-shaped structured tracing events
|
||||||
|
//! (`target: "chronicle"`) with the event_type constants from
|
||||||
|
//! [`crate::chronicle_events`], so tamper incidents remain forensically
|
||||||
|
//! complete rather than denied-and-forgotten.
|
||||||
|
|
||||||
use std::path::Path;
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use tracing::warn;
|
||||||
|
|
||||||
|
use crate::chronicle_events as events;
|
||||||
|
|
||||||
/// Result of a corpus check.
|
/// Result of a corpus check.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum CorpusCheckResult {
|
pub enum CorpusCheckResult {
|
||||||
/// Binary found in corpus — allowed.
|
/// Binary found in corpus and its content hashes to the expected CID.
|
||||||
Allowed,
|
Allowed,
|
||||||
/// Corpus is ungoverned — no check performed.
|
/// Corpus is ungoverned — no check performed.
|
||||||
Ungoverned,
|
Ungoverned,
|
||||||
|
|
@ -13,6 +37,23 @@ pub enum CorpusCheckResult {
|
||||||
NotMounted,
|
NotMounted,
|
||||||
/// Binary not in corpus directory — denied (killswitch active).
|
/// Binary not in corpus directory — denied (killswitch active).
|
||||||
Denied { command: String, corpus_cid: String },
|
Denied { command: String, corpus_cid: String },
|
||||||
|
/// Binary present but content does not hash to the expected CID.
|
||||||
|
/// Denied — the file has been tampered with or was placed under a
|
||||||
|
/// CID directory that does not match its content.
|
||||||
|
ContentMismatch {
|
||||||
|
command: String,
|
||||||
|
corpus_cid: String,
|
||||||
|
actual_cid: String,
|
||||||
|
path: PathBuf,
|
||||||
|
},
|
||||||
|
/// Binary present but could not be read for hashing. Fail-closed:
|
||||||
|
/// a read we cannot verify is a verification we cannot complete.
|
||||||
|
ReadFailed {
|
||||||
|
command: String,
|
||||||
|
corpus_cid: String,
|
||||||
|
path: PathBuf,
|
||||||
|
detail: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Default corpus base directory.
|
/// Default corpus base directory.
|
||||||
|
|
@ -21,7 +62,9 @@ pub const DEFAULT_CORPUS_BASE: &str = "/opt/substrate/corpus";
|
||||||
/// Check if a command is authorized in the corpus directory.
|
/// Check if a command is authorized in the corpus directory.
|
||||||
///
|
///
|
||||||
/// `base_dir` overrides the default /opt/substrate/corpus (set via GSH_CORPUS_DIR env).
|
/// `base_dir` overrides the default /opt/substrate/corpus (set via GSH_CORPUS_DIR env).
|
||||||
/// Returns Ok(result) always. Caller decides whether to block on Denied.
|
/// Returns Ok(result) always. Caller decides whether to block on Denied,
|
||||||
|
/// ContentMismatch, or ReadFailed — all three are execution-denied
|
||||||
|
/// states.
|
||||||
pub fn corpus_check(corpus_cid: &str, command: &str) -> CorpusCheckResult {
|
pub fn corpus_check(corpus_cid: &str, command: &str) -> CorpusCheckResult {
|
||||||
corpus_check_with_base(corpus_cid, command, DEFAULT_CORPUS_BASE)
|
corpus_check_with_base(corpus_cid, command, DEFAULT_CORPUS_BASE)
|
||||||
}
|
}
|
||||||
|
|
@ -44,22 +87,106 @@ pub fn corpus_check_with_base(corpus_cid: &str, command: &str, base_dir: &str) -
|
||||||
.map(|n| n.to_string_lossy().to_string())
|
.map(|n| n.to_string_lossy().to_string())
|
||||||
.unwrap_or_else(|| cmd_name.to_string());
|
.unwrap_or_else(|| cmd_name.to_string());
|
||||||
|
|
||||||
if corpus_dir.join(&cmd_name).exists() {
|
let binary_path = corpus_dir.join(&cmd_name);
|
||||||
CorpusCheckResult::Allowed
|
if !binary_path.exists() {
|
||||||
} else {
|
return CorpusCheckResult::Denied {
|
||||||
CorpusCheckResult::Denied {
|
|
||||||
command: cmd_name,
|
command: cmd_name,
|
||||||
corpus_cid: corpus_cid.to_string(),
|
corpus_cid: corpus_cid.to_string(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content verification: hash the binary on disk and compare to the
|
||||||
|
// CID the directory is named for. OCI registries make the admission
|
||||||
|
// layer's CID→content binding cryptographic; this re-check protects
|
||||||
|
// against post-admission filesystem tampering.
|
||||||
|
match std::fs::read(&binary_path) {
|
||||||
|
Ok(bytes) => {
|
||||||
|
let actual_cid = format!("sha256:{}", hex::encode(Sha256::digest(&bytes)));
|
||||||
|
if actual_cid == corpus_cid {
|
||||||
|
CorpusCheckResult::Allowed
|
||||||
|
} else {
|
||||||
|
warn!(
|
||||||
|
target: "chronicle",
|
||||||
|
event_type = events::CID_MISMATCH_EXECUTION_CONTENT_MISMATCH,
|
||||||
|
claimed_cid = corpus_cid,
|
||||||
|
actual_cid = %actual_cid,
|
||||||
|
context = %binary_path.display(),
|
||||||
|
actor = "gsh",
|
||||||
|
severity = "error",
|
||||||
|
command = %cmd_name,
|
||||||
|
"Corpus binary content does not match CID directory name (tamper signal)"
|
||||||
|
);
|
||||||
|
CorpusCheckResult::ContentMismatch {
|
||||||
|
command: cmd_name,
|
||||||
|
corpus_cid: corpus_cid.to_string(),
|
||||||
|
actual_cid,
|
||||||
|
path: binary_path,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let detail = e.to_string();
|
||||||
|
warn!(
|
||||||
|
target: "chronicle",
|
||||||
|
event_type = events::CID_MISMATCH_EXECUTION_READ_FAILED,
|
||||||
|
claimed_cid = corpus_cid,
|
||||||
|
context = %binary_path.display(),
|
||||||
|
actor = "gsh",
|
||||||
|
severity = "error",
|
||||||
|
command = %cmd_name,
|
||||||
|
detail = %detail,
|
||||||
|
"Could not read corpus binary for hash verification (fail-closed)"
|
||||||
|
);
|
||||||
|
CorpusCheckResult::ReadFailed {
|
||||||
|
command: cmd_name,
|
||||||
|
corpus_cid: corpus_cid.to_string(),
|
||||||
|
path: binary_path,
|
||||||
|
detail,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
//! Scenario coverage map (execution half of the CID-content
|
||||||
|
//! verification audit fix):
|
||||||
|
//!
|
||||||
|
//! - **Valid CID, content matches: execution allowed** —
|
||||||
|
//! `binary_with_matching_content_is_allowed`.
|
||||||
|
//! - **Valid CID at admission, tampered content at execution:
|
||||||
|
//! execution denies** — `tampered_content_triggers_content_mismatch`.
|
||||||
|
//! - **Missing binary where directory exists: denied (existing
|
||||||
|
//! behavior preserved as sanity check)** —
|
||||||
|
//! `missing_binary_in_corpus_is_denied`.
|
||||||
|
//! - **Binary present but unreadable: denied fail-closed** —
|
||||||
|
//! `unreadable_binary_triggers_read_failed`.
|
||||||
|
//! - **Sentinel: ungoverned CID** — `ungoverned_skips_check`.
|
||||||
|
//! - **Sentinel: corpus directory not mounted on host** —
|
||||||
|
//! `missing_corpus_dir_reports_not_mounted`.
|
||||||
|
//!
|
||||||
|
//! The admission half (forged CID rejected at CRD reconcile) is
|
||||||
|
//! covered in corpus-operator::verifier.
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// Write bytes to `dir/cid/name` and return the path so the caller can
|
||||||
|
/// pass a matching CID for the happy path or a different one to
|
||||||
|
/// simulate tamper.
|
||||||
|
fn write_binary(dir: &Path, cid: &str, name: &str, contents: &[u8]) -> PathBuf {
|
||||||
|
let corpus_dir = dir.join(cid);
|
||||||
|
std::fs::create_dir_all(&corpus_dir).unwrap();
|
||||||
|
let path = corpus_dir.join(name);
|
||||||
|
std::fs::write(&path, contents).unwrap();
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cid_of(bytes: &[u8]) -> String {
|
||||||
|
format!("sha256:{}", hex::encode(Sha256::digest(bytes)))
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_ungoverned_skips_check() {
|
fn ungoverned_skips_check() {
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
corpus_check("sha256:ungoverned", "anything"),
|
corpus_check("sha256:ungoverned", "anything"),
|
||||||
CorpusCheckResult::Ungoverned
|
CorpusCheckResult::Ungoverned
|
||||||
|
|
@ -67,7 +194,7 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_missing_corpus_dir() {
|
fn missing_corpus_dir_reports_not_mounted() {
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
corpus_check("sha256:nonexistent", "kubectl"),
|
corpus_check("sha256:nonexistent", "kubectl"),
|
||||||
CorpusCheckResult::NotMounted
|
CorpusCheckResult::NotMounted
|
||||||
|
|
@ -75,21 +202,78 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_corpus_with_real_dir() {
|
fn binary_with_matching_content_is_allowed() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let cid = "sha256:test-corpus";
|
let contents = b"#!/bin/sh\necho kubectl\n";
|
||||||
let corpus_dir = dir.path().join(cid);
|
let cid = cid_of(contents);
|
||||||
std::fs::create_dir_all(&corpus_dir).unwrap();
|
write_binary(dir.path(), &cid, "kubectl", contents);
|
||||||
std::fs::write(corpus_dir.join("kubectl"), "").unwrap();
|
|
||||||
|
|
||||||
let base = dir.path().to_str().unwrap();
|
let base = dir.path().to_str().unwrap();
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
corpus_check_with_base(cid, "kubectl get pods -n test", base),
|
corpus_check_with_base(&cid, "kubectl get pods -n test", base),
|
||||||
CorpusCheckResult::Allowed
|
CorpusCheckResult::Allowed
|
||||||
));
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_binary_in_corpus_is_denied() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let contents = b"kubectl";
|
||||||
|
let cid = cid_of(contents);
|
||||||
|
write_binary(dir.path(), &cid, "kubectl", contents);
|
||||||
|
|
||||||
|
let base = dir.path().to_str().unwrap();
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
corpus_check_with_base(cid, "helm install", base),
|
corpus_check_with_base(&cid, "helm install", base),
|
||||||
CorpusCheckResult::Denied { .. }
|
CorpusCheckResult::Denied { .. }
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tampered_content_triggers_content_mismatch() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let claimed = cid_of(b"original-kubectl-content");
|
||||||
|
// Write content that hashes to something OTHER than the claimed CID
|
||||||
|
// but store it under the claimed CID's directory — the tamper case.
|
||||||
|
write_binary(dir.path(), &claimed, "kubectl", b"malicious-replacement");
|
||||||
|
|
||||||
|
let base = dir.path().to_str().unwrap();
|
||||||
|
match corpus_check_with_base(&claimed, "kubectl", base) {
|
||||||
|
CorpusCheckResult::ContentMismatch {
|
||||||
|
corpus_cid,
|
||||||
|
actual_cid,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
assert_eq!(corpus_cid, claimed);
|
||||||
|
assert_ne!(actual_cid, claimed);
|
||||||
|
}
|
||||||
|
other => panic!("expected ContentMismatch, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Place a directory at the path where the binary should live; the
|
||||||
|
/// `exists()` check passes but `read()` fails. Verifies the fail-closed
|
||||||
|
/// path: an unreadable binary is denied rather than allowed.
|
||||||
|
#[test]
|
||||||
|
fn unreadable_binary_triggers_read_failed() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let claimed = cid_of(b"any-content");
|
||||||
|
let corpus_dir = dir.path().join(&claimed);
|
||||||
|
// Make a directory at the binary path — it satisfies `exists()` but
|
||||||
|
// `read()` will fail with EISDIR or similar.
|
||||||
|
std::fs::create_dir_all(corpus_dir.join("kubectl")).unwrap();
|
||||||
|
|
||||||
|
let base = dir.path().to_str().unwrap();
|
||||||
|
match corpus_check_with_base(&claimed, "kubectl", base) {
|
||||||
|
CorpusCheckResult::ReadFailed {
|
||||||
|
corpus_cid,
|
||||||
|
command,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
assert_eq!(corpus_cid, claimed);
|
||||||
|
assert_eq!(command, "kubectl");
|
||||||
|
}
|
||||||
|
other => panic!("expected ReadFailed, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,13 @@ pub fn broker_url(base: &str, path: &str) -> String {
|
||||||
pub fn post_cr(client: &Client, base: &str, ac_id: &str, outcome: &str) -> CrResult {
|
pub fn post_cr(client: &Client, base: &str, ac_id: &str, outcome: &str) -> CrResult {
|
||||||
let now = chrono::Utc::now().to_rfc3339();
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
let session_id = std::env::var("CHRONICLE_SESSION_ID").unwrap_or_default();
|
let session_id = std::env::var("CHRONICLE_SESSION_ID").unwrap_or_default();
|
||||||
|
// Phase 0 D1: FFC DID sourced from env (FFC_DID), not hardcoded.
|
||||||
|
// W3C colon form. Empty on error → null in payload.
|
||||||
|
let ffc_did = std::env::var("FFC_DID")
|
||||||
|
.ok()
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.and_then(|s| guildhouse_did::Did::parse(&s).ok())
|
||||||
|
.map(|d| d.as_str().to_owned());
|
||||||
|
|
||||||
match client
|
match client
|
||||||
.post(broker_url(base, "governance/complete/"))
|
.post(broker_url(base, "governance/complete/"))
|
||||||
|
|
@ -85,7 +92,7 @@ pub fn post_cr(client: &Client, base: &str, ac_id: &str, outcome: &str) -> CrRes
|
||||||
behavioral_attestation: CrAttestation {
|
behavioral_attestation: CrAttestation {
|
||||||
status: "unavailable".into(),
|
status: "unavailable".into(),
|
||||||
},
|
},
|
||||||
ffc: serde_json::json!({"did": "did:web:guildhouse.dev", "chronicle_session_id": session_id}),
|
ffc: serde_json::json!({"did": ffc_did, "chronicle_session_id": session_id}),
|
||||||
signature: serde_json::json!({"value": "gsh"}),
|
signature: serde_json::json!({"value": "gsh"}),
|
||||||
})
|
})
|
||||||
.send()
|
.send()
|
||||||
|
|
|
||||||
159
libgsh/src/governance_env.rs
Normal file
159
libgsh/src/governance_env.rs
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
//! `GSH_*` env-var contract for child processes spawned inside gsh.
|
||||||
|
//!
|
||||||
|
//! org-ops-core (substrate-level operations library) reads these
|
||||||
|
//! discrete env vars to construct a `GshContext` without re-parsing
|
||||||
|
//! the `GSAP_SESSION_AC` JSON blob:
|
||||||
|
//!
|
||||||
|
//! | Variable | Source |
|
||||||
|
//! |----------------------|---------------------------------------|
|
||||||
|
//! | `GSH_DID` | `principal.did` (canonical string) |
|
||||||
|
//! | `GSH_ACCORD_HASH` | `accord_hash` |
|
||||||
|
//! | `GSH_SHELL_CLASS` | `shell_class` |
|
||||||
|
//! | `GSH_POSTURE_LEVEL` | `posture_level` (decimal) |
|
||||||
|
//! | `GSH_CAPABILITY_SET` | `capability_set` formatted `0x{:08x}` |
|
||||||
|
//!
|
||||||
|
//! The legacy `GSAP_SESSION_*` exports are kept by the gsh binary for
|
||||||
|
//! existing consumers; this module is purely additive.
|
||||||
|
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
use crate::ac::AuthorizationContext;
|
||||||
|
|
||||||
|
/// Apply the `GSH_*` env-var contract to a child `Command`.
|
||||||
|
///
|
||||||
|
/// Each parameter is `Option`-typed; absent values leave the
|
||||||
|
/// corresponding env var unset (the child sees no `GSH_FOO` rather
|
||||||
|
/// than an empty `GSH_FOO`). Caller passes whatever subset is known —
|
||||||
|
/// ungoverned mode might supply only `did`.
|
||||||
|
pub fn apply(
|
||||||
|
cmd: &mut Command,
|
||||||
|
did: Option<&str>,
|
||||||
|
accord_hash: Option<&str>,
|
||||||
|
shell_class: Option<&str>,
|
||||||
|
posture_level: Option<u8>,
|
||||||
|
capability_set: Option<u32>,
|
||||||
|
) {
|
||||||
|
if let Some(d) = did {
|
||||||
|
cmd.env("GSH_DID", d);
|
||||||
|
}
|
||||||
|
if let Some(h) = accord_hash {
|
||||||
|
cmd.env("GSH_ACCORD_HASH", h);
|
||||||
|
}
|
||||||
|
if let Some(c) = shell_class {
|
||||||
|
cmd.env("GSH_SHELL_CLASS", c);
|
||||||
|
}
|
||||||
|
if let Some(p) = posture_level {
|
||||||
|
cmd.env("GSH_POSTURE_LEVEL", p.to_string());
|
||||||
|
}
|
||||||
|
if let Some(c) = capability_set {
|
||||||
|
cmd.env("GSH_CAPABILITY_SET", format!("0x{:08x}", c));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply the `GSH_*` env-var contract from a parsed AC.
|
||||||
|
pub fn apply_from_ac(cmd: &mut Command, ac: &AuthorizationContext) {
|
||||||
|
let did = ac
|
||||||
|
.principal
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|p| p.did.as_ref())
|
||||||
|
.map(|d| d.as_str().to_owned());
|
||||||
|
apply(
|
||||||
|
cmd,
|
||||||
|
did.as_deref(),
|
||||||
|
ac.accord_hash.as_deref(),
|
||||||
|
ac.shell_class.as_deref(),
|
||||||
|
ac.posture_level,
|
||||||
|
ac.capability_set,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
fn cmd_env(cmd: &Command, key: &str) -> Option<String> {
|
||||||
|
cmd.get_envs().find_map(|(k, v)| {
|
||||||
|
if k == std::ffi::OsStr::new(key) {
|
||||||
|
v.map(|s| s.to_string_lossy().into_owned())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_all_fields() {
|
||||||
|
let mut cmd = Command::new("true");
|
||||||
|
apply(
|
||||||
|
&mut cmd,
|
||||||
|
Some("did:web:guildhouse.dev:user:tking"),
|
||||||
|
Some("sha256:abcd"),
|
||||||
|
Some("Application"),
|
||||||
|
Some(3),
|
||||||
|
Some(0xCAFEBABE),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cmd_env(&cmd, "GSH_DID").as_deref(),
|
||||||
|
Some("did:web:guildhouse.dev:user:tking")
|
||||||
|
);
|
||||||
|
assert_eq!(cmd_env(&cmd, "GSH_ACCORD_HASH").as_deref(), Some("sha256:abcd"));
|
||||||
|
assert_eq!(cmd_env(&cmd, "GSH_SHELL_CLASS").as_deref(), Some("Application"));
|
||||||
|
assert_eq!(cmd_env(&cmd, "GSH_POSTURE_LEVEL").as_deref(), Some("3"));
|
||||||
|
assert_eq!(
|
||||||
|
cmd_env(&cmd, "GSH_CAPABILITY_SET").as_deref(),
|
||||||
|
Some("0xcafebabe")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_partial_only_did() {
|
||||||
|
let mut cmd = Command::new("true");
|
||||||
|
apply(&mut cmd, Some("did:web:foo:bar"), None, None, None, None);
|
||||||
|
assert_eq!(cmd_env(&cmd, "GSH_DID").as_deref(), Some("did:web:foo:bar"));
|
||||||
|
assert!(cmd_env(&cmd, "GSH_ACCORD_HASH").is_none());
|
||||||
|
assert!(cmd_env(&cmd, "GSH_SHELL_CLASS").is_none());
|
||||||
|
assert!(cmd_env(&cmd, "GSH_POSTURE_LEVEL").is_none());
|
||||||
|
assert!(cmd_env(&cmd, "GSH_CAPABILITY_SET").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_from_ac_full() {
|
||||||
|
let ac_json = r#"{
|
||||||
|
"context_id":"x",
|
||||||
|
"principal":{"did":"did:web:guildhouse.dev:user:tking"},
|
||||||
|
"accord_hash":"sha256:zz",
|
||||||
|
"shell_class":"System",
|
||||||
|
"capability_set":1,
|
||||||
|
"posture_level":5
|
||||||
|
}"#;
|
||||||
|
let ac: AuthorizationContext = serde_json::from_str(ac_json).unwrap();
|
||||||
|
let mut cmd = Command::new("true");
|
||||||
|
apply_from_ac(&mut cmd, &ac);
|
||||||
|
assert_eq!(
|
||||||
|
cmd_env(&cmd, "GSH_DID").as_deref(),
|
||||||
|
Some("did:web:guildhouse.dev:user:tking")
|
||||||
|
);
|
||||||
|
assert_eq!(cmd_env(&cmd, "GSH_ACCORD_HASH").as_deref(), Some("sha256:zz"));
|
||||||
|
assert_eq!(cmd_env(&cmd, "GSH_SHELL_CLASS").as_deref(), Some("System"));
|
||||||
|
assert_eq!(cmd_env(&cmd, "GSH_POSTURE_LEVEL").as_deref(), Some("5"));
|
||||||
|
assert_eq!(
|
||||||
|
cmd_env(&cmd, "GSH_CAPABILITY_SET").as_deref(),
|
||||||
|
Some("0x00000001")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_from_legacy_ac_no_governance_fields() {
|
||||||
|
let ac_json = r#"{"context_id":"legacy","principal":{"did":"did:web:foo:bar"}}"#;
|
||||||
|
let ac: AuthorizationContext = serde_json::from_str(ac_json).unwrap();
|
||||||
|
let mut cmd = Command::new("true");
|
||||||
|
apply_from_ac(&mut cmd, &ac);
|
||||||
|
assert_eq!(cmd_env(&cmd, "GSH_DID").as_deref(), Some("did:web:foo:bar"));
|
||||||
|
// No governance metadata — none of the other GSH_* vars set.
|
||||||
|
assert!(cmd_env(&cmd, "GSH_ACCORD_HASH").is_none());
|
||||||
|
assert!(cmd_env(&cmd, "GSH_SHELL_CLASS").is_none());
|
||||||
|
assert!(cmd_env(&cmd, "GSH_POSTURE_LEVEL").is_none());
|
||||||
|
assert!(cmd_env(&cmd, "GSH_CAPABILITY_SET").is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
pub mod ac;
|
pub mod ac;
|
||||||
|
pub mod agent_api;
|
||||||
|
pub mod chronicle_events;
|
||||||
pub mod classifier;
|
pub mod classifier;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod corpus;
|
pub mod corpus;
|
||||||
pub mod cr;
|
pub mod cr;
|
||||||
|
pub mod governance_env;
|
||||||
|
pub mod register;
|
||||||
pub mod registry;
|
pub mod registry;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
|
|
||||||
|
|
|
||||||
182
libgsh/src/register.rs
Normal file
182
libgsh/src/register.rs
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
//! App shell registration via substrate-fabric IPC.
|
||||||
|
//!
|
||||||
|
//! Used by `gsh --register --service-name <name>` in systemd
|
||||||
|
//! ExecStartPre to create a governed shell for an application
|
||||||
|
//! service before its main process starts.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use thiserror::Error;
|
||||||
|
use tokio::net::UnixStream;
|
||||||
|
|
||||||
|
use substrate_ipc::fabric_api::{FabricRequest, FabricResponse};
|
||||||
|
use substrate_ipc::wire;
|
||||||
|
|
||||||
|
const DEFAULT_FABRIC_SOCKET: &str = "/run/substrate/fabric.sock";
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum RegisterError {
|
||||||
|
#[error("connecting to fabric socket at {path}: {source}")]
|
||||||
|
Connect {
|
||||||
|
path: String,
|
||||||
|
source: std::io::Error,
|
||||||
|
},
|
||||||
|
#[error("fabric IPC: {0}")]
|
||||||
|
Wire(#[from] wire::WireError),
|
||||||
|
#[error("registration denied: {reason}")]
|
||||||
|
Denied { reason: String },
|
||||||
|
#[error("fabric error: {0}")]
|
||||||
|
FabricError(String),
|
||||||
|
#[error("writing shell env to {path}: {source}")]
|
||||||
|
WriteEnv {
|
||||||
|
path: PathBuf,
|
||||||
|
source: std::io::Error,
|
||||||
|
},
|
||||||
|
#[error("reading cgroup: {0}")]
|
||||||
|
Cgroup(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ShellEnv {
|
||||||
|
pub shell_id: String,
|
||||||
|
pub env_vars: HashMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register an app shell with substrate-fabric.
|
||||||
|
///
|
||||||
|
/// 1. Discovers the caller's cgroup path
|
||||||
|
/// 2. Connects to the fabric Unix socket
|
||||||
|
/// 3. Sends RegisterAppShell
|
||||||
|
/// 4. Returns the shell environment on success
|
||||||
|
pub async fn register_app_shell(
|
||||||
|
service_name: &str,
|
||||||
|
fabric_socket: Option<&str>,
|
||||||
|
) -> Result<ShellEnv, RegisterError> {
|
||||||
|
let cgroup_path = substrate_ipc::cgroup::get_self_cgroup_path()
|
||||||
|
.map_err(|e| RegisterError::Cgroup(e.to_string()))?;
|
||||||
|
|
||||||
|
let sock_path = fabric_socket.unwrap_or(DEFAULT_FABRIC_SOCKET);
|
||||||
|
let stream = UnixStream::connect(sock_path)
|
||||||
|
.await
|
||||||
|
.map_err(|e| RegisterError::Connect {
|
||||||
|
path: sock_path.into(),
|
||||||
|
source: e,
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let (mut rd, mut wr) = tokio::io::split(stream);
|
||||||
|
|
||||||
|
let req = FabricRequest::RegisterAppShell {
|
||||||
|
service_name: service_name.into(),
|
||||||
|
cgroup_path,
|
||||||
|
};
|
||||||
|
wire::send_msg(&mut wr, &req).await?;
|
||||||
|
|
||||||
|
let resp: FabricResponse = wire::recv_msg(&mut rd).await?;
|
||||||
|
match resp {
|
||||||
|
FabricResponse::ShellCreated { shell_id, env_vars } => {
|
||||||
|
Ok(ShellEnv { shell_id, env_vars })
|
||||||
|
}
|
||||||
|
FabricResponse::Denied { reason } => Err(RegisterError::Denied { reason }),
|
||||||
|
FabricResponse::Error(msg) => Err(RegisterError::FabricError(msg)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write shell environment variables to a systemd-compatible
|
||||||
|
/// EnvironmentFile at the given directory.
|
||||||
|
///
|
||||||
|
/// Creates `{dir}/shell.env` with `KEY=VALUE` lines.
|
||||||
|
pub fn write_shell_env(dir: &Path, env: &ShellEnv) -> Result<PathBuf, RegisterError> {
|
||||||
|
std::fs::create_dir_all(dir).map_err(|e| RegisterError::WriteEnv {
|
||||||
|
path: dir.to_path_buf(),
|
||||||
|
source: e,
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let env_path = dir.join("shell.env");
|
||||||
|
let mut content = String::new();
|
||||||
|
content.push_str(&format!("SUBSTRATE_SHELL_ID={}\n", env.shell_id));
|
||||||
|
for (k, v) in &env.env_vars {
|
||||||
|
content.push_str(&format!("{k}={v}\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::fs::write(&env_path, &content).map_err(|e| RegisterError::WriteEnv {
|
||||||
|
path: env_path.clone(),
|
||||||
|
source: e,
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(env_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_shell_env_creates_file() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let env = ShellEnv {
|
||||||
|
shell_id: "test-shell-001".into(),
|
||||||
|
env_vars: {
|
||||||
|
let mut m = HashMap::new();
|
||||||
|
m.insert("GSH_DID".into(), "did:web:example:user:test".into());
|
||||||
|
m.insert("GSH_SHELL_CLASS".into(), "Application".into());
|
||||||
|
m
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let path = write_shell_env(dir.path(), &env).unwrap();
|
||||||
|
assert!(path.exists());
|
||||||
|
|
||||||
|
let content = std::fs::read_to_string(&path).unwrap();
|
||||||
|
assert!(content.contains("SUBSTRATE_SHELL_ID=test-shell-001"));
|
||||||
|
assert!(content.contains("GSH_DID=did:web:example:user:test"));
|
||||||
|
assert!(content.contains("GSH_SHELL_CLASS=Application"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn register_fails_when_socket_missing() {
|
||||||
|
let result = register_app_shell("test-svc", Some("/tmp/nonexistent-fabric.sock")).await;
|
||||||
|
assert!(matches!(result, Err(RegisterError::Connect { .. })));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn register_round_trip_with_mock_fabric() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let sock_path = dir.path().join("fabric.sock");
|
||||||
|
let sock_str = sock_path.to_str().unwrap().to_string();
|
||||||
|
|
||||||
|
let listener = tokio::net::UnixListener::bind(&sock_path).unwrap();
|
||||||
|
|
||||||
|
let sock_clone = sock_str.clone();
|
||||||
|
let client = tokio::spawn(async move {
|
||||||
|
register_app_shell("my-daemon", Some(&sock_clone)).await
|
||||||
|
});
|
||||||
|
|
||||||
|
let (stream, _) = listener.accept().await.unwrap();
|
||||||
|
let (mut rd, mut wr) = tokio::io::split(stream);
|
||||||
|
|
||||||
|
let req: FabricRequest = wire::recv_msg(&mut rd).await.unwrap();
|
||||||
|
match req {
|
||||||
|
FabricRequest::RegisterAppShell { service_name, .. } => {
|
||||||
|
assert_eq!(service_name, "my-daemon");
|
||||||
|
}
|
||||||
|
_ => panic!("wrong request variant"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut env_vars = HashMap::new();
|
||||||
|
env_vars.insert("GSH_DID".into(), "did:web:test:hosts:h1:shells:my-daemon".into());
|
||||||
|
env_vars.insert("GSH_SHELL_CLASS".into(), "Application".into());
|
||||||
|
let resp = FabricResponse::ShellCreated {
|
||||||
|
shell_id: "shell-abc".into(),
|
||||||
|
env_vars,
|
||||||
|
};
|
||||||
|
wire::send_msg(&mut wr, &resp).await.unwrap();
|
||||||
|
|
||||||
|
let result = client.await.unwrap().unwrap();
|
||||||
|
assert_eq!(result.shell_id, "shell-abc");
|
||||||
|
assert_eq!(
|
||||||
|
result.env_vars.get("GSH_DID").unwrap(),
|
||||||
|
"did:web:test:hosts:h1:shells:my-daemon"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -17,14 +17,25 @@ pub struct SessionState {
|
||||||
pub free_count: u32,
|
pub free_count: u32,
|
||||||
pub ungoverned_count: u32,
|
pub ungoverned_count: u32,
|
||||||
pub denied_count: u32,
|
pub denied_count: u32,
|
||||||
|
/// Governance-context fields propagated from the AC (Phase 1 of org-ops-core
|
||||||
|
/// CLI standardization, 2026-05-03). Used to decorate child Commands with
|
||||||
|
/// `GSH_*` env vars via [`SessionState::apply_governance_env`].
|
||||||
|
pub accord_hash: Option<String>,
|
||||||
|
pub shell_class: Option<String>,
|
||||||
|
pub capability_set: Option<u32>,
|
||||||
|
pub posture_level: Option<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SessionState {
|
impl SessionState {
|
||||||
pub fn from_ac(ac: &AuthorizationContext, corpus_cid: &str) -> Self {
|
pub fn from_ac(ac: &AuthorizationContext, corpus_cid: &str) -> Self {
|
||||||
|
// Phase 0: AcPrincipal.did is now Did-typed. Render to canonical
|
||||||
|
// string for SessionState.principal (which stays String — it can
|
||||||
|
// also hold a Unix username in ungoverned mode, so a typed Did
|
||||||
|
// would force Option<Did> here and complicate the chain).
|
||||||
let principal = ac
|
let principal = ac
|
||||||
.principal
|
.principal
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|p| p.did.clone())
|
.and_then(|p| p.did.as_ref().map(|d| d.as_str().to_owned()))
|
||||||
.or_else(|| {
|
.or_else(|| {
|
||||||
ac.principal
|
ac.principal
|
||||||
.as_ref()
|
.as_ref()
|
||||||
|
|
@ -60,6 +71,10 @@ impl SessionState {
|
||||||
free_count: 0,
|
free_count: 0,
|
||||||
ungoverned_count: 0,
|
ungoverned_count: 0,
|
||||||
denied_count: 0,
|
denied_count: 0,
|
||||||
|
accord_hash: ac.accord_hash.clone(),
|
||||||
|
shell_class: ac.shell_class.clone(),
|
||||||
|
capability_set: ac.capability_set,
|
||||||
|
posture_level: ac.posture_level,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -92,9 +107,31 @@ impl SessionState {
|
||||||
free_count: 0,
|
free_count: 0,
|
||||||
ungoverned_count: 0,
|
ungoverned_count: 0,
|
||||||
denied_count: 0,
|
denied_count: 0,
|
||||||
|
accord_hash: None,
|
||||||
|
shell_class: None,
|
||||||
|
capability_set: None,
|
||||||
|
posture_level: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Apply the `GSH_*` env-var contract to a child `Command`.
|
||||||
|
///
|
||||||
|
/// Always exports `GSH_DID` (the resolved principal). Governance
|
||||||
|
/// metadata (`GSH_ACCORD_HASH`, `GSH_SHELL_CLASS`,
|
||||||
|
/// `GSH_POSTURE_LEVEL`, `GSH_CAPABILITY_SET`) is exported only when
|
||||||
|
/// the session has the corresponding fields populated (i.e. governed
|
||||||
|
/// mode with a full AC).
|
||||||
|
pub fn apply_governance_env(&self, cmd: &mut std::process::Command) {
|
||||||
|
crate::governance_env::apply(
|
||||||
|
cmd,
|
||||||
|
Some(&self.principal),
|
||||||
|
self.accord_hash.as_deref(),
|
||||||
|
self.shell_class.as_deref(),
|
||||||
|
self.posture_level,
|
||||||
|
self.capability_set,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
pub fn minutes_remaining(&self) -> i64 {
|
pub fn minutes_remaining(&self) -> i64 {
|
||||||
match &self.expires_at {
|
match &self.expires_at {
|
||||||
Some(exp) => (*exp - chrono::Utc::now()).num_minutes(),
|
Some(exp) => (*exp - chrono::Utc::now()).num_minutes(),
|
||||||
|
|
@ -114,16 +151,30 @@ fn whoami() -> String {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Derive a human-readable display name from a DID.
|
/// Derive a human-readable display name from a DID.
|
||||||
/// did:web:guildhouse.dev/user/tking → tking@guildhouse.dev
|
///
|
||||||
/// Fallback: return the full DID.
|
/// Phase 0 D1: prefers the W3C-canonical colon form
|
||||||
|
/// (`did:web:host:user:tking → tking@host`). The legacy slash form
|
||||||
|
/// (`did:web:host/user/tking`) is also recognized as a transition
|
||||||
|
/// affordance so any stale stored DIDs render sensibly. Falls through
|
||||||
|
/// to the input string when no `did:web:` prefix is present.
|
||||||
fn display_name_from_did(did: &str) -> String {
|
fn display_name_from_did(did: &str) -> String {
|
||||||
if let Some(rest) = did.strip_prefix("did:web:") {
|
let Some(rest) = did.strip_prefix("did:web:") else {
|
||||||
let parts: Vec<&str> = rest.splitn(2, '/').collect();
|
return did.to_string();
|
||||||
if parts.len() == 2 {
|
};
|
||||||
let domain = parts[0];
|
|
||||||
let name = parts[1].rsplit('/').next().unwrap_or(parts[1]);
|
// Try colon form first: `host:seg1:...:segN` → name=segN, domain=host.
|
||||||
return format!("{}@{}", name, domain);
|
if let Some((domain, path)) = rest.split_once(':') {
|
||||||
|
if !path.is_empty() {
|
||||||
|
let name = path.rsplit(':').next().unwrap_or(path);
|
||||||
|
return format!("{name}@{domain}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Legacy slash form: `host/user/tking`.
|
||||||
|
if let Some((domain, path)) = rest.split_once('/') {
|
||||||
|
let name = path.rsplit('/').next().unwrap_or(path);
|
||||||
|
return format!("{name}@{domain}");
|
||||||
|
}
|
||||||
|
|
||||||
did.to_string()
|
did.to_string()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue