Compare commits

..

6 commits

Author SHA256 Message Date
7c84854222 feat(gsh): add --register mode and agent API for shellbound host
Adds `gsh register --service-name <name>` subcommand for systemd
ExecStartPre integration. Connects to substrate-fabric Unix socket,
sends RegisterAppShell, writes shell.env for EnvironmentFile= loading.

New libgsh modules:
- register.rs: fabric IPC client for app shell creation + env writer
- agent_api.rs: capability attenuation validation for agent sub-shells

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Tyler J King <tking@guildhouse.dev>
2026-05-15 05:47:29 -04:00
061e2206ea docs: add CLAUDE.md
Documents the 2 crates (gsh binary + libgsh library), the GSH_*
env-var contract that bascule sets and forge-fuse reads (Phase 3
shell-IS-session), the cross-workspace dependency on
forge-core::CorpusCapabilityCeiling for the runtime three-way
intersection, and the local dev-environment/ playbook for the
WSL2 + Bascule + Hetzner Keycloak + Entra federation flow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Tyler J King <tking@guildhouse.dev>
2026-05-04 11:41:30 -04:00
88840ae620 feat(libgsh): GSH_* env contract for org-ops-core child processes
Phase 1 of org-ops-core CLI standardization: gsh now exports a
discrete set of governance-context env vars to child processes so
org-ops-core (substrate-level operations library, future move) can
construct a GshContext without re-parsing the GSAP_SESSION_AC blob.

Contract:

  GSH_DID            principal.did (canonical string)
  GSH_ACCORD_HASH    accord_hash
  GSH_SHELL_CLASS    shell_class ("Application" | "System" | ...)
  GSH_POSTURE_LEVEL  posture_level (decimal 1..=5)
  GSH_CAPABILITY_SET capability_set formatted "0x{:08x}"

AC schema (libgsh::ac::AuthorizationContext) gains four optional
fields — accord_hash, shell_class, capability_set, posture_level —
all #[serde(default, skip_serializing_if = "Option::is_none")].
Existing AC producers continue working unchanged; ACs without the
new fields parse cleanly. Serialize is added to the AC structs
to enable round-trip and to let library consumers construct ACs
programmatically.

New module libgsh::governance_env exposes:
- apply(cmd, did, accord_hash, shell_class, posture_level,
       capability_set) — stateless decorator
- apply_from_ac(cmd, &AC) — convenience wrapper over apply

SessionState gains the four governance fields (populated from AC
in from_ac, left None in ungoverned). SessionState::apply_governance_env
threads them onto a child Command at REPL spawn sites.

Spawn sites updated:
- gsh::main::run (governed --exec) — retains the parsed AC and
  calls governance_env::apply_from_ac on the exec Command.
- gsh::human::execute_passthrough — now takes &SessionState;
  applies session governance env (REPL Free/Ungoverned paths).
- gsh::human::execute_governed — applies session governance env
  alongside the existing BASCULE_SESSION_ID / BASCULE_CORPUS_CID.

Legacy GSAP_SESSION_AC / GSAP_SESSION_ID / GSAP_SESSION_SCOPE exports
remain intact — the GSH_* vars are purely additive convenience for
org-ops-core. Session and inline AC modes (which surface only an
ID, not the full struct) export nothing new — same fail-soft
behaviour as before.

Tests added:
- ac::tests::test_governance_fields_round_trip — full payload
  parses and re-serializes losslessly.
- ac::tests::test_governance_fields_absent_back_compat — legacy
  AC parses without governance fields and round-trips without
  emitting them.
- governance_env::tests::apply_all_fields — every GSH_* var set.
- governance_env::tests::apply_partial_only_did — missing fields
  leave the env var unset rather than empty.
- governance_env::tests::apply_from_ac_full — end-to-end AC →
  env var application.
- governance_env::tests::apply_from_legacy_ac_no_governance_fields
  — legacy AC sets only GSH_DID, no other GSH_* vars.

24 tests pass; cargo build clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Tyler J King <tking@guildhouse.dev>
2026-05-03 08:50:20 -04:00
f810537581 feat(libgsh): Phase 0 — typed Did on AcPrincipal
`AcPrincipal.did: Option<String>` → `Option<guildhouse_did::Did>`.
The AuthorizationContext now carries a W3C-canonical typed DID;
malformed DIDs fail at deserialize time rather than propagating
into the corpus_check / session state.

SessionState.principal stays a String — it can also hold a Unix
username in ungoverned mode, so a typed Did would force
Option<Did> there and complicate the chain. The render at
SessionState::from_ac now goes Did → as_str() instead of cloning
the legacy String. Behaviour at the audit-leaf level is
unchanged when the AC carries a valid `did:web:...` payload.

Phase 0 of DESIGN-DID-INTEGRATION-2026-04-29 §5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Tyler J King <tking@guildhouse.dev>
2026-05-01 06:28:19 -04:00
91f027ae61 libgsh: complete scenario coverage for corpus_check execution paths
Adds the ReadFailed scenario (binary path resolves to a directory so
exists() succeeds but read() fails) and a scenarios coverage map at the
top of the test module. The map links each test to the audit fix
scenarios:

- valid CID, content matches: Allowed
- valid CID at admission, tampered content at execution: ContentMismatch
- missing binary where directory exists: Denied (sanity preserved)
- binary present but unreadable: ReadFailed (fail-closed)

Plus the existing sentinels for ungoverned-CID and corpus-not-mounted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Tyler J King <tking@guildhouse.dev>
2026-04-25 03:18:56 -04:00
13b393a7f1 libgsh: verify corpus binary content before allowing execution
corpus_check() previously returned Allowed as soon as it found a file by
name in the corpus directory keyed by CID. The CID acted as a directory
label, not a content commitment. An attacker with write access to the
corpus directory could plant a malicious binary under a legitimate CID
and it would execute with that CID's authorization.

This change hashes the binary at the resolved path and compares to the
CID its directory is named for. Mismatches return a new ContentMismatch
variant; unreadable binaries return ReadFailed. Both are execution-denied
states — main.rs handles each explicitly with exit code 3 (previously
used only for Denied).

Both error classes emit Chronicle-shaped structured tracing events
(target: "chronicle") with stable event_type constants from
libgsh::chronicle_events. The field shape matches what substrate-chronicle's
post-io_uring emission API is expected to require; migration to direct
Chronicle emission becomes a mechanical translation once that API
stabilizes.

The tamper signal is that the binary and its directory name disagree.
This closes the execution-path half of the CID-content verification
audit fix — admission (corpus-operator) rejects CID forgery before the
enforcement ConfigMap is written; execution (libgsh) rejects any tamper
that landed after admission. Defense in depth across both layers.

Kernel-layer CID verification (the third layer, where eBPF LSM hooks
authorize by binary name via FNV-1a hash of comm) is explicit backlog,
deferred to Bifrost where in-kernel hashing or a ring-buffer userspace
verifier can be evaluated properly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Tyler J King <tking@guildhouse.dev>
2026-04-25 03:02:37 -04:00
18 changed files with 1396 additions and 62 deletions

51
CLAUDE.md Normal file
View 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
View file

@ -67,6 +67,17 @@ 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"
@ -90,12 +101,34 @@ 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"
@ -142,6 +175,12 @@ 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"
@ -156,6 +195,33 @@ 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"
@ -212,6 +278,18 @@ 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"
@ -273,6 +351,12 @@ 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"
@ -283,6 +367,69 @@ 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"
@ -325,6 +472,32 @@ 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"
@ -373,6 +546,12 @@ 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"
@ -511,9 +690,28 @@ 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"
@ -533,6 +731,17 @@ 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"
@ -904,13 +1113,17 @@ 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]]
@ -955,12 +1168,32 @@ 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"
@ -979,6 +1212,18 @@ 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"
@ -996,6 +1241,19 @@ 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"
@ -1111,6 +1369,16 @@ 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"
@ -1160,6 +1428,15 @@ 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"
@ -1256,6 +1533,15 @@ 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"
@ -1474,6 +1760,15 @@ 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"
@ -1496,6 +1791,16 @@ 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"
@ -1536,6 +1841,17 @@ 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"
@ -1666,11 +1982,25 @@ 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"
@ -1756,9 +2086,21 @@ 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"
@ -2355,6 +2697,26 @@ 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"

