Three path deps in this workspace's Cargo.toml (substrate-proto,
hfl-types, substrate-hfl) pointed at `../substrate-project/substrate/
crates/...`, which resolves correctly only when bascule-oss sits as a
sibling to a substrate-project-named directory at the parent level.
That's not the current layout: bascule-oss is now nested INSIDE the
substrate-project umbrella, as a working tree alongside substrate/
and other Guildhouse repos. The paths don't resolve in the current
nested layout; bascule-oss has been locally unbuildable since the
reorg (existing Cargo.lock survived only because nobody ran
`cargo update` or `cargo generate-lockfile` to force re-resolution).
Fix: update the three paths to `../substrate/crates/...`, matching
the sibling-checkout layout. Comment updated to name the current
convention explicitly rather than referencing an old side-by-side
CI claim that no longer matches CI's actual layout either (see
below on CI state).
What this fix does NOT change
- **The architectural rule in CLAUDE.md still stands** —
"zero substrate/chronicle/gsap dependencies." This fix is a path
correction, not a policy change.
- **The rule is already broken in both spirit and letter.** bascule-
core depends on substrate-proto unconditionally; substrate-proto
depends on substrate-common; both names are substrate-prefixed.
The CI contamination check at .github/workflows/ci.yml:81-88
greps Cargo.lock for "substrate|chronicle|gsap|hfl|metakernel"
and fails on any match — current Cargo.lock contains substrate-
common, substrate-hfl, substrate-proto, hfl-types, so CI would
fail on a fresh run regardless of this patch.
- **This fix is a LOCAL buildability unblock only.** It makes
`cargo check -p bascule-core` pass in the current layout. It does
NOT fix CI, which is independently broken for the reasons above.
CI state
The bascule-oss CI workflow uses `actions/checkout@v4` with no
coordinating checkout of substrate-project or its substrate/
subrepo. Regardless of whether bascule-oss's paths use
`../substrate/...` or `../substrate-project/substrate/...`, CI
currently has nothing to resolve them against. And the
contamination check would flag the resulting Cargo.lock even if
the paths did resolve. CI is already red (or will be, on next
trigger).
Re-enabling CI against bascule-oss requires separate coordination
work: either adding a substrate-project checkout step to the
workflow, or renaming substrate-proto (see below), or amending
the contamination check to allow specific neutral-protocol crates
by name. That work is out of scope for this patch.
Architectural open item: substrate-proto naming violation
Documented in the new NOTES-SUBSTRATE-PROTO-NAMING-VIOLATION.md.
Summary: substrate-proto's CONTENT is a legitimate neutral
protocol contract (proto-generated SAT types bascule-core needs
to compose session attestations). The NAMING is substrate-
prefixed, which both violates CLAUDE.md's string-match rule and
creates an optical problem for CNCF sandbox positioning —
sandbox committees read dependency names more than content when
evaluating whether a project is a generic open-source substrate
or a specific company's product with open-source framing.
Proposed resolution (post-v1): rename substrate-proto → sat-proto
(or attestation-proto), move the consumed subset of substrate-
common's public surface out of substrate-common into the neutrally-
named crate, update bascule-core and any other consumers, regenerate
Cargo.lock, confirm the contamination check passes.
This is NOT v1-blocking. This IS CNCF-sandbox-positioning work —
worth doing in coordination with sandbox application prep so the
naming story and the institutional positioning story land
together.
Why we're shipping this narrower fix now
Phase 5.5c-c of the substrate foundation work (the adapter crate
bridging bascule-core session lifecycle to bascule-revocation-
ingress SessionLookup) requires depending on bascule-core as a
cross-workspace path dep. Without this path fix, substrate-side
crates can't pull bascule-core locally, blocking 5.5c-c
implementation. The path fix costs one line per dep and
introduces no new coupling; the deeper rename is several-repo
coordination that would delay 5.5c-c unnecessarily.
Fix-what's-blocking, document-what's-underlying, keep moving.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Tyler J King <tking@guildhouse.dev>
The TOML schema for ~/.config/bascule/shell.toml carries
`servers = [{alias, hostname, port}]` entries that
bascule-shell deserializes but doesn't read at runtime. The
shell-side server chooser uses ssh host aliases (dev.gsh /
stg.gsh / prod.gsh) instead.
Marking the fields `#[allow(dead_code)]` with a comment
preserves the TOML wire format (so users with existing config
files don't get a parse error) without leaving the compiler
warning.
Verification:
$ cargo build --workspace
| grep -c "warning:"
0
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Claude Code <claude@guildhouse.dev>
Post-M6 enhancement: when /dev/substrate-hfl is loaded and the
bascule-core `hfl` feature is enabled, the new
compose_via_hfl_or_local() entry point hands a serialized
SessionClaim to the kernel's attestation::SAT_BUNDLE function and
uses the kernel-composed SatBundle (proto-encoded) in place of the
locally-composed M1 bundle. Kernel composition has access to TPM
state, governance state, and platform-claim producers Bascule can't
reach from userspace.
Without the `hfl` feature: M1 path unchanged.
With the `hfl` feature but no kernel module: graceful fallback to
the M1 local compose path. Per ADR D9, the SAT chain stays alive
regardless of HFL availability.
bascule-core::hfl_sat (NEW, behind --features hfl):
- compose_via_hfl_or_local(inputs) tries the kernel path first.
On any failure (device missing, ioctl error, decode error)
it logs at debug and returns the local M1 compose result.
- try_compose_via_hfl() encodes the SessionClaim with prost,
dispatches via HflClient::dispatch(0x0005, 1, claim_bytes,
[0u8;32], current_epoch), and decodes the result as a
proto SatBundle.
- 2 unit tests cover the device-absent fallback path (+ structure
equivalence with the M1 local compose).
Cargo.toml:
- Workspace deps: hfl-types + substrate-hfl as path deps to the
substrate workspace (cross-workspace, CI mounts both checkouts
side by side).
- bascule-core gains a `hfl` feature gating hfl-types +
substrate-hfl + prost (the last for SessionClaim::encode_to_vec
on the substrate-proto-generated types).
Tested (Docker rust:1.88-bookworm):
cargo build -p bascule-core clean
cargo test -p bascule-core --lib sat 7/7 (M1 regression)
cargo build -p bascule-core --features hfl clean
cargo test -p bascule-core --features hfl --lib 26/26
+2 hfl_sat tests on top of the existing bascule-core suite
Branched off main (post-merge of the M1..M5 stack).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Claude Code <claude@guildhouse.dev>
Adds bascule_shell::identity::detect_spiffe_svid which reads a
SPIFFE SVID URI from /var/run/spire/svid-uri (override via
SPIFFE_SVID_PATH). When present it wins over Entra/AZ-CLI/
Kerberos/cached-OIDC/system-user, becoming the SAT session_leaf
actor field that QM's M5 SpiffeSvidEvaluator validates against
the cluster allowlist.
Why a file read instead of the SPIFFE Workload API: bascule-shell
ships independently from QM and the standard SPIRE k8s sidecar
writes the URI as /var/run/spire/svid-uri alongside svid.pem.
The file path is hermetic for tests and matches the deploy model.
If a future iteration needs continuous SVID URI rotation, switch
to a notify watcher or pull spiffe::workload_api.
Trust domain is parsed and surfaced as Identity.domain so the
banner / dashboard can show "spiffe://gh.dev" affiliation.
bascule_shell::main::set_env: auth_method == "spiffe" maps to
BASCULE_ROLES = "operator" by default. SPIRE-attested workloads
are explicitly cluster-issued so they get operator role until
per-workload provisioning lands. The existing precedence
(caller-set BASCULE_ROLES wins) is unchanged.
Bascule mTLS *channel* construction (Bascule -> QM gRPC
renegotiation) is intentionally NOT wired in this commit.
Per ADR D9 hot path is local; the renegotiation client is
deferred to M6 alongside the Rekor signing client because they
share the rustls dep tree.
Tested (Docker rust:1.88-bookworm):
cargo build -p bascule-shell -p bascule-core clean
cargo test -p bascule-core --lib sat 7/7 (M1 regression)
Stacked on feat/m3-defcon-env.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Claude Code <claude@guildhouse.dev>
bascule-shell loads /opt/substrate/posture/current.json
(BASCULE_POSTURE_FILE override) at session start and exports:
BASCULE_DEFCON_LEVEL numeric global level (1..5)
BASCULE_POSTURE_LEVEL alias (already shipped in M1)
BASCULE_CAPABILITY_CEILING CAP_NONE..CAP_GOVERN
BASCULE_CEREMONY_REQUIRED "0" / "1"
BASCULE_MAX_SESSION_TTL minutes, omitted when 0
Fail-soft: missing/malformed file degrades to peacetime defaults so
the shell exec path stays alive on misconfigured hosts.
The new posture.rs module is a tiny inline snapshot loader (60 LOC,
serde_json on top of an already-present dep) — bascule-oss does not
pull libgsh as a dep, so the JSON wire format produced by
substrate-operator is the contract. gsh and bascule-shell share that
contract, not Rust types.
Stacked on feat/m2-roles-export.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Claude Code <claude@guildhouse.dev>
bascule-shell::set_env now populates BASCULE_ROLES so gsh's
M2 role-aware classifier has something to match against.
Precedence:
1. Caller-set BASCULE_ROLES wins (env var preserved as-is).
2. Otherwise derive a default from auth_method:
oidc-entra | oidc-cached | kerberos -> operator
ssh-key -> apprentice
_ -> apprentice
The auth-method fallback is intentionally minimal — bascule-oss
Identity has no real roles field, and proper role provisioning
(Entra group claims, SPIFFE workload roles) lands in M5. This
default at least populates the env var so M2's role-deny path
is exercised end-to-end on existing dev shells instead of
silently empty.
Stacked on feat/m1-session-sat.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Claude Code <claude@guildhouse.dev>
Replaces the opaque BASCULE_ATTESTATION_HASH (a SHA over a
"pcrN:val;ima:hash;" evidence string) with a real proto-canonical
SatBundle composed from the operator's identity + local platform
attestation, anchored on the L4 SessionClaim.
bascule-core::sat (NEW): pure composer module.
- build_session_claim(SessionInputs) -> SessionClaim builds the
L4 leaf from {principal, auth_method, actor_type,
identity_verified, platform_attested, software_verified,
nonce_seed}, computes posture per SAT-SPEC-0002 §7, and
populates the L1/L2/L3 binding fields with zero-padded
placeholders until upstream producers exist.
- compose_local(SessionClaim) -> ComposedSat assembles the proto
SatBundle via SatBundleBuilder. Hot path stays local per ADR D9
(zero network); QM's gRPC ComposeSat is the warm-path surface.
- 7 unit tests cover layer/actor wiring, posture math at each
evidence level, deterministic nonce, sat_hash uniqueness across
principal changes.
bascule-shell: composes the SAT in main() right before execvp
of the inner shell — that's the OSS equivalent of an "Authenticated
-> ShellActive" transition (the OSS Bascule has no russh state
machine; it's a CLI wrapper). Exports the new env var surface:
BASCULE_SAT_HASH hex of proto sat_hash (canonical)
BASCULE_SESSION_CLAIM_HASH hex of L4 leaf hash
BASCULE_SESSION_ID UUID from SessionClaim
BASCULE_POSTURE_LEVEL SAT-SPEC-0002 §7 posture
BASCULE_ATTESTATION_HASH retained as compat alias (gsh /
dashboard consumers); now points at
the proto sat_hash, not the old
evidence-string SHA.
Cross-workspace path dep: substrate-proto via
../substrate-project/substrate/crates/substrate-proto. CI mounts
~/projects as one volume so the path resolves. Switching to a git
dep is post-MVP.
Note: russh-keys pulls `home` which requires Rust 1.88; CI bumps
the docker image accordingly. No code change.
Tested:
cargo build -p bascule-core -p bascule-shell clean
cargo test -p bascule-core --lib sat 7/7
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Claude Code <claude@guildhouse.dev>
Same binary, same process, two listeners:
Port 2222: SSH proxy (russh)
Port 9090: Management API (axum)
API endpoints:
GET /api/sessions — active sessions
GET /api/sessions/history — recent history (last 500)
GET /api/stats — aggregate analytics
GET /api/health — server health + version
GET /api/info — server capabilities
Session tracking:
Arc<SessionStore> shared between SSH handler and API
In-memory: active sessions + 500-session history ring buffer
Tracks: auth breakdown, peak concurrent, TPM attested %
Feature flag:
--features dashboard (default on) — includes axum + tower-http
--no-default-features — SSH-only, no HTTP dependency
Config:
[dashboard] section: enabled, listen address
All smoke tests pass. 0 substrate deps.
Signed-off-by: Tyler King <tking@guildhouse.dev>
New crates:
bascule-dashboard — shared Dioxus component library
SessionTable: live active sessions with auth/backend/TPM status
StatsCards: active count, 24h total, TPM attested %, failed auth
StatusBar: connection health indicator
types.rs: DashboardSession, DashboardStats, HealthResponse
bascule-dashboard-web — WASM web target (Dioxus 0.6 + web features)
Compiles to wasm32-unknown-unknown
Dark-first CSS (light mode via prefers-color-scheme)
Monospace data display, clean stat cards
bascule-core/store.rs — in-memory session store
SessionStore with active sessions + aggregate stats
Updated via SessionHandler hooks
Both dashboard library and web WASM target compile clean.
Server and shell builds unaffected. Zero substrate deps.
Signed-off-by: Tyler King <tking@guildhouse.dev>
DCO (Developer Certificate of Origin):
Standard DCO 1.1 (Linux kernel, CNCF, Kubernetes standard)
Contributors retain copyright — no rights assignment
NOTICE:
Copyright attribution (Guildhouse LLC)
Contributors retain copyright, own their implementations
SessionHandler/AuthProvider as public API boundary
Tribal jurisdiction for voluntary dispute resolution
GOVERNANCE.md:
Project governance model and decision making
IP framework: Guildhouse brand vs contributor code vs shared Apache 2.0
SessionHandler trait IS the product boundary
Tribal dispute resolution: voluntary, technically informed
Tribal partnership mission
CI:
DCO sign-off check on pull requests
Existing commits on main exempt
README + CONTRIBUTING:
Governance section, DCO instructions, corporate guidance
Signed-off-by: Tyler King <tking@guildhouse.dev>
New crate: bascule-shell (471 lines, 1.8MB binary)
Login shell that detects identity + platform attestation at startup.
Wraps bash/zsh/fish — operator works normally, identity travels with them.
Identity detection (priority order):
1. Entra via WSL2 interop
2. Azure CLI
3. Kerberos TGT
4. Cached OIDC token
5. System user (fallback)
Platform attestation:
TPM 2.0 PCR values via tpm2_pcrread (PCRs 0,1,2,7,10,14)
IMA measurement log hash + count
Keylime agent state
Entra device compliance (WSL2 only)
Composite SHA-256 hash over all evidence
Shell features:
Banner with identity + attestation summary
BASCULE_* env vars injected into inner shell
--info mode for dry-run display
--json mode for machine-readable output
--exec mode for single-command execution
Configurable via ~/.config/bascule/shell.toml
Tested on Fedora with real TPM 2.0:
6 PCRs successfully read from hardware
All env vars propagated to inner shell
1.8MB binary, 0 substrate deps
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Bascule now supports two session modes:
Local — spawns a PTY on this machine (default, existing)
Proxy — forwards the session to a target SSH host (NEW)
Proxy mode:
SSH client ←→ bascule (auth + hooks) ←→ target SSH host
Authenticates client via configured auth provider
Connects to upstream SSH host via russh client
Bridges I/O between client and upstream channels
PTY, shell, and exec requests forwarded to target
Exit status propagated back to client
Config:
[proxy]
target_host = "192.168.1.100"
target_port = 22
target_user = "deploy" # optional, defaults to principal
target_key_path = "/etc/bascule/target_key"
accept_target_host_key = false # dev only
SessionHandler hooks fire in both modes:
on_session_start, on_exec, on_session_end
Custom handlers can enforce policy regardless of mode
New file: proxy.rs (152 lines)
UpstreamHandler — minimal russh client handler
connect_upstream — connects + authenticates to target
bridge_upstream_to_client — bidirectional I/O bridge
Binary: 6.3MB, zero substrate deps.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
New crate: bascule-auth-agent-id
Microsoft Entra Agent ID authentication for AI agents
Validates OAuth tokens against Entra JWKS (60min cache)
Extracts agent metadata: type, blueprint, sponsor, scopes
Detects on-behalf-of (delegated) agents
Token-as-password pattern for SSH auth
Cleanup:
Removed all governance-specific references from comments
SessionHandler trait is the only extension point
Zero substrate/chronicle/gsap dependencies
Config example uses neutral terminology
Config:
[auth.agent_id] section for Entra configuration
tenant_id, audiences, multi_tenant fields
3 crates: bascule-core, bascule-server, bascule-auth-agent-id
938 lines total, 5.6MB binary, 0 substrate deps.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>