IntersectionInput replaces separate accord lookups with a DID-document-
sourced three-way intersection: effective = posix_bounding & accord_allowed
& delegation_remaining. denied_to_allowed_mask() converts denied capability
names to a bitmask for the intersection.
Includes workspace-level cleanup and gsh binary refactoring.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Tyler J King <tking@guildhouse.dev>
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>
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>
`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>
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>
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>
Session principal resolution chain:
GSH_PRINCIPAL → BASCULE_DISPLAY_NAME → derive from DID → whoami()
GSH_DID → BASCULE_USER_DID → whoami()
.gshrc Windows identity detection:
Entra-joined: whoami /upn → tking@guildhouse.dev → DID
Domain-joined: USERNAME@USERDNSDOMAIN → DID
Local: USERNAME only (no DID)
Governed sessions (Bascule) override with authenticated identity.
Non-WSL2 environments fall back silently.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Reads BASCULE_DEFCON_LEVEL from env. At DEFCON <5:
Banner: DEFCON level + label (RESTRICTED/CRITICAL/LOCKDOWN) + reason
Prompt: [restricted] at DEFCON 3, [DEFCON] at ≤2
DEFCON 5 (peacetime): no DEFCON line in banner, normal prompt.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
scripts/build-wsl2-image.sh — idempotent setup for governed jumphost.
Installs: gsh, kubectl, helm (all to ~/.local/bin, no sudo needed)
Configures: corpus directory, SSH aliases (dev.gsh, stg.gsh),
.gshrc environment defaults
Export: --export flag prints wsl --export/import commands
No sudo required for gsh/corpus/config setup. System packages
(curl, git, etc.) prompt for manual install if sudo unavailable.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Banner shows human-readable principal and DID on separate lines:
Principal: tking@guildhouse.dev
DID: did:web:guildhouse.dev/user/tking
Prompt uses short name: [governed] tking@gsh
Reads BASCULE_DISPLAY_NAME env. Fallback: parse DID to name@domain.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Broker now supports session-scoped ACs that stay active across
multiple CRs. Session start posts 'completed' CR, session end
posts 'session_end' CR which consumes the AC.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Session start/end CRs used invalid outcome values (session_started,
session_ended) not in broker's Outcome enum, causing 422. Also, broker
consumes AC on first CR, blocking subsequent per-command CRs.
Skipped session lifecycle CRs until session-scoped AC model is
implemented. Per-command CRs still post on governed command completion.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- corpus_check_with_base(): accepts explicit base directory
- corpus_check(): still defaults to /opt/substrate/corpus
- Improved corpus test with actual Allowed/Denied assertions
- Updated bascule-dev.toml with [gsap] section and shell_command
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Phase 1 of the WSL2 jumphost build.
Three execution models:
1. Pre-issued AC: GSAP_AC='...' gsh --exec "cmd"
Caller provides AC. gsh validates (R-22/23/24), executes, posts CR.
For: Bascule, SK plugin, CI/CD.
2. Inline AC request: GSAP_BROKER_URL=... gsh --exec "cmd"
Backward compatible fallback.
3. Ungoverned: gsh --ungoverned --exec "cmd"
No AC, no CR, no corpus check. Dev mode.
AC validation (validate_pre_issued_ac):
R-22: Single-use — filesystem registry at ~/.gsh/consumed/{context_id}
R-23: Corpus match — AC corpus_entry_cid vs GSAP_CORPUS_CID env
R-24: (parameters_cid field parsed, verification at broker)
Expiry check — AC expires_at vs now
Replay detection — consumed context_ids rejected
Corpus directory gate (corpus_check):
/opt/substrate/corpus/{cid}/{command_name}
If binary missing from corpus dir → denied (exit 3)
The live killswitch: remove binary from corpus dir to revoke
Exit codes aligned with DESIGN.md:
0 = success, 1 = exec failure, 2 = auth failure,
3 = governance violation, 125 = gsh internal error
JSON output: new fields ac_mode ("pre-issued"|"inline"|"session"|"ungoverned"), corpus_cid
Tested against live fastapi-gsap broker:
Inline AC: backward compat ✓
Pre-issued AC from broker: validated + CR posted ✓
Expired AC: exit 2 ✓
Replay detection: exit 2 ✓
Ungoverned mode: no governance overhead ✓
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Per-invocation AC is the primitive for single governed ops.
Session mode is for scripts, pipelines, and interactive shells.
Per-invocation (unchanged):
gsh --exec "cmd" → 1 AC + 1 CR per command
Session mode (new):
eval "$(gsh session-start --scope shell:session)"
gsh --exec "cmd1" # reuses session AC
gsh --exec "cmd2"
eval "$(gsh session-end)"
Detection: GSAP_SESSION_AC in environment.
Subcommands: session-start, session-end, session-status
Known gap: broker currently marks AC consumed after first CR.
Session commands 2+ get 404 on CR. This is a broker-side fix
(needs session AC type). gsh handles it gracefully.
Tested against live fastapi-gsap Spoke on Hetzner.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
~200 lines of Rust. Every command: AC → exec → CR → CID.
Usage:
gsh --exec "echo hello"
gsh --exec "hcloud server list" --json
gsh --exec "ansible-playbook site.yml" --dry-run
Flow:
1. SHA-256 hash the command
2. POST /governance/authorize/ → AC ID
3. exec(sh, -c, command) → capture stdout/stderr/exit
4. POST /governance/complete/ → receipt + Chronicle CID
5. Print stdout (passthrough) or JSON (structured)
6. Exit with command's exit code
Environment:
GSAP_BROKER_URL http://fastapi-gsap:8000
GSAP_AGENT_DID did:web:bxnet.../agent/platform-ops
GSAP_TOKEN Bearer token (optional)
GSAP_CORPUS_CID sha256:{image_digest} (optional)
Tested against live fastapi-gsap Spoke broker on Hetzner:
dry-run: AC only ✓
live exec: stdout passthrough + CID ✓
JSON mode: ac_id + cr_id + chronicle_cid ✓
exit code: 42 passed through ✓
The command_hash in the AC request means the broker knows
WHAT will be executed before authorizing. Not just "was
this agent allowed" but "was this exact command authorized."
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>