Compare commits
No commits in common. "7c84854222d9b214365ac9b8447b0cd5a8f45fe198b27f7792dcf7177ab61e77" and "d0b9ca0e6ab7cc1360616df3d748b302565c0c9f9814a856b5d1e6cf60c55fd5" have entirely different histories.
7c84854222
...
d0b9ca0e6a
18 changed files with 62 additions and 1396 deletions
51
CLAUDE.md
51
CLAUDE.md
|
|
@ -1,51 +0,0 @@
|
|||
# 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,17 +67,6 @@ version = "1.0.102"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "atomic-waker"
|
||||
version = "1.1.2"
|
||||
|
|
@ -101,34 +90,12 @@ version = "1.5.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "base64ct"
|
||||
version = "1.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.11.0"
|
||||
|
|
@ -175,12 +142,6 @@ version = "1.0.4"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "cfg_aliases"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.44"
|
||||
|
|
@ -195,33 +156,6 @@ dependencies = [
|
|||
"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]]
|
||||
name = "clap"
|
||||
version = "4.6.0"
|
||||
|
|
@ -278,18 +212,6 @@ dependencies = [
|
|||
"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]]
|
||||
name = "core-foundation"
|
||||
version = "0.9.4"
|
||||
|
|
@ -351,12 +273,6 @@ dependencies = [
|
|||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crunchy"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
|
|
@ -367,69 +283,6 @@ dependencies = [
|
|||
"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]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
|
|
@ -472,32 +325,6 @@ dependencies = [
|
|||
"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]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
|
|
@ -546,12 +373,6 @@ dependencies = [
|
|||
"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]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
|
|
@ -690,28 +511,9 @@ dependencies = [
|
|||
"reedline",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"substrate-ipc",
|
||||
"tokio",
|
||||
"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]]
|
||||
name = "h2"
|
||||
version = "0.4.13"
|
||||
|
|
@ -731,17 +533,6 @@ dependencies = [
|
|||
"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]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
|
|
@ -1113,17 +904,13 @@ version = "0.1.0"
|
|||
dependencies = [
|
||||
"chrono",
|
||||
"dirs",
|
||||
"guildhouse-did",
|
||||
"hex",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"substrate-ipc",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1168,32 +955,12 @@ version = "0.4.29"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "memchr"
|
||||
version = "2.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||
|
||||
[[package]]
|
||||
name = "memoffset"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mime"
|
||||
version = "0.3.17"
|
||||
|
|
@ -1212,18 +979,6 @@ dependencies = [
|
|||
"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]]
|
||||
name = "native-tls"
|
||||
version = "0.2.18"
|
||||
|
|
@ -1241,19 +996,6 @@ dependencies = [
|
|||
"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]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
|
|
@ -1369,16 +1111,6 @@ version = "0.2.17"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.32"
|
||||
|
|
@ -1428,15 +1160,6 @@ version = "6.0.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
|
|
@ -1533,15 +1256,6 @@ dependencies = [
|
|||
"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]]
|
||||
name = "rustix"
|
||||
version = "0.38.44"
|
||||
|
|
@ -1760,15 +1474,6 @@ dependencies = [
|
|||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signature"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||
dependencies = [
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
|
|
@ -1791,16 +1496,6 @@ dependencies = [
|
|||
"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]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
|
|
@ -1841,17 +1536,6 @@ dependencies = [
|
|||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "substrate-ipc"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"ciborium",
|
||||
"nix",
|
||||
"serde",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
|
|
@ -1982,25 +1666,11 @@ dependencies = [
|
|||
"bytes",
|
||||
"libc",
|
||||
"mio",
|
||||
"parking_lot",
|
||||
"pin-project-lite",
|
||||
"signal-hook-registry",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"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]]
|
||||
name = "tokio-native-tls"
|
||||
version = "0.3.1"
|
||||
|
|
@ -2086,21 +1756,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||
dependencies = [
|
||||
"pin-project-lite",
|
||||
"tracing-attributes",
|
||||
"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]]
|
||||
name = "tracing-core"
|
||||
version = "0.1.36"
|
||||
|
|
@ -2697,26 +2355,6 @@ dependencies = [
|
|||
"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]]
|
||||
name = "zerofrom"
|
||||
version = "0.1.7"
|
||||
|
|
|
|||
|
|
@ -23,6 +23,3 @@ dirs = "5"
|
|||
reedline = "0.38"
|
||||
colored = "2"
|
||||
atty = "0.2"
|
||||
tracing = "0.1"
|
||||
substrate-ipc = { path = "../substrate/crates/substrate-ipc" }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
# gsh
|
||||
|
||||
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.
|
||||
gsh — the GCAP governed shell. Human and machine modes. Chronicle-attributed execution.
|
||||
|
|
@ -19,5 +19,3 @@ chrono = { workspace = true }
|
|||
reedline = { workspace = true }
|
||||
colored = { workspace = true }
|
||||
atty = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
substrate-ipc = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ pub fn run_human_mode(
|
|||
// Classify command:
|
||||
match classify_command(line, &session.corpus_cid, &corpus_dir) {
|
||||
CommandClass::Free => {
|
||||
execute_passthrough(line, session);
|
||||
execute_passthrough(line);
|
||||
session.free_count += 1;
|
||||
}
|
||||
CommandClass::Governed { corpus_binary } => {
|
||||
|
|
@ -80,7 +80,7 @@ pub fn run_human_mode(
|
|||
"{}",
|
||||
format!(" ⚠ ungoverned: '{}' not in corpus", cmd_name).yellow()
|
||||
);
|
||||
execute_passthrough(line, session);
|
||||
execute_passthrough(line);
|
||||
session.ungoverned_count += 1;
|
||||
}
|
||||
CommandClass::Denied { reason } => {
|
||||
|
|
@ -234,11 +234,11 @@ fn build_prompt(session: &SessionState) -> DefaultPrompt {
|
|||
)
|
||||
}
|
||||
|
||||
fn execute_passthrough(line: &str, session: &SessionState) -> i32 {
|
||||
let mut cmd = std::process::Command::new("sh");
|
||||
cmd.arg("-c").arg(line);
|
||||
session.apply_governance_env(&mut cmd);
|
||||
let status = cmd.status();
|
||||
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) => {
|
||||
|
|
@ -250,12 +250,11 @@ fn execute_passthrough(line: &str, 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 mut cmd = std::process::Command::new(corpus_binary);
|
||||
cmd.args(&args)
|
||||
let status = std::process::Command::new(corpus_binary)
|
||||
.args(&args)
|
||||
.env("BASCULE_SESSION_ID", &session.ac_id)
|
||||
.env("BASCULE_CORPUS_CID", &session.corpus_cid);
|
||||
session.apply_governance_env(&mut cmd);
|
||||
let status = cmd.status();
|
||||
.env("BASCULE_CORPUS_CID", &session.corpus_cid)
|
||||
.status();
|
||||
match status {
|
||||
Ok(s) => s.code().unwrap_or(1),
|
||||
Err(e) => {
|
||||
|
|
|
|||
|
|
@ -63,19 +63,6 @@ enum Cmd {
|
|||
},
|
||||
SessionEnd,
|
||||
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)]
|
||||
|
|
@ -138,18 +125,8 @@ fn run(args: Args) -> Result<i32> {
|
|||
return Ok(code);
|
||||
}
|
||||
|
||||
// ── Subcommands ─────────────────────────────────────────
|
||||
// ── Session subcommands ──────────────────────────────────
|
||||
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()
|
||||
.or_else(|| std::env::var("GSAP_BROKER_URL").ok())
|
||||
.context("GSAP_BROKER_URL not set")?;
|
||||
|
|
@ -189,7 +166,6 @@ fn run(args: Args) -> Result<i32> {
|
|||
}
|
||||
Ok(0)
|
||||
}
|
||||
Cmd::Register { .. } => unreachable!("handled above"),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -239,11 +215,7 @@ fn run(args: Args) -> Result<i32> {
|
|||
// Determine AC mode:
|
||||
let pre_issued = args.ac.clone().or_else(|| std::env::var("GSAP_AC").ok());
|
||||
|
||||
// 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 (ac_id, ac_mode) = 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))?;
|
||||
|
|
@ -251,9 +223,9 @@ fn run(args: Args) -> Result<i32> {
|
|||
if let Some(ref p) = ac.principal {
|
||||
if let Some(ref did) = p.did { eprintln!("gsh: principal — {}", did); }
|
||||
}
|
||||
(ac.context_id.clone(), "pre-issued".to_string(), Some(ac))
|
||||
(ac.context_id, "pre-issued".to_string())
|
||||
} else if let Ok(session_ac) = std::env::var("GSAP_SESSION_AC") {
|
||||
(session_ac, "session".to_string(), None)
|
||||
(session_ac, "session".to_string())
|
||||
} else {
|
||||
let base = args.broker_url.clone()
|
||||
.or_else(|| std::env::var("GSAP_BROKER_URL").ok())
|
||||
|
|
@ -263,7 +235,7 @@ fn run(args: Args) -> Result<i32> {
|
|||
let id = request_ac_inline(&client, &base, &args.operation, &command_hash, &corpus)
|
||||
.map_err(|e| anyhow::anyhow!(e))?;
|
||||
eprintln!("gsh: AC issued — {}", &id[..8.min(id.len())]);
|
||||
(id, "inline".to_string(), None)
|
||||
(id, "inline".to_string())
|
||||
};
|
||||
|
||||
// Corpus gate:
|
||||
|
|
@ -272,40 +244,10 @@ fn run(args: Args) -> Result<i32> {
|
|||
eprintln!("gsh: command '{}' not in corpus {} (killswitch active)", command, corpus_cid);
|
||||
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 => {
|
||||
eprintln!("gsh: corpus directory not found (host may not have corpus mounted)");
|
||||
}
|
||||
libgsh::CorpusCheckResult::Allowed | libgsh::CorpusCheckResult::Ungoverned => {}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if args.dry_run {
|
||||
|
|
@ -321,12 +263,7 @@ fn run(args: Args) -> Result<i32> {
|
|||
}
|
||||
|
||||
// Execute:
|
||||
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 output = process::Command::new("sh").arg("-c").arg(exec).output().context("exec failed")?;
|
||||
let exit_code = output.status.code().unwrap_or(1);
|
||||
let stdout_str = String::from_utf8_lossy(&output.stdout).to_string();
|
||||
let stderr_str = String::from_utf8_lossy(&output.stderr).to_string();
|
||||
|
|
@ -363,24 +300,3 @@ fn run(args: Args) -> Result<i32> {
|
|||
|
||||
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,7 +5,6 @@ edition.workspace = true
|
|||
description = "Governed shell library — AC validation, CR building, corpus gate"
|
||||
|
||||
[dependencies]
|
||||
guildhouse-did = { path = "../../guildhouse-did" }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
|
|
@ -14,9 +13,6 @@ sha2 = { workspace = true }
|
|||
hex = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
dirs = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
substrate-ipc = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
|
||||
|
||||
[dev-dependencies]
|
||||
|
|
|
|||
|
|
@ -1,59 +1,39 @@
|
|||
//! Authorization Context validation (R-22, R-23, R-24).
|
||||
|
||||
use guildhouse_did::Did;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::Deserialize;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::registry::ConsumedRegistry;
|
||||
|
||||
/// A pre-issued Authorization Context from the broker.
|
||||
///
|
||||
/// 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)]
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
pub struct AuthorizationContext {
|
||||
pub context_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub issued_at: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub expires_at: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub operation: Option<AcOperation>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
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(Serialize, Deserialize, Debug, Clone, Default)]
|
||||
#[derive(Deserialize, Debug, Clone, Default)]
|
||||
pub struct AcOperation {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub corpus_entry_cid: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub parameters_cid: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub playbook: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
|
||||
#[derive(Deserialize, Debug, Clone, Default)]
|
||||
pub struct AcPrincipal {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub did: Option<Did>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub did: Option<String>,
|
||||
#[serde(default)]
|
||||
pub display_name: Option<String>,
|
||||
}
|
||||
|
||||
|
|
@ -191,49 +171,4 @@ mod tests {
|
|||
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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,129 +0,0 @@
|
|||
//! 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 { .. }));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
//! 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,15 +1,13 @@
|
|||
//! Configuration from environment variables.
|
||||
|
||||
use guildhouse_did::Did;
|
||||
|
||||
/// Consolidated gsh configuration from env vars.
|
||||
pub struct GshConfig {
|
||||
/// Pre-issued AC JSON string.
|
||||
pub ac: Option<String>,
|
||||
/// GSAP broker URL.
|
||||
pub broker_url: Option<String>,
|
||||
/// Agent DID. None when `GSAP_AGENT_DID` is unset or doesn't parse as a DID.
|
||||
pub agent_did: Option<Did>,
|
||||
/// Agent DID.
|
||||
pub agent_did: Option<String>,
|
||||
/// Bearer auth token.
|
||||
pub token: Option<String>,
|
||||
/// Corpus CID this session is authorized for.
|
||||
|
|
@ -30,9 +28,7 @@ impl GshConfig {
|
|||
Self {
|
||||
ac: std::env::var("GSAP_AC").ok(),
|
||||
broker_url: std::env::var("GSAP_BROKER_URL").ok(),
|
||||
agent_did: std::env::var("GSAP_AGENT_DID")
|
||||
.ok()
|
||||
.and_then(|s| Did::parse(&s).ok()),
|
||||
agent_did: std::env::var("GSAP_AGENT_DID").ok(),
|
||||
token: std::env::var("GSAP_TOKEN").ok(),
|
||||
corpus_cid: std::env::var("GSAP_CORPUS_CID")
|
||||
.unwrap_or_else(|_| "sha256:ungoverned".into()),
|
||||
|
|
|
|||
|
|
@ -1,35 +1,11 @@
|
|||
//! 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, PathBuf};
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
use tracing::warn;
|
||||
|
||||
use crate::chronicle_events as events;
|
||||
use std::path::Path;
|
||||
|
||||
/// Result of a corpus check.
|
||||
#[derive(Debug)]
|
||||
pub enum CorpusCheckResult {
|
||||
/// Binary found in corpus and its content hashes to the expected CID.
|
||||
/// Binary found in corpus — allowed.
|
||||
Allowed,
|
||||
/// Corpus is ungoverned — no check performed.
|
||||
Ungoverned,
|
||||
|
|
@ -37,23 +13,6 @@ pub enum CorpusCheckResult {
|
|||
NotMounted,
|
||||
/// Binary not in corpus directory — denied (killswitch active).
|
||||
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.
|
||||
|
|
@ -62,9 +21,7 @@ pub const DEFAULT_CORPUS_BASE: &str = "/opt/substrate/corpus";
|
|||
/// Check if a command is authorized in the corpus directory.
|
||||
///
|
||||
/// `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,
|
||||
/// ContentMismatch, or ReadFailed — all three are execution-denied
|
||||
/// states.
|
||||
/// Returns Ok(result) always. Caller decides whether to block on Denied.
|
||||
pub fn corpus_check(corpus_cid: &str, command: &str) -> CorpusCheckResult {
|
||||
corpus_check_with_base(corpus_cid, command, DEFAULT_CORPUS_BASE)
|
||||
}
|
||||
|
|
@ -87,106 +44,22 @@ pub fn corpus_check_with_base(corpus_cid: &str, command: &str, base_dir: &str) -
|
|||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| cmd_name.to_string());
|
||||
|
||||
let binary_path = corpus_dir.join(&cmd_name);
|
||||
if !binary_path.exists() {
|
||||
return CorpusCheckResult::Denied {
|
||||
if corpus_dir.join(&cmd_name).exists() {
|
||||
CorpusCheckResult::Allowed
|
||||
} else {
|
||||
CorpusCheckResult::Denied {
|
||||
command: cmd_name,
|
||||
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)]
|
||||
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::*;
|
||||
|
||||
/// 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]
|
||||
fn ungoverned_skips_check() {
|
||||
fn test_ungoverned_skips_check() {
|
||||
assert!(matches!(
|
||||
corpus_check("sha256:ungoverned", "anything"),
|
||||
CorpusCheckResult::Ungoverned
|
||||
|
|
@ -194,7 +67,7 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn missing_corpus_dir_reports_not_mounted() {
|
||||
fn test_missing_corpus_dir() {
|
||||
assert!(matches!(
|
||||
corpus_check("sha256:nonexistent", "kubectl"),
|
||||
CorpusCheckResult::NotMounted
|
||||
|
|
@ -202,78 +75,21 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn binary_with_matching_content_is_allowed() {
|
||||
fn test_corpus_with_real_dir() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let contents = b"#!/bin/sh\necho kubectl\n";
|
||||
let cid = cid_of(contents);
|
||||
write_binary(dir.path(), &cid, "kubectl", contents);
|
||||
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("kubectl"), "").unwrap();
|
||||
|
||||
let base = dir.path().to_str().unwrap();
|
||||
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
|
||||
));
|
||||
}
|
||||
|
||||
#[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!(
|
||||
corpus_check_with_base(&cid, "helm install", base),
|
||||
corpus_check_with_base(cid, "helm install", base),
|
||||
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,13 +70,6 @@ pub fn broker_url(base: &str, path: &str) -> String {
|
|||
pub fn post_cr(client: &Client, base: &str, ac_id: &str, outcome: &str) -> CrResult {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
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
|
||||
.post(broker_url(base, "governance/complete/"))
|
||||
|
|
@ -92,7 +85,7 @@ pub fn post_cr(client: &Client, base: &str, ac_id: &str, outcome: &str) -> CrRes
|
|||
behavioral_attestation: CrAttestation {
|
||||
status: "unavailable".into(),
|
||||
},
|
||||
ffc: serde_json::json!({"did": ffc_did, "chronicle_session_id": session_id}),
|
||||
ffc: serde_json::json!({"did": "did:web:guildhouse.dev", "chronicle_session_id": session_id}),
|
||||
signature: serde_json::json!({"value": "gsh"}),
|
||||
})
|
||||
.send()
|
||||
|
|
|
|||
|
|
@ -1,159 +0,0 @@
|
|||
//! `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,12 +1,8 @@
|
|||
pub mod ac;
|
||||
pub mod agent_api;
|
||||
pub mod chronicle_events;
|
||||
pub mod classifier;
|
||||
pub mod config;
|
||||
pub mod corpus;
|
||||
pub mod cr;
|
||||
pub mod governance_env;
|
||||
pub mod register;
|
||||
pub mod registry;
|
||||
pub mod session;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,182 +0,0 @@
|
|||
//! 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,25 +17,14 @@ pub struct SessionState {
|
|||
pub free_count: u32,
|
||||
pub ungoverned_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 {
|
||||
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
|
||||
.principal
|
||||
.as_ref()
|
||||
.and_then(|p| p.did.as_ref().map(|d| d.as_str().to_owned()))
|
||||
.and_then(|p| p.did.clone())
|
||||
.or_else(|| {
|
||||
ac.principal
|
||||
.as_ref()
|
||||
|
|
@ -71,10 +60,6 @@ impl SessionState {
|
|||
free_count: 0,
|
||||
ungoverned_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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -107,31 +92,9 @@ impl SessionState {
|
|||
free_count: 0,
|
||||
ungoverned_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 {
|
||||
match &self.expires_at {
|
||||
Some(exp) => (*exp - chrono::Utc::now()).num_minutes(),
|
||||
|
|
@ -151,30 +114,16 @@ fn whoami() -> String {
|
|||
}
|
||||
|
||||
/// Derive a human-readable display name from a 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.
|
||||
/// did:web:guildhouse.dev/user/tking → tking@guildhouse.dev
|
||||
/// Fallback: return the full DID.
|
||||
fn display_name_from_did(did: &str) -> String {
|
||||
let Some(rest) = did.strip_prefix("did:web:") else {
|
||||
return did.to_string();
|
||||
};
|
||||
|
||||
// Try colon form first: `host:seg1:...:segN` → name=segN, domain=host.
|
||||
if let Some((domain, path)) = rest.split_once(':') {
|
||||
if !path.is_empty() {
|
||||
let name = path.rsplit(':').next().unwrap_or(path);
|
||||
return format!("{name}@{domain}");
|
||||
if let Some(rest) = did.strip_prefix("did:web:") {
|
||||
let parts: Vec<&str> = rest.splitn(2, '/').collect();
|
||||
if parts.len() == 2 {
|
||||
let domain = parts[0];
|
||||
let name = parts[1].rsplit('/').next().unwrap_or(parts[1]);
|
||||
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()
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue