diff --git a/crates/bascule-shell/src/main.rs b/crates/bascule-shell/src/main.rs index 2d2c290..1f2b6f0 100644 --- a/crates/bascule-shell/src/main.rs +++ b/crates/bascule-shell/src/main.rs @@ -12,6 +12,7 @@ mod attestation; mod banner; mod config; mod identity; +mod posture; #[derive(Parser)] #[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); } + + // 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 // replaces it with the proto SAT composite hash. Kept under the same // env var name for backward compatibility with existing gsh consumers diff --git a/crates/bascule-shell/src/posture.rs b/crates/bascule-shell/src/posture.rs new file mode 100644 index 0000000..0e2a071 --- /dev/null +++ b/crates/bascule-shell/src/posture.rs @@ -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::(&s).ok()) + .unwrap_or_default() + } +}