View file

@ -23,3 +23,6 @@ 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"] }

View file

@ -1,3 +1,5 @@
# 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.

View file

@ -19,3 +19,5 @@ chrono = { workspace = true }
reedline = { workspace = true }
colored = { workspace = true }
atty = { workspace = true }
tokio = { workspace = true }
substrate-ipc = { workspace = true }

View file

@ -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);
execute_passthrough(line, session);
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);
execute_passthrough(line, session);
session.ungoverned_count += 1;
}
CommandClass::Denied { reason } => {
@ -234,11 +234,11 @@ fn build_prompt(session: &SessionState) -> DefaultPrompt {
)
}
fn execute_passthrough(line: &str) -> i32 {
let status = std::process::Command::new("sh")
.arg("-c")
.arg(line)
.status();
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();
match status {
Ok(s) => s.code().unwrap_or(1),
Err(e) => {
@ -250,11 +250,12 @@ fn execute_passthrough(line: &str) -> i32 {
fn execute_governed(line: &str, corpus_binary: &Path, session: &SessionState) -> i32 {
let args: Vec<&str> = line.split_whitespace().skip(1).collect();
let status = std::process::Command::new(corpus_binary)
.args(&args)
let mut cmd = std::process::Command::new(corpus_binary);
cmd.args(&args)
.env("BASCULE_SESSION_ID", &session.ac_id)
.env("BASCULE_CORPUS_CID", &session.corpus_cid)
.status();
.env("BASCULE_CORPUS_CID", &session.corpus_cid);
session.apply_governance_env(&mut cmd);
let status = cmd.status();
match status {
Ok(s) => s.code().unwrap_or(1),
Err(e) => {

View file

@ -63,6 +63,19 @@ 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)]
@ -125,8 +138,18 @@ fn run(args: Args) -> Result<i32> {
return Ok(code);
}
// ── Session subcommands ──────────────────────────────────
// ── 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")?;
@ -166,6 +189,7 @@ fn run(args: Args) -> Result<i32> {
}
Ok(0)
}
Cmd::Register { .. } => unreachable!("handled above"),
};
}
@ -215,7 +239,11 @@ fn run(args: Args) -> Result<i32> {
// Determine AC mode:
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 ac = libgsh::ac::validate_ac(&ac_json, &corpus, &mut registry)
.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 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") {
(session_ac, "session".to_string())
(session_ac, "session".to_string(), None)
} else {
let base = args.broker_url.clone()
.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)
.map_err(|e| anyhow::anyhow!(e))?;
eprintln!("gsh: AC issued — {}", &id[..8.min(id.len())]);
(id, "inline".to_string())
(id, "inline".to_string(), None)
};
// Corpus gate:
@ -244,10 +272,40 @@ 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 {
@ -263,7 +321,12 @@ fn run(args: Args) -> Result<i32> {
}
// 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 stdout_str = String::from_utf8_lossy(&output.stdout).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)
}
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)
}

View file

@ -5,6 +5,7 @@ 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 }
@ -13,6 +14,9 @@ sha2 = { workspace = true }
hex = { workspace = true }
chrono = { workspace = true }
dirs = { workspace = true }
tracing = { workspace = true }
substrate-ipc = { workspace = true }
tokio = { workspace = true }
[dev-dependencies]

View file

