feat(m3): bascule-shell exports DEFCON env vars from posture-current.json

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>
This commit is contained in:
Claude Code 2026-04-07 19:02:34 -04:00
parent 56529626f6
commit df5a2a6f88
2 changed files with 74 additions and 0 deletions

View file

@ -12,6 +12,7 @@ mod attestation;
mod banner; mod banner;
mod config; mod config;
mod identity; mod identity;
mod posture;
#[derive(Parser)] #[derive(Parser)]
#[command(name = "bascule-shell", about = "Identity-aware shell with TPM attestation")] #[command(name = "bascule-shell", about = "Identity-aware shell with TPM attestation")]
@ -140,6 +141,28 @@ fn set_env(
}; };
std::env::set_var("BASCULE_ROLES", default_role); std::env::set_var("BASCULE_ROLES", default_role);
} }
// M3: load the operator's posture-current.json once at session
// start and surface the consumer-facing fields as BASCULE_*
// env vars so gsh's classifier (and any external tooling) gets
// the same view without re-reading the file. gsh ALSO reads the
// file directly via libgsh::PostureState — these env vars are
// the secondary surface for non-gsh consumers and for the
// posture-watcher fallback when the file is unreachable.
let posture = posture::PostureSnapshot::load();
std::env::set_var("BASCULE_DEFCON_LEVEL", posture.global_level.to_string());
std::env::set_var("BASCULE_POSTURE_LEVEL", posture.global_level.to_string());
std::env::set_var("BASCULE_CAPABILITY_CEILING", &posture.capability_ceiling);
std::env::set_var(
"BASCULE_CEREMONY_REQUIRED",
if posture.ceremony_required_for_writes { "1" } else { "0" },
);
if posture.max_session_ttl_minutes > 0 {
std::env::set_var(
"BASCULE_MAX_SESSION_TTL",
posture.max_session_ttl_minutes.to_string(),
);
}
// BASCULE_ATTESTATION_HASH was an opaque "evidence string" SHA. M1 // BASCULE_ATTESTATION_HASH was an opaque "evidence string" SHA. M1
// replaces it with the proto SAT composite hash. Kept under the same // replaces it with the proto SAT composite hash. Kept under the same
// env var name for backward compatibility with existing gsh consumers // env var name for backward compatibility with existing gsh consumers

View file

@ -0,0 +1,51 @@
//! M3: minimal DEFCON posture loader for bascule-shell.
//!
//! Reads `/opt/substrate/posture/current.json` (override via
//! `BASCULE_POSTURE_FILE`), exposes a few fields the env var exporter
//! cares about, and falls back gracefully if the file is missing or
//! malformed. The struct is intentionally NOT shared with libgsh's
//! richer `PostureState` — bascule-shell ships independently from gsh
//! and the cross-workspace dep would force cargo to compile libgsh just
//! to pull a serde struct. Keep them in sync via the JSON wire format
//! (`current.json`), which substrate-operator owns.
use std::path::PathBuf;
use serde::Deserialize;
pub const DEFAULT_POSTURE_FILE: &str = "/opt/substrate/posture/current.json";
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct PostureSnapshot {
pub global_level: u8,
pub max_session_ttl_minutes: i64,
pub ceremony_required_for_writes: bool,
pub capability_ceiling: String,
}
impl Default for PostureSnapshot {
fn default() -> Self {
Self {
global_level: 5,
max_session_ttl_minutes: 0,
ceremony_required_for_writes: false,
capability_ceiling: "CAP_GOVERN".into(),
}
}
}
impl PostureSnapshot {
/// Load the operator's posture-current.json. Never errors:
/// missing/malformed files degrade to peacetime defaults so the
/// shell exec path stays alive on misconfigured hosts.
pub fn load() -> Self {
let path = std::env::var("BASCULE_POSTURE_FILE")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from(DEFAULT_POSTURE_FILE));
std::fs::read_to_string(&path)
.ok()
.and_then(|s| serde_json::from_str::<Self>(&s).ok())
.unwrap_or_default()
}
}