@ -1,39 +1,59 @@
//! Authorization Context validation (R-22, R-23, R-24).
use serde::Deserialize;
use guildhouse_did::Did;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::registry::ConsumedRegistry;
/// 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 context_id: String,
#[serde(default)]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub issued_at: Option<String>,
#[serde(default)]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires_at: Option<String>,
#[serde(default)]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub operation: Option<AcOperation>,
#[serde(default)]
#[serde(default, skip_serializing_if = "Option::is_none")]
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 {
#[serde(default)]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub corpus_entry_cid: Option<String>,
#[serde(default)]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parameters_cid: Option<String>,
#[serde(default)]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub playbook: Option<String>,
}
#[derive(Deserialize, Debug, Clone, Default)]
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
pub struct AcPrincipal {
#[serde(default)]
pub did: Option<String>,
#[serde(default)]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub did: Option<Did>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
}
@ -171,4 +191,49 @@ 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"));
}
}

129
libgsh/src/agent_api.rs Normal file
View 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 { .. }));
}
}

View 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";

View file

@ -1,13 +1,15 @@
//! 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.
pub agent_did: Option<String>,
/// Agent DID. None when `GSAP_AGENT_DID` is unset or doesn't parse as a DID.
pub agent_did: Option<Did>,
/// Bearer auth token.
pub token: Option<String>,
/// Corpus CID this session is authorized for.
@ -28,7 +30,9 @@ 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(),
agent_did: std::env::var("GSAP_AGENT_DID")
.ok()
.and_then(|s| Did::parse(&s).ok()),
token: std::env::var("GSAP_TOKEN").ok(),
corpus_cid: std::env::var("GSAP_CORPUS_CID")
.unwrap_or_else(|_| "sha256:ungoverned".into()),

View file

@ -1,11 +1,35 @@
//! 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.
#[derive(Debug)]
pub enum CorpusCheckResult {
/// Binary found in corpus — allowed.
/// Binary found in corpus and its content hashes to the expected CID.
Allowed,
/// Corpus is ungoverned — no check performed.
Ungoverned,
@ -13,6 +37,23 @@ 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.
@ -21,7 +62,9 @@ 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.
/// 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 {
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())
.unwrap_or_else(|| cmd_name.to_string());
if corpus_dir.join(&cmd_name).exists() {
CorpusCheckResult::Allowed
} else {
CorpusCheckResult::Denied {
let binary_path = corpus_dir.join(&cmd_name);
if !binary_path.exists() {
return 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 test_ungoverned_skips_check() {
fn ungoverned_skips_check() {
assert!(matches!(
corpus_check("sha256:ungoverned", "anything"),
CorpusCheckResult::Ungoverned
@ -67,7 +194,7 @@ mod tests {
}
#[test]
fn test_missing_corpus_dir() {
fn missing_corpus_dir_reports_not_mounted() {
assert!(matches!(
corpus_check("sha256:nonexistent", "kubectl"),
CorpusCheckResult::NotMounted
@ -75,21 +202,78 @@ mod tests {
}
#[test]
fn test_corpus_with_real_dir() {
fn binary_with_matching_content_is_allowed() {
let dir = tempfile::tempdir().unwrap();
let cid = "sha256:test-corpus";
let corpus_dir = dir.path().join(cid);
std::fs::create_dir_all(&corpus_dir).unwrap();
std::fs::write(corpus_dir.join("kubectl"), "").unwrap();
let contents = b"#!/bin/sh\necho kubectl\n";
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, "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:?}"),
}
}
}

View file

@ -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 {
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/"))
@ -85,7 +92,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": "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"}),
})
.send()

View 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());
}
}

View file

@ -1,8 +1,12 @@
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;

182
libgsh/src/register.rs Normal file
View 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"
);
}
}

View file

@ -17,14 +17,25 @@ 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.clone())
.and_then(|p| p.did.as_ref().map(|d| d.as_str().to_owned()))
.or_else(|| {
ac.principal
.as_ref()
@ -60,6 +71,10 @@ 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,
}
}
@ -92,9 +107,31 @@ 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(),
@ -114,16 +151,30 @@ fn whoami() -> String {
}
/// 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 {
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);
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}");
}
}
// 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()
}