initial: bascule v0.1.0

Bascule shell runtime workspace — governed shell access layer
for Substrate/Guildhouse FFC deployments.

Crates:
- bascule-agent: node agent with SSH server + command filtering
- bascule-core: audit, grant engine, ceremony types, session
- bascule-filter-core: log line filtering (stdio protocol)
- bascule-gateway: OIDC auth, session management, SAT validation
- bascule-node-agent: k8s DaemonSet agent (pod watcher, BPF manager)
- bascule-proto: protobuf definitions
- bascule-shell: governed SSH shell (commands, elevation, REPL)
- bascule-tail: chronicle log tail + fanout
- ceremony-engine: ceremony lifecycle (6 types + request/resolution)

172 tests passing.
Implements SBS-SPEC-0001 shell model.
Reference impl for SPEC-SHELLOPS-0001 Layer 1 (root shell).
This commit is contained in:
Tyler King 2026-03-18 16:40:48 -04:00
commit b1865a0627
131 changed files with 23034 additions and 0 deletions

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
/target/
**/target/
**/*.rs.bk
.env
*.swp
*.swo

5706
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

86
Cargo.toml Normal file
View file

@ -0,0 +1,86 @@
[workspace]
resolver = "2"
members = [
"bascule-proto",
"bascule-filter-core",
"bascule-node-agent",
"bascule-shell",
"bascule-tail",
"bascule-agent",
"bascule-core",
"bascule-gateway",
"ceremony-engine",
]
[workspace.dependencies]
# Async runtime
tokio = { version = "1", features = ["full"] }
async-trait = "0.1"
# Serialization
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_json_canonicalizer = "0.3"
serde_yaml = "0.9"
# gRPC
tonic = "0.12"
tonic-build = "0.12"
prost = "0.13"
prost-types = "0.13"
# Kubernetes
kube = { version = "0.98", features = ["runtime", "derive"] }
k8s-openapi = { version = "0.24", features = ["latest"] }
schemars = "0.8"
# TLS
rustls = { version = "0.23", features = ["ring"] }
# Crypto
sha2 = "0.10"
hmac = "0.12"
# Observability
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
# Common
uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
thiserror = "2"
anyhow = "1"
clap = { version = "4", features = ["derive", "env"] }
reqwest = { version = "0.12", features = ["json"] }
dashmap = "6"
regex = "1"
which = "7"
dirs = "6"
rand = "0.8"
hex = "0.4"
jsonwebtoken = "9"
# SSH
russh = "0.49"
russh-keys = "0.49"
ssh-key = { version = "0.6", features = ["ed25519", "rand_core"] }
# Database
sqlx = { version = "0.8", features = [
"runtime-tokio",
"tls-native-tls",
"postgres",
] }
# HTTP
axum = "0.8"
tower-http = { version = "0.6", features = ["trace"] }
config = "0.14"
tokio-stream = "0.1"
tempfile = "3"
# Internal crate deps
bascule-filter-core = { path = "./bascule-filter-core" }
bascule-core = { path = "./bascule-core" }
bascule-proto = { path = "./bascule-proto" }
ceremony-engine = { path = "./ceremony-engine" }

54
bascule-agent/Cargo.toml Normal file
View file

@ -0,0 +1,54 @@
[package]
name = "bascule-agent"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "bascule-agent"
path = "src/main.rs"
[[bin]]
name = "sb"
path = "src/bin/sb.rs"
[dependencies]
bascule-core = { workspace = true }
tokio = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
sha2 = { workspace = true }
jsonwebtoken = { workspace = true }
reqwest = { workspace = true }
dashmap = { workspace = true }
async-trait = { workspace = true }
# Cross-workspace path deps — substrate crates
substrate-rt = { path = "../../substrate/crates/substrate-rt" }
hfl-types = { path = "../../substrate/crates/hfl-types", features = ["serde", "agent-extensions"] }
# Msgpack — retained for convenience constructors and legacy decode paths
rmp-serde = "1"
rmpv = { version = "1", features = ["with-serde"] }
# Config file parsing
toml = "0.8"
# CLI
clap = { workspace = true }
hex = { workspace = true }
# SSH server
russh = { workspace = true }
russh-keys = { workspace = true }
ssh-key = { workspace = true }
rand = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }

33
bascule-agent/Dockerfile Normal file
View file

@ -0,0 +1,33 @@
# Multi-stage build for bascule-agent
# Stage 1: Build
FROM rust:latest AS builder
WORKDIR /build
COPY . .
ENV SQLX_OFFLINE=true
RUN cd services && cargo build --release -p bascule-agent
# Stage 2: Runtime
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Create substrate user and directories
RUN groupadd -r substrate && useradd -r -g substrate substrate && \
mkdir -p /var/run/substrate /etc/substrate && \
chown substrate:substrate /var/run/substrate
COPY --from=builder /build/services/target/release/bascule-agent /usr/local/bin/bascule-agent
# Default config
COPY services/bascule-agent/tests/e2e-config.toml /etc/substrate/shell.toml
USER substrate
EXPOSE 2222
ENTRYPOINT ["bascule-agent"]
CMD ["--config", "/etc/substrate/shell.toml"]

View file

@ -0,0 +1,93 @@
//! sb — Substrate CLI
//!
//! Currently supports:
//! sb shell [--host HOST] [--port PORT] [--user USER]
//!
//! Connects to the bascule-agent's governed SSH shell.
use std::process::Command;
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(name = "sb", about = "Substrate CLI", version)]
struct Cli {
#[command(subcommand)]
command: SubCmd,
}
#[derive(Subcommand)]
enum SubCmd {
/// Connect to the bascule-agent governed shell via SSH.
Shell {
/// SSH host to connect to.
#[arg(long, default_value = "localhost")]
host: String,
/// SSH port.
#[arg(short, long, default_value = "2222")]
port: u16,
/// SSH username.
#[arg(short, long, default_value_t = whoami())]
user: String,
/// SSH identity file (private key).
#[arg(short, long)]
identity: Option<String>,
},
/// Show agent status via IPC socket.
Status {
/// Path to the agent Unix socket.
#[arg(long, default_value = "/var/run/substrate/agent.sock")]
socket: String,
},
}
fn whoami() -> String {
std::env::var("USER").unwrap_or_else(|_| "substrate".to_string())
}
fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
match cli.command {
SubCmd::Shell {
host,
port,
user,
identity,
} => {
let mut cmd = Command::new("ssh");
cmd.arg("-p").arg(port.to_string());
cmd.arg("-o").arg("StrictHostKeyChecking=no");
cmd.arg("-o").arg("UserKnownHostsFile=/dev/null");
cmd.arg("-o").arg("LogLevel=ERROR");
if let Some(ref key) = identity {
cmd.arg("-i").arg(key);
}
cmd.arg(format!("{user}@{host}"));
eprintln!("Connecting to bascule-agent shell at {host}:{port}...");
let status = cmd.status()?;
if !status.success() {
std::process::exit(status.code().unwrap_or(1));
}
}
SubCmd::Status { socket } => {
if std::path::Path::new(&socket).exists() {
println!("Agent socket: {} (active)", socket);
} else {
println!("Agent socket: {} (not found)", socket);
std::process::exit(1);
}
}
}
Ok(())
}

View file

@ -0,0 +1,206 @@
//! Command filter — restricts shell commands based on scope/trust tier.
//!
//! The filter defines which namespace commands are accessible at each
//! trust tier. In soft/dev mode, all commands are allowed. In live mode,
//! the filter enforces the trust tier's capability set.
use std::collections::HashSet;
/// Trust tier for an authenticated operator.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum TrustTier {
Apprentice,
Journeyman,
Master,
SiteOwner,
}
impl TrustTier {
pub fn from_str(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"apprentice" => Some(Self::Apprentice),
"journeyman" => Some(Self::Journeyman),
"master" => Some(Self::Master),
"site_owner" | "siteowner" | "owner" => Some(Self::SiteOwner),
_ => None,
}
}
pub fn name(&self) -> &'static str {
match self {
Self::Apprentice => "apprentice",
Self::Journeyman => "journeyman",
Self::Master => "master",
Self::SiteOwner => "site_owner",
}
}
}
/// Capability flags.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Capability {
Read,
Propose,
Mutate,
}
/// Command filter that maps trust tiers to allowed commands.
pub struct CommandFilter {
dev_mode: bool,
/// Commands allowed for each tier (namespace::function format).
tier_allowlists: Vec<(TrustTier, HashSet<String>)>,
}
impl CommandFilter {
pub fn new(dev_mode: bool) -> Self {
let mut filter = Self {
dev_mode,
tier_allowlists: Vec::new(),
};
if !dev_mode {
filter.build_default_allowlists();
}
filter
}
fn build_default_allowlists(&mut self) {
// Apprentice: READ only
let mut apprentice = HashSet::new();
apprentice.insert("attestation::posture".into());
apprentice.insert("audit::query".into());
apprentice.insert("identity::authenticate".into());
apprentice.insert("identity::resolve".into());
apprentice.insert("help".into());
apprentice.insert("status".into());
apprentice.insert("whoami".into());
// Journeyman: READ + PROPOSE
let mut journeyman = apprentice.clone();
journeyman.insert("governance::gate".into());
journeyman.insert("governance::propose".into());
journeyman.insert("audit::emit".into());
// Master: READ + PROPOSE + MUTATE (via approval)
let mut master = journeyman.clone();
master.insert("crypto::sign".into());
master.insert("crypto::verify".into());
master.insert("crypto::hash".into());
master.insert("secrets::get".into());
master.insert("governance::evaluate".into());
master.insert("audit::anchor".into());
master.insert("attestation::sat".into());
// Site Owner: everything
let mut site_owner = master.clone();
site_owner.insert("secrets::put".into());
site_owner.insert("secrets::rotate".into());
site_owner.insert("identity::authorize".into());
site_owner.insert("attestation::verify".into());
self.tier_allowlists = vec![
(TrustTier::Apprentice, apprentice),
(TrustTier::Journeyman, journeyman),
(TrustTier::Master, master),
(TrustTier::SiteOwner, site_owner),
];
}
/// Check if a command is allowed for the given trust tier.
pub fn is_allowed(&self, command: &str, tier: TrustTier) -> bool {
if self.dev_mode {
return true;
}
// Shell builtins always allowed
if matches!(command, "help" | "status" | "whoami" | "exit" | "quit") {
return true;
}
for (t, allowlist) in &self.tier_allowlists {
if *t == tier {
return allowlist.contains(command);
}
}
false
}
/// Get capabilities for a trust tier.
pub fn capabilities(tier: TrustTier) -> Vec<Capability> {
match tier {
TrustTier::Apprentice => vec![Capability::Read],
TrustTier::Journeyman => vec![Capability::Read, Capability::Propose],
TrustTier::Master => vec![Capability::Read, Capability::Propose, Capability::Mutate],
TrustTier::SiteOwner => vec![Capability::Read, Capability::Propose, Capability::Mutate],
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_dev_mode_allows_all() {
let filter = CommandFilter::new(true);
assert!(filter.is_allowed("secrets::put", TrustTier::Apprentice));
assert!(filter.is_allowed("anything", TrustTier::Apprentice));
}
#[test]
fn test_live_mode_apprentice_restricted() {
let filter = CommandFilter::new(false);
assert!(filter.is_allowed("attestation::posture", TrustTier::Apprentice));
assert!(filter.is_allowed("help", TrustTier::Apprentice));
assert!(!filter.is_allowed("secrets::get", TrustTier::Apprentice));
assert!(!filter.is_allowed("crypto::sign", TrustTier::Apprentice));
}
#[test]
fn test_live_mode_master_has_crypto() {
let filter = CommandFilter::new(false);
assert!(filter.is_allowed("crypto::sign", TrustTier::Master));
assert!(filter.is_allowed("secrets::get", TrustTier::Master));
assert!(!filter.is_allowed("secrets::put", TrustTier::Master));
}
#[test]
fn test_live_mode_site_owner_has_all() {
let filter = CommandFilter::new(false);
assert!(filter.is_allowed("secrets::put", TrustTier::SiteOwner));
assert!(filter.is_allowed("secrets::rotate", TrustTier::SiteOwner));
assert!(filter.is_allowed("identity::authorize", TrustTier::SiteOwner));
}
#[test]
fn test_builtins_always_allowed() {
let filter = CommandFilter::new(false);
for tier in [TrustTier::Apprentice, TrustTier::Journeyman, TrustTier::Master, TrustTier::SiteOwner] {
assert!(filter.is_allowed("help", tier));
assert!(filter.is_allowed("status", tier));
assert!(filter.is_allowed("whoami", tier));
assert!(filter.is_allowed("exit", tier));
}
}
#[test]
fn test_trust_tier_from_str() {
assert_eq!(TrustTier::from_str("apprentice"), Some(TrustTier::Apprentice));
assert_eq!(TrustTier::from_str("MASTER"), Some(TrustTier::Master));
assert_eq!(TrustTier::from_str("site_owner"), Some(TrustTier::SiteOwner));
assert_eq!(TrustTier::from_str("owner"), Some(TrustTier::SiteOwner));
assert_eq!(TrustTier::from_str("invalid"), None);
}
#[test]
fn test_tier_capabilities() {
let caps = CommandFilter::capabilities(TrustTier::Apprentice);
assert_eq!(caps.len(), 1);
assert!(caps.contains(&Capability::Read));
let caps = CommandFilter::capabilities(TrustTier::Master);
assert_eq!(caps.len(), 3);
}
}

456
bascule-agent/src/config.rs Normal file
View file

@ -0,0 +1,456 @@
//! Configuration for bascule-agent, loaded from shell.toml.
use serde::Deserialize;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Deserialize)]
pub struct AgentConfig {
#[serde(default)]
pub agent: AgentSection,
}
#[derive(Debug, Clone, Deserialize)]
pub struct AgentSection {
#[serde(default = "default_socket_path")]
pub socket_path: String,
#[serde(default = "default_socket_permissions")]
pub socket_permissions: String,
#[serde(default)]
pub ssh: SshConfig,
#[serde(default)]
pub identity: IdentityConfig,
#[serde(default)]
pub audit: AuditConfig,
#[serde(default)]
pub namespaces: NamespaceConfig,
#[serde(default)]
pub session: SessionConfig,
}
#[derive(Debug, Clone, Deserialize)]
pub struct SshConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_ssh_listen")]
pub listen_addr: String,
#[serde(default)]
pub host_key_path: Option<PathBuf>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct IdentityConfig {
#[serde(default)]
pub keycloak_url: String,
#[serde(default = "default_realm")]
pub realm: String,
#[serde(default)]
pub dev_mode: bool,
}
#[derive(Debug, Clone, Deserialize)]
pub struct AuditConfig {
#[serde(default = "default_audit_backend")]
pub backend: String,
#[serde(default)]
pub log_path: Option<PathBuf>,
#[serde(default = "default_batch_size")]
pub batch_size: usize,
#[serde(default = "default_flush_interval")]
pub flush_interval_ms: u64,
}
#[derive(Debug, Clone, Deserialize)]
pub struct NamespaceConfig {
#[serde(default = "default_backend")]
pub backend: String,
#[serde(default)]
pub identity: IdentityNamespaceConfig,
#[serde(default)]
pub crypto: CryptoNamespaceConfig,
#[serde(default)]
pub audit: AuditNamespaceConfig,
#[serde(default)]
pub governance: GovernanceNamespaceConfig,
#[serde(default)]
pub secrets: SecretsNamespaceConfig,
#[serde(default)]
pub attestation: AttestationNamespaceConfig,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct IdentityNamespaceConfig {
#[serde(default)]
pub keycloak_introspect: bool,
#[serde(default = "default_token_cache_ttl")]
pub token_cache_ttl_seconds: u64,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct CryptoNamespaceConfig {
#[serde(default = "default_key_source")]
pub key_source: String,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct AuditNamespaceConfig {
#[serde(default = "default_merkle_algorithm")]
pub merkle_algorithm: String,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct GovernanceNamespaceConfig {
#[serde(default = "default_policy_engine")]
pub policy_engine: String,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct SecretsNamespaceConfig {
#[serde(default = "default_secrets_backend")]
pub backend: String,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct AttestationNamespaceConfig {
#[serde(default = "default_posture_source")]
pub posture_source: String,
#[serde(default = "default_posture_level")]
pub default_posture: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct SessionConfig {
#[serde(default = "default_scope")]
pub default_scope: String,
#[serde(default = "default_ttl_hours")]
pub default_ttl_hours: u64,
#[serde(default = "default_mutation_budget")]
pub default_mutation_budget: u32,
#[serde(default)]
pub motd_template: Option<PathBuf>,
}
// Default functions
fn default_socket_path() -> String {
"/var/run/substrate/agent.sock".into()
}
fn default_socket_permissions() -> String {
"0660".into()
}
fn default_ssh_listen() -> String {
"0.0.0.0:2222".into()
}
fn default_realm() -> String {
"substrate".into()
}
fn default_audit_backend() -> String {
"log".into()
}
fn default_batch_size() -> usize {
100
}
fn default_flush_interval() -> u64 {
1000
}
fn default_backend() -> String {
"soft".into()
}
fn default_token_cache_ttl() -> u64 {
300
}
fn default_key_source() -> String {
"env".into()
}
fn default_merkle_algorithm() -> String {
"sha256".into()
}
fn default_policy_engine() -> String {
"scope".into()
}
fn default_secrets_backend() -> String {
"env".into()
}
fn default_posture_source() -> String {
"config".into()
}
fn default_posture_level() -> String {
"standard".into()
}
fn default_scope() -> String {
"operate".into()
}
fn default_ttl_hours() -> u64 {
4
}
fn default_mutation_budget() -> u32 {
50
}
impl Default for AgentSection {
fn default() -> Self {
Self {
socket_path: default_socket_path(),
socket_permissions: default_socket_permissions(),
ssh: SshConfig::default(),
identity: IdentityConfig::default(),
audit: AuditConfig::default(),
namespaces: NamespaceConfig::default(),
session: SessionConfig::default(),
}
}
}
impl Default for SshConfig {
fn default() -> Self {
Self {
enabled: false,
listen_addr: default_ssh_listen(),
host_key_path: None,
}
}
}
impl Default for IdentityConfig {
fn default() -> Self {
Self {
keycloak_url: String::new(),
realm: default_realm(),
dev_mode: false,
}
}
}
impl Default for AuditConfig {
fn default() -> Self {
Self {
backend: default_audit_backend(),
log_path: None,
batch_size: default_batch_size(),
flush_interval_ms: default_flush_interval(),
}
}
}
impl Default for NamespaceConfig {
fn default() -> Self {
Self {
backend: default_backend(),
identity: Default::default(),
crypto: Default::default(),
audit: Default::default(),
governance: Default::default(),
secrets: Default::default(),
attestation: Default::default(),
}
}
}
impl Default for SessionConfig {
fn default() -> Self {
Self {
default_scope: default_scope(),
default_ttl_hours: default_ttl_hours(),
default_mutation_budget: default_mutation_budget(),
motd_template: None,
}
}
}
impl AgentConfig {
/// Load from a TOML file, substituting environment variables.
pub fn load(path: &Path) -> anyhow::Result<Self> {
let content = std::fs::read_to_string(path)?;
// Simple env var substitution: ${VAR_NAME} or ${VAR_NAME:default}
let expanded = expand_env_vars(&content);
let config: AgentConfig = toml::from_str(&expanded)?;
Ok(config)
}
/// Create with all defaults (for testing or dev mode).
pub fn default_config() -> Self {
Self {
agent: AgentSection::default(),
}
}
}
/// Expand `${VAR}` and `${VAR:default}` patterns in a string.
fn expand_env_vars(input: &str) -> String {
let mut result = String::with_capacity(input.len());
let mut chars = input.chars().peekable();
while let Some(c) = chars.next() {
if c == '$' && chars.peek() == Some(&'{') {
chars.next(); // consume '{'
let mut var_expr = String::new();
for c in chars.by_ref() {
if c == '}' {
break;
}
var_expr.push(c);
}
// Split on ':' for default value
if let Some((var_name, default)) = var_expr.split_once(':') {
match std::env::var(var_name) {
Ok(val) if !val.is_empty() => result.push_str(&val),
_ => result.push_str(default),
}
} else {
match std::env::var(&var_expr) {
Ok(val) => result.push_str(&val),
Err(_) => {} // empty string if not set
}
}
} else {
result.push(c);
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = AgentConfig::default_config();
assert_eq!(config.agent.socket_path, "/var/run/substrate/agent.sock");
assert_eq!(config.agent.socket_permissions, "0660");
assert!(!config.agent.ssh.enabled);
assert_eq!(config.agent.ssh.listen_addr, "0.0.0.0:2222");
assert_eq!(config.agent.identity.realm, "substrate");
assert!(!config.agent.identity.dev_mode);
assert_eq!(config.agent.audit.backend, "log");
assert_eq!(config.agent.audit.batch_size, 100);
assert_eq!(config.agent.namespaces.backend, "soft");
assert_eq!(config.agent.session.default_scope, "operate");
assert_eq!(config.agent.session.default_ttl_hours, 4);
assert_eq!(config.agent.session.default_mutation_budget, 50);
}
#[test]
fn test_parse_minimal_toml() {
let toml = r#"
[agent]
socket_path = "/tmp/test.sock"
"#;
let config: AgentConfig = toml::from_str(toml).unwrap();
assert_eq!(config.agent.socket_path, "/tmp/test.sock");
// All other fields should have defaults
assert_eq!(config.agent.audit.backend, "log");
}
#[test]
fn test_parse_full_toml() {
let toml = r#"
[agent]
socket_path = "/var/run/substrate/agent.sock"
socket_permissions = "0660"
[agent.ssh]
enabled = true
listen_addr = "0.0.0.0:2222"
host_key_path = "/etc/substrate/ssh_host_ed25519_key"
[agent.identity]
keycloak_url = "https://auth.guildhouse.dev"
realm = "substrate"
dev_mode = false
[agent.audit]
backend = "log"
log_path = "/var/log/substrate/audit.json"
batch_size = 100
flush_interval_ms = 1000
[agent.namespaces]
backend = "soft"
[agent.namespaces.identity]
keycloak_introspect = true
token_cache_ttl_seconds = 300
[agent.namespaces.crypto]
key_source = "env"
[agent.namespaces.governance]
policy_engine = "scope"
[agent.session]
default_scope = "operate"
default_ttl_hours = 4
default_mutation_budget = 50
motd_template = "/etc/substrate/motd.template"
"#;
let config: AgentConfig = toml::from_str(toml).unwrap();
assert!(config.agent.ssh.enabled);
assert_eq!(
config.agent.identity.keycloak_url,
"https://auth.guildhouse.dev"
);
assert!(config.agent.namespaces.identity.keycloak_introspect);
assert_eq!(
config.agent.session.motd_template.unwrap().to_str().unwrap(),
"/etc/substrate/motd.template"
);
}
#[test]
fn test_env_var_expansion() {
std::env::set_var("TEST_BASCULE_VAR", "hello");
assert_eq!(expand_env_vars("${TEST_BASCULE_VAR}"), "hello");
assert_eq!(expand_env_vars("${MISSING_VAR:fallback}"), "fallback");
assert_eq!(expand_env_vars("no vars here"), "no vars here");
assert_eq!(
expand_env_vars("pre-${TEST_BASCULE_VAR}-post"),
"pre-hello-post"
);
std::env::remove_var("TEST_BASCULE_VAR");
}
#[test]
fn test_load_from_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.toml");
std::fs::write(
&path,
r#"
[agent]
socket_path = "/tmp/agent.sock"
[agent.identity]
dev_mode = true
"#,
)
.unwrap();
let config = AgentConfig::load(&path).unwrap();
assert_eq!(config.agent.socket_path, "/tmp/agent.sock");
assert!(config.agent.identity.dev_mode);
}
}

View file

@ -0,0 +1,411 @@
//! Governance IPC server — Unix socket serving Shellstream protocol.
//!
//! Django middleware, Celery workers, and co-located apps connect here.
//! Each connection is bound to a session established on the first message.
use std::path::Path;
use std::sync::Arc;
use anyhow::Context;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{UnixListener, UnixStream};
use tracing::{debug, error, info, warn};
use crate::config::AgentConfig;
use crate::namespace::NamespaceRouter;
use crate::session_store::SessionStore;
use crate::shellstream::{Namespace, ShellstreamMessage, ShellstreamResponse, Status};
/// The governance IPC server.
pub struct GovernanceServer {
config: Arc<AgentConfig>,
router: Arc<NamespaceRouter>,
sessions: Arc<SessionStore>,
}
impl GovernanceServer {
pub fn new(
config: Arc<AgentConfig>,
router: Arc<NamespaceRouter>,
sessions: Arc<SessionStore>,
) -> Self {
Self {
config,
router,
sessions,
}
}
/// Start listening on the Unix socket. Runs until cancelled.
pub async fn serve(&self) -> anyhow::Result<()> {
let socket_path = &self.config.agent.socket_path;
// Remove stale socket if it exists
if Path::new(socket_path).exists() {
std::fs::remove_file(socket_path)
.with_context(|| format!("Failed to remove stale socket at {socket_path}"))?;
}
// Ensure parent directory exists
if let Some(parent) = Path::new(socket_path).parent() {
std::fs::create_dir_all(parent).ok();
}
let listener = UnixListener::bind(socket_path)
.with_context(|| format!("Failed to bind Unix socket at {socket_path}"))?;
// Set socket permissions (best effort)
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = u32::from_str_radix(
self.config
.agent
.socket_permissions
.trim_start_matches('0'),
8,
)
.unwrap_or(0o660);
std::fs::set_permissions(socket_path, std::fs::Permissions::from_mode(perms)).ok();
}
info!(socket_path, "Governance IPC server listening");
loop {
match listener.accept().await {
Ok((stream, _addr)) => {
let router = self.router.clone();
let sessions = self.sessions.clone();
tokio::spawn(async move {
if let Err(e) = handle_connection(stream, router, sessions).await {
debug!(error = %e, "Connection closed");
}
});
}
Err(e) => {
error!(error = %e, "Failed to accept connection");
}
}
}
}
}
/// Handle a single client connection.
async fn handle_connection(
mut stream: UnixStream,
router: Arc<NamespaceRouter>,
sessions: Arc<SessionStore>,
) -> anyhow::Result<()> {
debug!("New IPC connection");
loop {
// Read 4-byte big-endian length prefix
let length = match read_length_prefix(&mut stream).await {
Ok(0) => break, // clean disconnect
Ok(len) => len,
Err(e) => {
// ConnectionReset / EOF = clean disconnect
if e.downcast_ref::<std::io::Error>()
.is_some_and(|io_err| {
io_err.kind() == std::io::ErrorKind::UnexpectedEof
|| io_err.kind() == std::io::ErrorKind::ConnectionReset
})
{
break;
}
return Err(e);
}
};
// Sanity check message size (max 16 MB)
if length > 16 * 1024 * 1024 {
warn!(length, "Message too large, dropping connection");
break;
}
// Read message body
let mut body = vec![0u8; length as usize];
stream
.read_exact(&mut body)
.await
.context("Failed to read message body")?;
// Decode the Shellstream message
let msg = match ShellstreamMessage::decode(&body) {
Ok(msg) => msg,
Err(e) => {
warn!(error = %e, "Malformed message");
let resp = ShellstreamResponse::error([0u8; 16], 0, &format!("Malformed: {e}"));
write_response(&mut stream, &resp).await?;
continue;
}
};
// Ensure session exists (auto-create on first message)
sessions.ensure_session(msg.session_id);
// Validate nonce (replay protection)
if !sessions.validate_nonce(msg.session_id, msg.nonce) {
let resp = ShellstreamResponse::denied(msg.session_id, msg.nonce, "Replay detected");
write_response(&mut stream, &resp).await?;
continue;
}
// Rate limit
if !sessions.check_rate_limit(msg.session_id) {
let resp =
ShellstreamResponse::denied(msg.session_id, msg.nonce, "Rate limit exceeded");
write_response(&mut stream, &resp).await?;
continue;
}
// Route to namespace handler
let response = match Namespace::from_u16(msg.namespace) {
Some(ns) => router.handle(ns, msg.function, &msg.payload, &msg.session_id).await,
None => ShellstreamResponse::error(
msg.session_id,
msg.nonce,
&format!("Unknown namespace: {:#06x}", msg.namespace),
),
};
// Send response (echo session_id and nonce)
let response = ShellstreamResponse {
session_id: msg.session_id,
nonce: msg.nonce,
..response
};
write_response(&mut stream, &response).await?;
}
debug!("IPC connection closed");
Ok(())
}
/// Read 4-byte big-endian length prefix.
async fn read_length_prefix(stream: &mut UnixStream) -> anyhow::Result<u32> {
let mut buf = [0u8; 4];
stream.read_exact(&mut buf).await?;
Ok(u32::from_be_bytes(buf))
}
/// Write a length-prefixed response.
async fn write_response(
stream: &mut UnixStream,
response: &ShellstreamResponse,
) -> anyhow::Result<()> {
let encoded = response.encode();
stream.write_all(&encoded).await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::namespace::NamespaceRouter;
use crate::session_store::SessionStore;
use rmp_serde;
use rmpv::Value;
use tempfile::TempDir;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::UnixStream;
fn test_config(socket_path: &str) -> AgentConfig {
let mut config = AgentConfig::default_config();
config.agent.socket_path = socket_path.into();
config
}
/// Encode a ShellstreamMessage to wire format (length-prefixed msgpack).
fn encode_request(namespace: u16, function: u16, session_id: [u8; 16], payload: &[u8], nonce: u64) -> Vec<u8> {
let arr = Value::Array(vec![
Value::from(namespace as u64),
Value::from(function as u64),
Value::Binary(session_id.to_vec()),
Value::Binary(payload.to_vec()),
Value::from(nonce),
]);
let body = rmp_serde::to_vec(&arr).unwrap();
let len = body.len() as u32;
let mut out = Vec::with_capacity(4 + body.len());
out.extend_from_slice(&len.to_be_bytes());
out.extend_from_slice(&body);
out
}
/// Read a response from a stream.
async fn read_response(stream: &mut UnixStream) -> ShellstreamResponse {
let mut len_buf = [0u8; 4];
stream.read_exact(&mut len_buf).await.unwrap();
let len = u32::from_be_bytes(len_buf) as usize;
let mut body = vec![0u8; len];
stream.read_exact(&mut body).await.unwrap();
// Parse manually
let value: rmpv::Value = rmp_serde::from_slice(&body).unwrap();
let arr = value.as_array().unwrap();
let status = arr[0].as_u64().unwrap() as u16;
let session_id_bytes = match &arr[1] {
Value::Binary(b) => b.clone(),
_ => panic!("Expected binary session_id"),
};
let payload = match &arr[2] {
Value::Binary(b) => b.clone(),
_ => panic!("Expected binary payload"),
};
let nonce = arr[3].as_u64().unwrap();
let mut session_id = [0u8; 16];
session_id.copy_from_slice(&session_id_bytes);
ShellstreamResponse {
status,
session_id,
payload,
nonce,
}
}
#[tokio::test]
async fn test_server_accepts_connection() {
let dir = TempDir::new().unwrap();
let sock = dir.path().join("agent.sock");
let config = Arc::new(test_config(sock.to_str().unwrap()));
let router = Arc::new(NamespaceRouter::new());
let sessions = Arc::new(SessionStore::new());
let server = GovernanceServer::new(config, router, sessions);
let handle = tokio::spawn(async move { server.serve().await });
// Wait for socket to appear
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
let mut stream = UnixStream::connect(&sock).await.unwrap();
// Send a valid audit emit request
let payload = rmp_serde::to_vec(&serde_json::json!({"event_type": "test"})).unwrap();
let data = encode_request(0x0006, 0x01, [0xAA; 16], &payload, 1);
stream.write_all(&data).await.unwrap();
let resp = read_response(&mut stream).await;
assert_eq!(resp.session_id, [0xAA; 16]);
assert_eq!(resp.nonce, 1);
// Will be an error since no handlers registered — that's fine for this test
// The point is the server accepted the connection and responded
handle.abort();
}
#[tokio::test]
async fn test_unknown_namespace_returns_error() {
let dir = TempDir::new().unwrap();
let sock = dir.path().join("agent.sock");
let config = Arc::new(test_config(sock.to_str().unwrap()));
let router = Arc::new(NamespaceRouter::new());
let sessions = Arc::new(SessionStore::new());
let server = GovernanceServer::new(config, router, sessions);
let handle = tokio::spawn(async move { server.serve().await });
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
let mut stream = UnixStream::connect(&sock).await.unwrap();
let data = encode_request(0x00FF, 0x01, [0xBB; 16], &[0x80], 1);
stream.write_all(&data).await.unwrap();
let resp = read_response(&mut stream).await;
assert_eq!(resp.status, Status::Error as u16);
assert_eq!(resp.nonce, 1);
handle.abort();
}
#[tokio::test]
async fn test_nonce_replay_rejected() {
let dir = TempDir::new().unwrap();
let sock = dir.path().join("agent.sock");
let config = Arc::new(test_config(sock.to_str().unwrap()));
let router = Arc::new(NamespaceRouter::new());
let sessions = Arc::new(SessionStore::new());
let server = GovernanceServer::new(config, router, sessions);
let handle = tokio::spawn(async move { server.serve().await });
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
let mut stream = UnixStream::connect(&sock).await.unwrap();
let sid = [0xCC; 16];
// First request with nonce=5 — should succeed
let data = encode_request(0x0006, 0x01, sid, &[0x80], 5);
stream.write_all(&data).await.unwrap();
let resp = read_response(&mut stream).await;
assert_eq!(resp.nonce, 5);
// Replay same nonce=5 — should be denied
let data = encode_request(0x0006, 0x01, sid, &[0x80], 5);
stream.write_all(&data).await.unwrap();
let resp = read_response(&mut stream).await;
assert_eq!(resp.status, Status::Denied as u16);
// Lower nonce=3 — should also be denied
let data = encode_request(0x0006, 0x01, sid, &[0x80], 3);
stream.write_all(&data).await.unwrap();
let resp = read_response(&mut stream).await;
assert_eq!(resp.status, Status::Denied as u16);
// Higher nonce=6 — should succeed
let data = encode_request(0x0006, 0x01, sid, &[0x80], 6);
stream.write_all(&data).await.unwrap();
let resp = read_response(&mut stream).await;
assert_eq!(resp.nonce, 6);
// Not denied (will be error because no handler, but not DENIED)
assert_ne!(resp.status, Status::Denied as u16);
handle.abort();
}
#[tokio::test]
async fn test_malformed_message() {
let dir = TempDir::new().unwrap();
let sock = dir.path().join("agent.sock");
let config = Arc::new(test_config(sock.to_str().unwrap()));
let router = Arc::new(NamespaceRouter::new());
let sessions = Arc::new(SessionStore::new());
let server = GovernanceServer::new(config, router, sessions);
let handle = tokio::spawn(async move { server.serve().await });
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
let mut stream = UnixStream::connect(&sock).await.unwrap();
// Send garbage with valid length prefix
let garbage = vec![0xFF, 0x01, 0x02, 0x03];
let len = garbage.len() as u32;
stream.write_all(&len.to_be_bytes()).await.unwrap();
stream.write_all(&garbage).await.unwrap();
let resp = read_response(&mut stream).await;
assert_eq!(resp.status, Status::Error as u16);
handle.abort();
}
#[tokio::test]
async fn test_clean_disconnect() {
let dir = TempDir::new().unwrap();
let sock = dir.path().join("agent.sock");
let config = Arc::new(test_config(sock.to_str().unwrap()));
let router = Arc::new(NamespaceRouter::new());
let sessions = Arc::new(SessionStore::new());
let server = GovernanceServer::new(config, router, sessions);
let handle = tokio::spawn(async move { server.serve().await });
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
let stream = UnixStream::connect(&sock).await.unwrap();
drop(stream); // Clean disconnect
// Server should still be running — connect again
tokio::time::sleep(tokio::time::Duration::from_millis(20)).await;
let _stream2 = UnixStream::connect(&sock).await.unwrap();
handle.abort();
}
}

147
bascule-agent/src/main.rs Normal file
View file

@ -0,0 +1,147 @@
//! bascule-agent — governed application sidecar.
//!
//! A single Rust process serving two interfaces over one governance engine:
//!
//! 1. **Governance IPC** — Unix socket at `/var/run/substrate/agent.sock`
//! Django middleware, Celery workers, and co-located apps talk Shellstream here.
//!
//! 2. **Interactive Shell** — SSH server on port 2222 (optional)
//! Bascule Gateway, `sb shell`, and Dashboard WebSocket connect here.
use std::path::PathBuf;
use std::sync::Arc;
use anyhow::Context;
use clap::Parser;
use tracing::{error, info};
mod command_filter;
mod config;
mod governance_server;
mod namespace;
mod session_store;
mod shellstream;
mod ssh_server;
use governance_server::GovernanceServer;
use namespace::NamespaceRouter;
use session_store::SessionStore;
use shellstream::Namespace;
use ssh_server::AgentSSHServer;
#[derive(Parser)]
#[command(name = "bascule-agent", about = "Governed application sidecar")]
struct Cli {
/// Path to shell.toml configuration file.
#[arg(long, default_value = "/etc/substrate/shell.toml")]
config: PathBuf,
/// Run with default config (dev mode, no config file needed).
#[arg(long)]
dev: bool,
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// Initialize tracing
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.json()
.init();
let cli = Cli::parse();
let config = if cli.dev {
info!("Starting in dev mode with default configuration");
config::AgentConfig::default_config()
} else {
config::AgentConfig::load(&cli.config)
.with_context(|| format!("Failed to load config from {:?}", cli.config))?
};
info!(
socket_path = %config.agent.socket_path,
ssh_enabled = config.agent.ssh.enabled,
namespace_backend = %config.agent.namespaces.backend,
"bascule-agent starting"
);
let dev_mode = config.agent.namespaces.backend == "soft";
let config = Arc::new(config);
// Build namespace router with all soft-mode handlers
let mut router = NamespaceRouter::new();
router.register(
Namespace::Crypto,
Arc::new(namespace::crypto::CryptoHandler::new()),
);
router.register(
Namespace::Identity,
Arc::new(namespace::identity::IdentityHandler::new(dev_mode)),
);
router.register(
Namespace::Secrets,
Arc::new(namespace::secrets::SecretsHandler::new()),
);
router.register(
Namespace::Governance,
Arc::new(namespace::governance::GovernanceHandler::new(dev_mode)),
);
router.register(
Namespace::Attestation,
Arc::new(namespace::attestation::AttestationHandler::new(
config.agent.namespaces.attestation.default_posture.clone(),
)),
);
router.register(
Namespace::Audit,
Arc::new(namespace::audit::AuditHandler::new()),
);
router.register(
Namespace::Network,
Arc::new(namespace::network::NetworkHandler::new()),
);
router.register(
Namespace::Intelligence,
Arc::new(namespace::intelligence::IntelligenceHandler::new()),
);
let router = Arc::new(router);
let sessions = Arc::new(SessionStore::new());
// Start governance IPC server
let server = GovernanceServer::new(config.clone(), router.clone(), sessions);
let ipc_handle = tokio::spawn(async move {
if let Err(e) = server.serve().await {
error!(error = %e, "Governance IPC server failed");
}
});
// Start SSH shell server (if enabled)
let ssh_handle = if config.agent.ssh.enabled {
let ssh_server = AgentSSHServer::new(config.clone(), router.clone())?;
Some(tokio::spawn(async move {
if let Err(e) = ssh_server.serve().await {
error!(error = %e, "SSH shell server failed");
}
}))
} else {
info!("SSH shell server disabled");
None
};
info!("bascule-agent ready");
// Wait for shutdown signal
tokio::signal::ctrl_c().await?;
info!("bascule-agent shutting down");
ipc_handle.abort();
if let Some(h) = ssh_handle {
h.abort();
}
Ok(())
}

View file

@ -0,0 +1,74 @@
//! ATTESTATION namespace (0x0005) — posture level + soft SAT generation.
use async_trait::async_trait;
use chrono::Utc;
use crate::shellstream::{attestation, ShellstreamResponse};
use super::NamespaceHandler;
pub struct AttestationHandler {
default_posture: String,
}
impl AttestationHandler {
pub fn new(default_posture: String) -> Self {
Self { default_posture }
}
}
#[async_trait]
impl NamespaceHandler for AttestationHandler {
async fn handle(
&self,
function_id: u16,
payload: &[u8],
session_id: &[u8; 16],
) -> ShellstreamResponse {
match function_id {
attestation::POSTURE => self.posture(session_id).await,
attestation::SAT_BUNDLE => self.sat_bundle(session_id).await,
attestation::ATTESTATION_VERIFY => self.verify(payload, session_id).await,
_ => ShellstreamResponse::error(
*session_id,
0,
&format!("Unknown ATTESTATION function: {function_id:#06x}"),
),
}
}
}
impl AttestationHandler {
async fn posture(&self, session_id: &[u8; 16]) -> ShellstreamResponse {
let response = rmp_serde::to_vec(&serde_json::json!({
"level": self.default_posture,
"source": "config",
"timestamp": Utc::now().to_rfc3339(),
}))
.unwrap_or_default();
ShellstreamResponse::ok(*session_id, 0, response)
}
async fn sat_bundle(&self, session_id: &[u8; 16]) -> ShellstreamResponse {
// Soft SAT: a JSON bundle with session info (not cryptographically bound)
let bundle_id = uuid::Uuid::new_v4().to_string();
let response = rmp_serde::to_vec(&serde_json::json!({
"bundle_id": bundle_id,
"session_id": hex::encode(session_id),
"posture": self.default_posture,
"issued_at": Utc::now().to_rfc3339(),
"soft_mode": true,
}))
.unwrap_or_default();
ShellstreamResponse::ok(*session_id, 0, response)
}
async fn verify(&self, _payload: &[u8], session_id: &[u8; 16]) -> ShellstreamResponse {
// Soft mode: always returns valid (no TPM to verify against)
let response = rmp_serde::to_vec(&serde_json::json!({
"valid": true,
"soft_mode": true,
}))
.unwrap_or_default();
ShellstreamResponse::ok(*session_id, 0, response)
}
}

View file

@ -0,0 +1,111 @@
//! AUDIT namespace (0x0006) — structured event emission + merkle anchoring.
use async_trait::async_trait;
use sha2::{Digest, Sha256};
use tracing::info;
use uuid::Uuid;
use crate::shellstream::{audit, ShellstreamResponse};
use super::NamespaceHandler;
pub struct AuditHandler;
impl AuditHandler {
pub fn new() -> Self {
Self
}
}
#[async_trait]
impl NamespaceHandler for AuditHandler {
async fn handle(
&self,
function_id: u16,
payload: &[u8],
session_id: &[u8; 16],
) -> ShellstreamResponse {
match function_id {
audit::EMIT => self.emit(payload, session_id).await,
audit::ANCHOR => self.anchor(payload, session_id).await,
audit::QUERY => self.query(payload, session_id).await,
_ => ShellstreamResponse::error(
*session_id,
0,
&format!("Unknown AUDIT function: {function_id:#06x}"),
),
}
}
}
impl AuditHandler {
async fn emit(&self, payload: &[u8], session_id: &[u8; 16]) -> ShellstreamResponse {
// Compute merkle leaf hash of the event
let mut hasher = Sha256::new();
hasher.update(session_id);
hasher.update(payload);
let hash = hasher.finalize();
let merkle_leaf = hex::encode(&hash);
let event_id = Uuid::new_v4().to_string();
// Log the audit event (structured JSON)
let payload_parsed: serde_json::Value =
rmp_serde::from_slice(payload).unwrap_or(serde_json::Value::Null);
info!(
event_id = %event_id,
session_id = %hex::encode(session_id),
merkle_leaf = %merkle_leaf,
event = %payload_parsed,
"audit.emit"
);
let response_payload = rmp_serde::to_vec(&serde_json::json!({
"event_id": event_id,
"merkle_leaf": merkle_leaf,
}))
.unwrap_or_default();
ShellstreamResponse::ok(*session_id, 0, response_payload)
}
async fn anchor(&self, payload: &[u8], session_id: &[u8; 16]) -> ShellstreamResponse {
let payload_parsed: serde_json::Value =
rmp_serde::from_slice(payload).unwrap_or(serde_json::Value::Null);
let event_ids = payload_parsed
.get("event_ids")
.and_then(|v| v.as_array())
.map(|arr| arr.len())
.unwrap_or(0);
// Compute subtree root hash
let mut hasher = Sha256::new();
hasher.update(payload);
let root_hash = hex::encode(hasher.finalize());
info!(
event_count = event_ids,
root_hash = %root_hash,
"audit.anchor"
);
let response_payload = rmp_serde::to_vec(&serde_json::json!({
"root_hash": root_hash,
"event_count": event_ids,
}))
.unwrap_or_default();
ShellstreamResponse::ok(*session_id, 0, response_payload)
}
async fn query(&self, _payload: &[u8], session_id: &[u8; 16]) -> ShellstreamResponse {
// Stub: return empty results
let response_payload = rmp_serde::to_vec(&serde_json::json!({
"events": [],
}))
.unwrap_or_default();
ShellstreamResponse::ok(*session_id, 0, response_payload)
}
}

View file

@ -0,0 +1,127 @@
//! CRYPTO namespace (0x0001) — signing, verification, hashing.
use async_trait::async_trait;
use sha2::{Digest, Sha256};
use crate::shellstream::{crypto, ShellstreamResponse};
use super::NamespaceHandler;
pub struct CryptoHandler;
impl CryptoHandler {
pub fn new() -> Self {
Self
}
}
#[async_trait]
impl NamespaceHandler for CryptoHandler {
async fn handle(
&self,
function_id: u16,
payload: &[u8],
session_id: &[u8; 16],
) -> ShellstreamResponse {
match function_id {
crypto::SIGN => self.sign(payload, session_id).await,
crypto::VERIFY => self.verify(payload, session_id).await,
// ENCRYPT (canonical ID 6) — used for hash in soft mode
crypto::ENCRYPT => self.hash(payload, session_id).await,
_ => ShellstreamResponse::error(
*session_id,
0,
&format!("Unknown CRYPTO function: {function_id:#06x}"),
),
}
}
}
/// Extract bytes from an rmpv map field — handles both Binary and String values.
fn extract_bytes_from_map(map: &rmpv::Value, key: &str) -> Vec<u8> {
let key_val = rmpv::Value::String(key.into());
if let rmpv::Value::Map(entries) = map {
for (k, v) in entries {
if k == &key_val {
return match v {
rmpv::Value::Binary(b) => b.clone(),
rmpv::Value::String(s) => s.as_bytes().to_vec(),
_ => vec![],
};
}
}
}
vec![]
}
/// Extract a UTF-8 string from an rmpv map field — handles Binary (as UTF-8), String, or missing.
fn extract_string_from_map(map: &rmpv::Value, key: &str) -> String {
let key_val = rmpv::Value::String(key.into());
if let rmpv::Value::Map(entries) = map {
for (k, v) in entries {
if k == &key_val {
return match v {
rmpv::Value::String(s) => s.as_str().unwrap_or_default().to_string(),
rmpv::Value::Binary(b) => String::from_utf8_lossy(b).to_string(),
_ => String::new(),
};
}
}
}
String::new()
}
impl CryptoHandler {
async fn sign(&self, payload: &[u8], session_id: &[u8; 16]) -> ShellstreamResponse {
// Soft mode: HMAC-SHA256 with a dev key.
// Use rmpv::Value to preserve binary data from Python SDK.
let payload_parsed: rmpv::Value =
rmp_serde::from_slice(payload).unwrap_or(rmpv::Value::Nil);
let data = extract_bytes_from_map(&payload_parsed, "data");
let mut hasher = Sha256::new();
hasher.update(b"soft-signing-key:");
hasher.update(&data);
let signature = hex::encode(hasher.finalize());
let response = rmp_serde::to_vec(&serde_json::json!({
"signature": signature,
"algorithm": "sha256-hmac-soft",
}))
.unwrap_or_default();
ShellstreamResponse::ok(*session_id, 0, response)
}
async fn verify(&self, payload: &[u8], session_id: &[u8; 16]) -> ShellstreamResponse {
let payload_parsed: rmpv::Value =
rmp_serde::from_slice(payload).unwrap_or(rmpv::Value::Nil);
let data = extract_bytes_from_map(&payload_parsed, "data");
// Signature may arrive as String or Binary (Python SDK sends bytes)
let sig = extract_string_from_map(&payload_parsed, "signature");
// Recompute
let mut hasher = Sha256::new();
hasher.update(b"soft-signing-key:");
hasher.update(&data);
let expected = hex::encode(hasher.finalize());
let valid = sig == expected;
let response = rmp_serde::to_vec(&serde_json::json!({
"valid": valid,
}))
.unwrap_or_default();
ShellstreamResponse::ok(*session_id, 0, response)
}
async fn hash(&self, payload: &[u8], session_id: &[u8; 16]) -> ShellstreamResponse {
let hash = hex::encode(Sha256::digest(payload));
let response = rmp_serde::to_vec(&serde_json::json!({
"hash": hash,
"algorithm": "sha256",
}))
.unwrap_or_default();
ShellstreamResponse::ok(*session_id, 0, response)
}
}

View file

@ -0,0 +1,87 @@
//! GOVERNANCE namespace (0x0004) — scope checking + ceremony proposals.
use async_trait::async_trait;
use crate::shellstream::{governance, ShellstreamResponse};
use super::NamespaceHandler;
pub struct GovernanceHandler {
dev_mode: bool,
}
impl GovernanceHandler {
pub fn new(dev_mode: bool) -> Self {
Self { dev_mode }
}
}
#[async_trait]
impl NamespaceHandler for GovernanceHandler {
async fn handle(
&self,
function_id: u16,
payload: &[u8],
session_id: &[u8; 16],
) -> ShellstreamResponse {
match function_id {
governance::GATE => self.gate(payload, session_id).await,
governance::PROPOSE => self.propose(payload, session_id).await,
governance::ATTENUATE => self.evaluate(payload, session_id).await,
_ => ShellstreamResponse::error(
*session_id,
0,
&format!("Unknown GOVERNANCE function: {function_id:#06x}"),
),
}
}
}
impl GovernanceHandler {
async fn gate(&self, payload: &[u8], session_id: &[u8; 16]) -> ShellstreamResponse {
let payload_parsed: serde_json::Value =
rmp_serde::from_slice(payload).unwrap_or(serde_json::Value::Null);
let subject = payload_parsed.get("subject").and_then(|v| v.as_str()).unwrap_or("");
let resource = payload_parsed.get("resource").and_then(|v| v.as_str()).unwrap_or("");
let action = payload_parsed.get("action").and_then(|v| v.as_str()).unwrap_or("");
tracing::debug!(subject, resource, action, "governance.gate");
if self.dev_mode {
// Dev mode: allow everything
let response = rmp_serde::to_vec(&serde_json::json!({
"permitted": true,
}))
.unwrap_or_default();
return ShellstreamResponse::ok(*session_id, 0, response);
}
// TODO: Check SessionScope::permits() from bascule-core (Stage 3)
let response = rmp_serde::to_vec(&serde_json::json!({
"permitted": true,
}))
.unwrap_or_default();
ShellstreamResponse::ok(*session_id, 0, response)
}
async fn propose(&self, _payload: &[u8], session_id: &[u8; 16]) -> ShellstreamResponse {
let proposal_id = uuid::Uuid::new_v4().to_string();
tracing::info!(proposal_id, "governance.propose");
let response = rmp_serde::to_vec(&serde_json::json!({
"proposal_id": proposal_id,
"status": "created",
}))
.unwrap_or_default();
ShellstreamResponse::ok(*session_id, 0, response)
}
async fn evaluate(&self, _payload: &[u8], session_id: &[u8; 16]) -> ShellstreamResponse {
let response = rmp_serde::to_vec(&serde_json::json!({
"message": "evaluate not yet implemented",
}))
.unwrap_or_default();
ShellstreamResponse::ok(*session_id, 0, response)
}
}

View file

@ -0,0 +1,109 @@
//! IDENTITY namespace (0x0002) — OIDC token validation + authorization.
use async_trait::async_trait;
use crate::shellstream::{identity, ShellstreamResponse};
use super::NamespaceHandler;
pub struct IdentityHandler {
dev_mode: bool,
}
impl IdentityHandler {
pub fn new(dev_mode: bool) -> Self {
Self { dev_mode }
}
}
#[async_trait]
impl NamespaceHandler for IdentityHandler {
async fn handle(
&self,
function_id: u16,
payload: &[u8],
session_id: &[u8; 16],
) -> ShellstreamResponse {
match function_id {
identity::AUTHENTICATE => self.authenticate(payload, session_id).await,
identity::AUTHORIZE => self.authorize(payload, session_id).await,
identity::WHOAMI => self.resolve(payload, session_id).await,
_ => ShellstreamResponse::error(
*session_id,
0,
&format!("Unknown IDENTITY function: {function_id:#06x}"),
),
}
}
}
impl IdentityHandler {
async fn authenticate(&self, payload: &[u8], session_id: &[u8; 16]) -> ShellstreamResponse {
let payload_parsed: serde_json::Value =
rmp_serde::from_slice(payload).unwrap_or(serde_json::Value::Null);
if self.dev_mode {
// Dev mode: accept any token, return synthetic identity
let response = rmp_serde::to_vec(&serde_json::json!({
"subject": "dev-user",
"email": "dev@guildhouse.local",
"issuer": "dev-mode",
"verified": true,
}))
.unwrap_or_default();
return ShellstreamResponse::ok(*session_id, 0, response);
}
// TODO: Validate JWT via OidcAuthProvider (Stage 3 — after moving to bascule-core)
let token = payload_parsed
.get("token")
.and_then(|v| v.as_str())
.unwrap_or("");
if token.is_empty() {
return ShellstreamResponse::denied(*session_id, 0, "No token provided");
}
// Placeholder: accept token and return minimal claims
let response = rmp_serde::to_vec(&serde_json::json!({
"subject": "unknown",
"verified": false,
"message": "OIDC validation not yet implemented",
}))
.unwrap_or_default();
ShellstreamResponse::ok(*session_id, 0, response)
}
async fn authorize(&self, payload: &[u8], session_id: &[u8; 16]) -> ShellstreamResponse {
if self.dev_mode {
let response = rmp_serde::to_vec(&serde_json::json!({
"authorized": true,
}))
.unwrap_or_default();
return ShellstreamResponse::ok(*session_id, 0, response);
}
let payload_parsed: serde_json::Value =
rmp_serde::from_slice(payload).unwrap_or(serde_json::Value::Null);
let subject = payload_parsed.get("subject").and_then(|v| v.as_str()).unwrap_or("");
let resource = payload_parsed.get("resource").and_then(|v| v.as_str()).unwrap_or("");
let action = payload_parsed.get("action").and_then(|v| v.as_str()).unwrap_or("");
tracing::debug!(subject, resource, action, "identity.authorize");
// Default: allow in soft mode
let response = rmp_serde::to_vec(&serde_json::json!({
"authorized": true,
}))
.unwrap_or_default();
ShellstreamResponse::ok(*session_id, 0, response)
}
async fn resolve(&self, _payload: &[u8], session_id: &[u8; 16]) -> ShellstreamResponse {
let response = rmp_serde::to_vec(&serde_json::json!({
"message": "resolve not yet implemented",
}))
.unwrap_or_default();
ShellstreamResponse::ok(*session_id, 0, response)
}
}

View file

@ -0,0 +1,68 @@
//! INTELLIGENCE namespace (0x0008) — soft-mode inference passthrough.
use async_trait::async_trait;
use crate::shellstream::{intelligence, ShellstreamResponse};
use super::NamespaceHandler;
pub struct IntelligenceHandler;
impl IntelligenceHandler {
pub fn new() -> Self {
Self
}
}
#[async_trait]
impl NamespaceHandler for IntelligenceHandler {
async fn handle(
&self,
function_id: u16,
payload: &[u8],
session_id: &[u8; 16],
) -> ShellstreamResponse {
match function_id {
intelligence::INFER => self.infer(payload, session_id).await,
intelligence::EMBED => self.embed(payload, session_id).await,
intelligence::INTELLIGENCE_CLASSIFY => self.classify(payload, session_id).await,
_ => ShellstreamResponse::error(
*session_id,
0,
&format!("Unknown INTELLIGENCE function: {function_id:#06x}"),
),
}
}
}
impl IntelligenceHandler {
async fn infer(&self, _payload: &[u8], session_id: &[u8; 16]) -> ShellstreamResponse {
// Soft mode: no local model — return stub indicating external provider needed
let response = rmp_serde::to_vec(&serde_json::json!({
"status": "not_available",
"reason": "No local model loaded in soft mode. Use external provider directly.",
"soft_mode": true,
}))
.unwrap_or_default();
ShellstreamResponse::ok(*session_id, 0, response)
}
async fn embed(&self, _payload: &[u8], session_id: &[u8; 16]) -> ShellstreamResponse {
let response = rmp_serde::to_vec(&serde_json::json!({
"status": "not_available",
"reason": "Embedding requires HFL namespace with loaded model.",
"soft_mode": true,
}))
.unwrap_or_default();
ShellstreamResponse::ok(*session_id, 0, response)
}
async fn classify(&self, _payload: &[u8], session_id: &[u8; 16]) -> ShellstreamResponse {
let response = rmp_serde::to_vec(&serde_json::json!({
"status": "not_available",
"reason": "Classification requires HFL namespace with loaded model.",
"soft_mode": true,
}))
.unwrap_or_default();
ShellstreamResponse::ok(*session_id, 0, response)
}
}

View file

@ -0,0 +1,70 @@
//! Namespace handler trait and router.
//!
//! Each HFL namespace has a soft-mode handler that implements the
//! `NamespaceHandler` trait. The `NamespaceRouter` dispatches incoming
//! Shellstream messages to the appropriate handler.
pub mod audit;
pub mod attestation;
pub mod crypto;
pub mod governance;
pub mod identity;
pub mod intelligence;
pub mod network;
pub mod secrets;
use std::collections::HashMap;
use std::sync::Arc;
use async_trait::async_trait;
use crate::shellstream::{Namespace, ShellstreamResponse};
/// Trait implemented by each namespace's soft-mode handler.
#[async_trait]
pub trait NamespaceHandler: Send + Sync {
/// Handle a function call within this namespace.
async fn handle(
&self,
function: u16,
payload: &[u8],
session_id: &[u8; 16],
) -> ShellstreamResponse;
}
/// Routes namespace IDs to their handlers.
pub struct NamespaceRouter {
handlers: HashMap<u16, Arc<dyn NamespaceHandler>>,
}
impl NamespaceRouter {
/// Create an empty router (no handlers registered).
pub fn new() -> Self {
Self {
handlers: HashMap::new(),
}
}
/// Register a handler for a namespace.
pub fn register(&mut self, namespace: Namespace, handler: Arc<dyn NamespaceHandler>) {
self.handlers.insert(namespace as u16, handler);
}
/// Dispatch a request to the appropriate namespace handler.
pub async fn handle(
&self,
namespace: Namespace,
function: u16,
payload: &[u8],
session_id: &[u8; 16],
) -> ShellstreamResponse {
match self.handlers.get(&(namespace as u16)) {
Some(handler) => handler.handle(function, payload, session_id).await,
None => ShellstreamResponse::error(
*session_id,
0,
&format!("No handler for namespace {}", namespace.name()),
),
}
}
}

View file

@ -0,0 +1,76 @@
//! NETWORK namespace (0x0007) — soft-mode network classification.
use async_trait::async_trait;
use crate::shellstream::{network, ShellstreamResponse};
use super::NamespaceHandler;
pub struct NetworkHandler;
impl NetworkHandler {
pub fn new() -> Self {
Self
}
}
#[async_trait]
impl NamespaceHandler for NetworkHandler {
async fn handle(
&self,
function_id: u16,
payload: &[u8],
session_id: &[u8; 16],
) -> ShellstreamResponse {
match function_id {
network::CLASSIFY => self.classify(payload, session_id).await,
network::ALLOW => self.allow(payload, session_id).await,
network::DENY => self.deny(payload, session_id).await,
network::SEGMENT_LIST => {
tracing::debug!("SEGMENT_LIST not implemented in soft mode");
ShellstreamResponse::error(*session_id, 0, "SEGMENT_LIST not implemented in soft mode")
},
network::REDIRECT => {
tracing::debug!("REDIRECT not implemented in soft mode");
ShellstreamResponse::error(*session_id, 0, "REDIRECT not implemented in soft mode")
},
_ => ShellstreamResponse::error(
*session_id,
0,
&format!("Unknown NETWORK function: {function_id:#06x}"),
),
}
}
}
impl NetworkHandler {
async fn classify(&self, _payload: &[u8], session_id: &[u8; 16]) -> ShellstreamResponse {
// Soft mode: all traffic classified as "standard"
let response = rmp_serde::to_vec(&serde_json::json!({
"classification": "standard",
"soft_mode": true,
}))
.unwrap_or_default();
ShellstreamResponse::ok(*session_id, 0, response)
}
async fn allow(&self, _payload: &[u8], session_id: &[u8; 16]) -> ShellstreamResponse {
// Soft mode: always allowed (no eBPF enforcement)
let response = rmp_serde::to_vec(&serde_json::json!({
"allowed": true,
"soft_mode": true,
}))
.unwrap_or_default();
ShellstreamResponse::ok(*session_id, 0, response)
}
async fn deny(&self, _payload: &[u8], session_id: &[u8; 16]) -> ShellstreamResponse {
// Soft mode: log intent but no enforcement
let response = rmp_serde::to_vec(&serde_json::json!({
"denied": true,
"enforced": false,
"soft_mode": true,
}))
.unwrap_or_default();
ShellstreamResponse::ok(*session_id, 0, response)
}
}

View file

@ -0,0 +1,71 @@
//! SECRETS namespace (0x0003) — secret retrieval from env or Vault.
use async_trait::async_trait;
use crate::shellstream::{secrets, ShellstreamResponse};
use super::NamespaceHandler;
pub struct SecretsHandler;
impl SecretsHandler {
pub fn new() -> Self {
Self
}
}
#[async_trait]
impl NamespaceHandler for SecretsHandler {
async fn handle(
&self,
function_id: u16,
payload: &[u8],
session_id: &[u8; 16],
) -> ShellstreamResponse {
match function_id {
secrets::GET => self.get(payload, session_id).await,
secrets::PUT => ShellstreamResponse::denied(*session_id, 0, "PUT not supported in soft mode"),
secrets::ROTATE => ShellstreamResponse::denied(*session_id, 0, "ROTATE not supported in soft mode"),
_ => ShellstreamResponse::error(
*session_id,
0,
&format!("Unknown SECRETS function: {function_id:#06x}"),
),
}
}
}
impl SecretsHandler {
async fn get(&self, payload: &[u8], session_id: &[u8; 16]) -> ShellstreamResponse {
let payload_parsed: serde_json::Value =
rmp_serde::from_slice(payload).unwrap_or(serde_json::Value::Null);
let path = payload_parsed
.get("path")
.and_then(|v| v.as_str())
.unwrap_or("");
if path.is_empty() {
return ShellstreamResponse::error(*session_id, 0, "Missing 'path'");
}
// Soft mode: look up environment variable
// Convert path like "db/password" to env var "DB_PASSWORD"
let env_key = path.replace('/', "_").to_uppercase();
match std::env::var(&env_key) {
Ok(value) => {
let response = rmp_serde::to_vec(&serde_json::json!({
"value": value,
"source": "env",
}))
.unwrap_or_default();
ShellstreamResponse::ok(*session_id, 0, response)
}
Err(_) => ShellstreamResponse::error(
*session_id,
0,
&format!("Secret not found: {path} (env: {env_key})"),
),
}
}
}

View file

@ -0,0 +1,152 @@
//! Session tracking for IPC connections.
//!
//! Each Python SDK `SubstrateClient` instance has a UUID session_id.
//! We track the highest nonce seen (replay protection) and rate limiting.
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Instant;
use dashmap::DashMap;
/// Per-session state.
pub struct SessionInfo {
/// Highest nonce seen (monotonic — reject anything ≤ this).
highest_nonce: AtomicU64,
/// Rate limiting: request timestamps in a sliding window.
request_times: std::sync::Mutex<Vec<Instant>>,
}
impl SessionInfo {
fn new() -> Self {
Self {
highest_nonce: AtomicU64::new(0),
request_times: std::sync::Mutex::new(Vec::new()),
}
}
}
/// Concurrent session store backed by DashMap.
pub struct SessionStore {
sessions: DashMap<[u8; 16], SessionInfo>,
/// Max requests per second per session.
rate_limit: u32,
}
impl SessionStore {
pub fn new() -> Self {
Self {
sessions: DashMap::new(),
rate_limit: 1000,
}
}
pub fn with_rate_limit(rate_limit: u32) -> Self {
Self {
sessions: DashMap::new(),
rate_limit,
}
}
/// Ensure a session entry exists for this session_id.
pub fn ensure_session(&self, session_id: [u8; 16]) {
self.sessions
.entry(session_id)
.or_insert_with(SessionInfo::new);
}
/// Validate nonce: must be strictly greater than the last seen nonce.
/// Returns true if valid, false if replay/stale.
pub fn validate_nonce(&self, session_id: [u8; 16], nonce: u64) -> bool {
if let Some(session) = self.sessions.get(&session_id) {
let prev = session.highest_nonce.load(Ordering::Acquire);
if nonce <= prev {
return false;
}
session.highest_nonce.store(nonce, Ordering::Release);
true
} else {
false
}
}
/// Check rate limit for a session. Returns true if under limit.
pub fn check_rate_limit(&self, session_id: [u8; 16]) -> bool {
if let Some(session) = self.sessions.get(&session_id) {
let mut times = session.request_times.lock().unwrap();
let now = Instant::now();
let window = std::time::Duration::from_secs(1);
// Remove requests older than the window
times.retain(|t| now.duration_since(*t) < window);
if times.len() >= self.rate_limit as usize {
return false;
}
times.push(now);
true
} else {
false
}
}
/// Remove a session.
pub fn remove_session(&self, session_id: [u8; 16]) {
self.sessions.remove(&session_id);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_nonce_validation() {
let store = SessionStore::new();
let sid = [0x01; 16];
store.ensure_session(sid);
// First nonce accepted
assert!(store.validate_nonce(sid, 1));
// Higher nonce accepted
assert!(store.validate_nonce(sid, 5));
// Same nonce rejected (replay)
assert!(!store.validate_nonce(sid, 5));
// Lower nonce rejected
assert!(!store.validate_nonce(sid, 3));
// Higher nonce accepted again
assert!(store.validate_nonce(sid, 6));
}
#[test]
fn test_rate_limit() {
let store = SessionStore::with_rate_limit(3);
let sid = [0x02; 16];
store.ensure_session(sid);
// First 3 should pass
assert!(store.check_rate_limit(sid));
assert!(store.check_rate_limit(sid));
assert!(store.check_rate_limit(sid));
// 4th should be rejected
assert!(!store.check_rate_limit(sid));
}
#[test]
fn test_session_not_found() {
let store = SessionStore::new();
let sid = [0x03; 16];
// No session created — nonce validation fails
assert!(!store.validate_nonce(sid, 1));
assert!(!store.check_rate_limit(sid));
}
#[test]
fn test_ensure_session_idempotent() {
let store = SessionStore::new();
let sid = [0x04; 16];
store.ensure_session(sid);
store.ensure_session(sid); // should not reset nonce
assert!(store.validate_nonce(sid, 1));
}
}

View file

@ -0,0 +1,420 @@
//! Shellstream wire protocol codec — msgpack over Unix domain socket.
//!
//! Wire format must match `substrate-sdk-python/substrate_sdk/protocol.py` byte-for-byte.
//! Encoding/decoding delegates to `substrate_rt::ShellstreamCodec` (canonical implementation).
//!
//! Request: [4-byte BE length] [msgpack array: [namespace, function, session_id, payload, nonce]]
//! Response: [4-byte BE length] [msgpack array: [status, session_id, payload, nonce]]
use serde::Deserialize;
use substrate_rt::ShellstreamCodec;
/// HFL namespace identifiers (0x00010x0008).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[repr(u16)]
pub enum Namespace {
Crypto = 0x0001,
Identity = 0x0002,
Secrets = 0x0003,
Governance = 0x0004,
Attestation = 0x0005,
Audit = 0x0006,
Network = 0x0007,
Intelligence = 0x0008,
}
impl Namespace {
pub fn from_u16(v: u16) -> Option<Self> {
match v {
0x0001 => Some(Self::Crypto),
0x0002 => Some(Self::Identity),
0x0003 => Some(Self::Secrets),
0x0004 => Some(Self::Governance),
0x0005 => Some(Self::Attestation),
0x0006 => Some(Self::Audit),
0x0007 => Some(Self::Network),
0x0008 => Some(Self::Intelligence),
_ => None,
}
}
pub fn name(&self) -> &'static str {
match self {
Self::Crypto => "CRYPTO",
Self::Identity => "IDENTITY",
Self::Secrets => "SECRETS",
Self::Governance => "GOVERNANCE",
Self::Attestation => "ATTESTATION",
Self::Audit => "AUDIT",
Self::Network => "NETWORK",
Self::Intelligence => "INTELLIGENCE",
}
}
}
/// Canonical HFL function IDs — re-exported from hfl-types::ids (single source of truth).
/// See hfl-types::ids for canonical constants. Intelligence namespace (0x0008) is a
/// bascule-agent extension, gated behind the `agent-extensions` feature.
pub use hfl_types::ids::{
crypto, identity, secrets, governance,
attestation, audit, network, intelligence,
};
/// Response status codes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u16)]
pub enum Status {
Ok = 0x00,
Error = 0x01,
Denied = 0x02,
CeremonyRequired = 0x03,
}
impl Status {
pub fn from_u16(v: u16) -> Option<Self> {
match v {
0x00 => Some(Self::Ok),
0x01 => Some(Self::Error),
0x02 => Some(Self::Denied),
0x03 => Some(Self::CeremonyRequired),
_ => None,
}
}
}
/// Incoming request from Python SDK.
#[derive(Debug, Clone)]
pub struct ShellstreamMessage {
pub namespace: u16,
pub function: u16,
pub session_id: [u8; 16],
pub payload: Vec<u8>,
pub nonce: u64,
}
/// Outgoing response to Python SDK.
#[derive(Debug, Clone)]
pub struct ShellstreamResponse {
pub status: u16,
pub session_id: [u8; 16],
pub payload: Vec<u8>,
pub nonce: u64,
}
impl ShellstreamMessage {
/// Decode from msgpack body (without length prefix).
pub fn decode(data: &[u8]) -> anyhow::Result<Self> {
let (namespace, function, session_id, payload, nonce) =
ShellstreamCodec::decode_request(data)
.map_err(|e| anyhow::anyhow!("{e}"))?;
Ok(Self {
namespace,
function,
session_id,
payload,
nonce,
})
}
}
impl ShellstreamResponse {
/// Encode to wire format (length-prefixed msgpack).
pub fn encode(&self) -> Vec<u8> {
ShellstreamCodec::encode_response(self.status, &self.session_id, &self.payload, self.nonce)
.expect("msgpack serialize")
}
/// Create an OK response.
pub fn ok(session_id: [u8; 16], nonce: u64, payload: Vec<u8>) -> Self {
Self {
status: Status::Ok as u16,
session_id,
payload,
nonce,
}
}
/// Create an error response.
pub fn error(session_id: [u8; 16], nonce: u64, message: &str) -> Self {
let payload = rmp_serde::to_vec(&serde_json::json!({"message": message}))
.unwrap_or_default();
Self {
status: Status::Error as u16,
session_id,
payload,
nonce,
}
}
/// Create a denied response.
pub fn denied(session_id: [u8; 16], nonce: u64, message: &str) -> Self {
let payload = rmp_serde::to_vec(&serde_json::json!({"message": message}))
.unwrap_or_default();
Self {
status: Status::Denied as u16,
session_id,
payload,
nonce,
}
}
/// Create a ceremony-required response.
pub fn ceremony_required(
session_id: [u8; 16],
nonce: u64,
ceremony_id: &str,
ceremony_type: &str,
) -> Self {
let payload = rmp_serde::to_vec(&serde_json::json!({
"ceremony_id": ceremony_id,
"ceremony_type": ceremony_type,
}))
.unwrap_or_default();
Self {
status: Status::CeremonyRequired as u16,
session_id,
payload,
nonce,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Debug, Deserialize)]
struct TestVectors {
namespace_ids: std::collections::HashMap<String, u16>,
status_codes: std::collections::HashMap<String, u16>,
vectors: Vec<TestVector>,
}
#[derive(Debug, Deserialize)]
struct TestVector {
name: String,
#[serde(rename = "type")]
vec_type: String,
#[serde(default)]
namespace: Option<u16>,
#[serde(default)]
function: Option<u16>,
#[serde(default)]
status: Option<u16>,
session_id_hex: String,
payload_hex: String,
nonce: u64,
body_hex: String,
full_encoded_hex: String,
}
fn load_vectors() -> TestVectors {
let data = include_str!("../tests/fixtures/shellstream_vectors.json");
serde_json::from_str(data).expect("Failed to parse test vectors")
}
#[test]
fn test_namespace_ids_match_python() {
let vectors = load_vectors();
assert_eq!(vectors.namespace_ids["CRYPTO"], Namespace::Crypto as u16);
assert_eq!(vectors.namespace_ids["IDENTITY"], Namespace::Identity as u16);
assert_eq!(vectors.namespace_ids["SECRETS"], Namespace::Secrets as u16);
assert_eq!(vectors.namespace_ids["GOVERNANCE"], Namespace::Governance as u16);
assert_eq!(vectors.namespace_ids["ATTESTATION"], Namespace::Attestation as u16);
assert_eq!(vectors.namespace_ids["AUDIT"], Namespace::Audit as u16);
assert_eq!(vectors.namespace_ids["NETWORK"], Namespace::Network as u16);
assert_eq!(vectors.namespace_ids["INTELLIGENCE"], Namespace::Intelligence as u16);
}
#[test]
fn test_status_codes_match_python() {
let vectors = load_vectors();
assert_eq!(vectors.status_codes["OK"], Status::Ok as u16);
assert_eq!(vectors.status_codes["ERROR"], Status::Error as u16);
assert_eq!(vectors.status_codes["DENIED"], Status::Denied as u16);
assert_eq!(vectors.status_codes["CEREMONY_REQUIRED"], Status::CeremonyRequired as u16);
}
#[test]
fn test_decode_all_request_vectors() {
let vectors = load_vectors();
for v in &vectors.vectors {
if v.vec_type != "request" {
continue;
}
let body = hex::decode(&v.body_hex).unwrap_or_else(|e| {
panic!("Failed to decode hex for {}: {}", v.name, e)
});
let msg = ShellstreamMessage::decode(&body).unwrap_or_else(|e| {
panic!("Failed to decode request '{}': {}", v.name, e)
});
assert_eq!(
msg.namespace,
v.namespace.unwrap(),
"namespace mismatch for {}",
v.name
);
assert_eq!(
msg.function,
v.function.unwrap(),
"function mismatch for {}",
v.name
);
assert_eq!(
hex::encode(msg.session_id),
v.session_id_hex,
"session_id mismatch for {}",
v.name
);
assert_eq!(
hex::encode(&msg.payload),
v.payload_hex,
"payload mismatch for {}",
v.name
);
assert_eq!(msg.nonce, v.nonce, "nonce mismatch for {}", v.name);
}
}
#[test]
fn test_decode_response_vectors() {
use substrate_rt::ShellstreamCodec;
let vectors = load_vectors();
for v in &vectors.vectors {
if v.vec_type != "response" {
continue;
}
let body = hex::decode(&v.body_hex).unwrap();
let (status, session_id, payload, nonce) =
ShellstreamCodec::decode_response(&body).unwrap();
assert_eq!(status, v.status.unwrap(), "status mismatch for {}", v.name);
assert_eq!(
hex::encode(session_id),
v.session_id_hex,
"session_id mismatch for {}",
v.name
);
assert_eq!(
hex::encode(&payload),
v.payload_hex,
"payload mismatch for {}",
v.name
);
assert_eq!(nonce, v.nonce, "nonce mismatch for {}", v.name);
}
}
#[test]
fn test_encode_response_matches_python() {
let vectors = load_vectors();
for v in &vectors.vectors {
if v.vec_type != "response" {
continue;
}
let mut session_id = [0u8; 16];
let sid_bytes = hex::decode(&v.session_id_hex).unwrap();
session_id.copy_from_slice(&sid_bytes);
let payload = hex::decode(&v.payload_hex).unwrap();
let resp = ShellstreamResponse {
status: v.status.unwrap(),
session_id,
payload,
nonce: v.nonce,
};
let encoded = resp.encode();
assert_eq!(
hex::encode(&encoded),
v.full_encoded_hex,
"encoded response mismatch for {}",
v.name
);
}
}
#[test]
fn test_namespace_roundtrip() {
for ns_val in 1u16..=8 {
let ns = Namespace::from_u16(ns_val).unwrap();
assert_eq!(ns as u16, ns_val);
}
assert!(Namespace::from_u16(0).is_none());
assert!(Namespace::from_u16(9).is_none());
}
#[test]
fn test_status_roundtrip() {
for s_val in 0u16..=3 {
let s = Status::from_u16(s_val).unwrap();
assert_eq!(s as u16, s_val);
}
assert!(Status::from_u16(4).is_none());
}
#[test]
fn test_edge_case_empty_payload() {
let vectors = load_vectors();
let v = vectors
.vectors
.iter()
.find(|v| v.name == "empty_payload")
.expect("empty_payload vector not found");
let body = hex::decode(&v.body_hex).unwrap();
let msg = ShellstreamMessage::decode(&body).unwrap();
assert_eq!(msg.nonce, 0);
// Empty dict {} in msgpack is 0x80
assert_eq!(hex::encode(&msg.payload), "80");
}
#[test]
fn test_edge_case_max_nonce() {
let vectors = load_vectors();
let v = vectors
.vectors
.iter()
.find(|v| v.name == "max_nonce")
.expect("max_nonce vector not found");
let body = hex::decode(&v.body_hex).unwrap();
let msg = ShellstreamMessage::decode(&body).unwrap();
assert_eq!(msg.nonce, u64::MAX);
}
#[test]
fn test_length_prefix_big_endian() {
let vectors = load_vectors();
let v = &vectors.vectors[0]; // any vector
let full = hex::decode(&v.full_encoded_hex).unwrap();
let body = hex::decode(&v.body_hex).unwrap();
// First 4 bytes are big-endian body length
let len_bytes: [u8; 4] = full[..4].try_into().unwrap();
let length = u32::from_be_bytes(len_bytes);
assert_eq!(length as usize, body.len());
}
#[test]
fn test_response_convenience_constructors() {
let sid = [0xABu8; 16];
let ok = ShellstreamResponse::ok(sid, 1, vec![0x80]);
assert_eq!(ok.status, Status::Ok as u16);
assert_eq!(ok.session_id, sid);
assert_eq!(ok.nonce, 1);
let err = ShellstreamResponse::error(sid, 2, "boom");
assert_eq!(err.status, Status::Error as u16);
let denied = ShellstreamResponse::denied(sid, 3, "nope");
assert_eq!(denied.status, Status::Denied as u16);
let cer = ShellstreamResponse::ceremony_required(sid, 4, "cer-1", "SingleApproval");
assert_eq!(cer.status, Status::CeremonyRequired as u16);
}
}

View file

@ -0,0 +1,511 @@
//! SSH shell server — interactive governed shell sessions.
//!
//! Provides an SSH server that routes commands through the namespace
//! handlers. In dev/soft mode, accepts any public key authentication.
use std::sync::Arc;
use async_trait::async_trait;
use russh::server::{Auth, Config, Handler, Msg, Server, Session};
use russh::{Channel, ChannelId};
use ssh_key::{Algorithm, PrivateKey};
use tracing::{debug, error, info, warn};
use crate::config::AgentConfig;
use crate::namespace::NamespaceRouter;
use crate::shellstream::{Namespace, ShellstreamResponse, Status};
/// The bascule-agent SSH server.
pub struct AgentSSHServer {
config: Arc<AgentConfig>,
russh_config: Arc<Config>,
router: Arc<NamespaceRouter>,
}
impl AgentSSHServer {
pub fn new(
config: Arc<AgentConfig>,
router: Arc<NamespaceRouter>,
) -> anyhow::Result<Self> {
// Load or generate host key
let host_key = if let Some(ref path) = config.agent.ssh.host_key_path {
if path.exists() {
info!(path = %path.display(), "Loading SSH host key");
russh_keys::load_secret_key(path, None)?
} else {
info!("Generating ephemeral Ed25519 host key");
let mut rng = rand::thread_rng();
PrivateKey::random(&mut rng, Algorithm::Ed25519)?
}
} else {
info!("Generating ephemeral Ed25519 host key");
let mut rng = rand::thread_rng();
PrivateKey::random(&mut rng, Algorithm::Ed25519)?
};
let russh_config = Config {
keys: vec![host_key],
..Default::default()
};
Ok(Self {
config,
russh_config: Arc::new(russh_config),
router,
})
}
/// Start the SSH server. Runs until cancelled.
pub async fn serve(mut self) -> anyhow::Result<()> {
let addr = &self.config.agent.ssh.listen_addr;
let listen: std::net::SocketAddr = addr
.parse()
.map_err(|e| anyhow::anyhow!("Invalid SSH listen address '{}': {}", addr, e))?;
info!(addr = %listen, "SSH shell server listening");
self.run_on_address(self.russh_config.clone(), listen).await?;
Ok(())
}
}
impl Server for AgentSSHServer {
type Handler = AgentShellHandler;
fn new_client(&mut self, peer_addr: Option<std::net::SocketAddr>) -> AgentShellHandler {
let addr = peer_addr
.map(|a| a.to_string())
.unwrap_or_else(|| "unknown".to_string());
info!(peer = %addr, "New SSH connection");
AgentShellHandler {
router: self.router.clone(),
dev_mode: self.config.agent.namespaces.backend == "soft",
peer_addr: addr,
identity: None,
terminal_width: 80,
session_state: None,
}
}
}
/// Per-connection SSH handler.
pub struct AgentShellHandler {
router: Arc<NamespaceRouter>,
dev_mode: bool,
peer_addr: String,
identity: Option<String>,
terminal_width: u16,
session_state: Option<ShellSessionState>,
}
struct ShellSessionState {
input_buffer: String,
channel_id: ChannelId,
session_id: [u8; 16],
}
impl AgentShellHandler {
fn banner(&self) -> String {
let identity = self.identity.as_deref().unwrap_or("unknown");
format!(
"\r\n\x1b[1;36m╔══════════════════════════════════════════╗\r\n\
bascule-agent governed shell \r\n\
\x1b[0m\r\n\
\r\n\
Identity: {identity}\r\n\
Mode: {mode}\r\n\
\r\n\
Type 'help' for available commands.\r\n\r\n",
mode = if self.dev_mode { "dev (soft)" } else { "live" },
)
}
fn prompt(&self) -> String {
let identity = self.identity.as_deref().unwrap_or("?");
format!("\x1b[1;32m{identity}\x1b[0m@\x1b[1;34magent\x1b[0m> ")
}
async fn process_line(
&self,
line: &str,
handle: &russh::server::Handle,
channel_id: ChannelId,
session_id: &[u8; 16],
) -> bool {
let line = line.trim();
if line == "exit" || line == "quit" {
let _ = handle.data(channel_id, "Goodbye.\r\n".into()).await;
let _ = handle.close(channel_id).await;
return true;
}
if line.is_empty() {
let _ = handle.data(channel_id, self.prompt().into()).await;
return false;
}
let output = match line {
"help" => self.cmd_help(),
"status" => self.cmd_status().await,
"whoami" => self.cmd_whoami(),
cmd if cmd.starts_with("audit ") => {
self.cmd_namespace("audit", &cmd[6..], session_id).await
}
cmd if cmd.starts_with("crypto ") => {
self.cmd_namespace("crypto", &cmd[7..], session_id).await
}
cmd if cmd.starts_with("identity ") => {
self.cmd_namespace("identity", &cmd[9..], session_id).await
}
cmd if cmd.starts_with("governance ") => {
self.cmd_namespace("governance", &cmd[11..], session_id).await
}
cmd if cmd.starts_with("secrets ") => {
self.cmd_namespace("secrets", &cmd[8..], session_id).await
}
cmd if cmd.starts_with("attestation ") => {
self.cmd_namespace("attestation", &cmd[12..], session_id).await
}
_ => format!("Unknown command: {}. Type 'help' for available commands.\r\n", line),
};
if !output.is_empty() {
let _ = handle.data(channel_id, output.into()).await;
}
let _ = handle.data(channel_id, self.prompt().into()).await;
false
}
fn cmd_help(&self) -> String {
"\x1b[1mAvailable commands:\x1b[0m\r\n\
\r\n\
\x1b[33mShell:\x1b[0m\r\n\
\x20 help Show this help message\r\n\
\x20 status Show agent status\r\n\
\x20 whoami Show current identity\r\n\
\x20 exit Close the session\r\n\
\r\n\
\x1b[33mNamespaces:\x1b[0m\r\n\
\x20 audit emit Emit an audit event\r\n\
\x20 attestation posture Show posture level\r\n\
\x20 crypto hash <data> Hash data with SHA-256\r\n\
\x20 governance gate Check governance gate\r\n\
\x20 identity whoami Show identity claims\r\n\
\x20 secrets get <path> Look up a secret\r\n\
\r\n"
.to_string()
}
async fn cmd_status(&self) -> String {
// Query attestation posture through namespace handler
let payload = rmp_serde::to_vec(&serde_json::json!({})).unwrap_or_default();
let session_id = [0u8; 16];
let resp = self
.router
.handle(Namespace::Attestation, 0x01, &payload, &session_id)
.await;
let posture: serde_json::Value =
rmp_serde::from_slice(&resp.payload).unwrap_or(serde_json::Value::Null);
format!(
"\x1b[1mAgent Status\x1b[0m\r\n\
\x20 Mode: {mode}\r\n\
\x20 Posture: {posture}\r\n\
\x20 Peer: {peer}\r\n\r\n",
mode = if self.dev_mode { "soft (dev)" } else { "live" },
posture = posture.get("level").and_then(|v| v.as_str()).unwrap_or("unknown"),
peer = self.peer_addr,
)
}
fn cmd_whoami(&self) -> String {
format!(
"Identity: {}\r\nMode: {}\r\nPeer: {}\r\n",
self.identity.as_deref().unwrap_or("unknown"),
if self.dev_mode { "dev" } else { "live" },
self.peer_addr,
)
}
async fn cmd_namespace(
&self,
ns_name: &str,
args: &str,
session_id: &[u8; 16],
) -> String {
let ns = match ns_name {
"audit" => Namespace::Audit,
"crypto" => Namespace::Crypto,
"identity" => Namespace::Identity,
"governance" => Namespace::Governance,
"secrets" => Namespace::Secrets,
"attestation" => Namespace::Attestation,
_ => return format!("Unknown namespace: {ns_name}\r\n"),
};
let (function_id, payload) = match parse_namespace_command(ns_name, args) {
Ok(v) => v,
Err(e) => return format!("Error: {e}\r\n"),
};
let resp = self.router.handle(ns, function_id, &payload, session_id).await;
let status = Status::from_u16(resp.status);
let payload_parsed: serde_json::Value =
rmp_serde::from_slice(&resp.payload).unwrap_or(serde_json::Value::Null);
let status_str = match status {
Some(Status::Ok) => "\x1b[32mOK\x1b[0m",
Some(Status::Error) => "\x1b[31mERROR\x1b[0m",
Some(Status::Denied) => "\x1b[31mDENIED\x1b[0m",
Some(Status::CeremonyRequired) => "\x1b[33mCEREMONY_REQUIRED\x1b[0m",
None => "UNKNOWN",
};
format!(
"[{status_str}] {}\r\n",
serde_json::to_string_pretty(&payload_parsed).unwrap_or_else(|_| "{}".to_string())
)
.replace('\n', "\r\n")
}
}
/// Parse a namespace subcommand into (function_id, msgpack payload).
fn parse_namespace_command(ns: &str, args: &str) -> anyhow::Result<(u16, Vec<u8>)> {
let parts: Vec<&str> = args.splitn(2, ' ').collect();
let subcmd = parts.first().copied().unwrap_or("");
let rest = parts.get(1).copied().unwrap_or("");
match (ns, subcmd) {
("audit", "emit") => {
let payload = serde_json::json!({
"event_type": "shell.command",
"subject": "ssh-user",
"resource": "shell",
"action": "emit",
"outcome": "success",
});
Ok((0x01, rmp_serde::to_vec(&payload)?))
}
("audit", "anchor") => {
Ok((0x02, rmp_serde::to_vec(&serde_json::json!({"event_ids": []}))?))
}
("crypto", "hash") => {
let data = if rest.is_empty() { "test" } else { rest };
Ok((0x03, rmp_serde::to_vec(&serde_json::json!({"data": data}))?))
}
("identity", "whoami") | ("identity", "authenticate") => {
Ok((0x01, rmp_serde::to_vec(&serde_json::json!({"token": "ssh-session"}))?))
}
("governance", "gate") => {
Ok((0x01, rmp_serde::to_vec(&serde_json::json!({
"subject": "ssh-user",
"resource": "shell",
"action": "access",
}))?))
}
("secrets", "get") => {
let path = if rest.is_empty() { "test/secret" } else { rest };
Ok((0x01, rmp_serde::to_vec(&serde_json::json!({"path": path}))?))
}
("attestation", "posture") => {
Ok((0x01, rmp_serde::to_vec(&serde_json::json!({}))?))
}
("attestation", "sat") => {
Ok((0x02, rmp_serde::to_vec(&serde_json::json!({}))?))
}
_ => anyhow::bail!("Unknown {ns} subcommand: {subcmd}"),
}
}
#[async_trait]
impl Handler for AgentShellHandler {
type Error = anyhow::Error;
async fn auth_publickey(
&mut self,
user: &str,
public_key: &ssh_key::PublicKey,
) -> Result<Auth, Self::Error> {
let fingerprint = public_key.fingerprint(ssh_key::HashAlg::Sha256).to_string();
if self.dev_mode {
// Dev mode: accept any key
info!(user = %user, fingerprint = %fingerprint, "SSH auth accepted (dev mode)");
self.identity = Some(user.to_string());
Ok(Auth::Accept)
} else {
// TODO: validate against authorized keys store
warn!(fingerprint = %fingerprint, "SSH auth rejected: live mode not implemented");
Ok(Auth::Reject {
proceed_with_methods: None,
})
}
}
async fn auth_password(
&mut self,
user: &str,
_password: &str,
) -> Result<Auth, Self::Error> {
if self.dev_mode {
info!(user = %user, "SSH password auth accepted (dev mode)");
self.identity = Some(user.to_string());
Ok(Auth::Accept)
} else {
Ok(Auth::Reject {
proceed_with_methods: None,
})
}
}
async fn channel_open_session(
&mut self,
_channel: Channel<Msg>,
_session: &mut Session,
) -> Result<bool, Self::Error> {
Ok(true)
}
async fn pty_request(
&mut self,
_channel: ChannelId,
_term: &str,
col_width: u32,
_row_height: u32,
_pix_width: u32,
_pix_height: u32,
_modes: &[(russh::Pty, u32)],
session: &mut Session,
) -> Result<(), Self::Error> {
self.terminal_width = col_width.min(200) as u16;
session.request_success();
Ok(())
}
async fn shell_request(
&mut self,
channel: ChannelId,
session: &mut Session,
) -> Result<(), Self::Error> {
session.request_success();
let session_id: [u8; 16] = uuid::Uuid::new_v4().into_bytes();
let banner = self.banner();
let prompt = self.prompt();
session.data(channel, format!("{banner}{prompt}").into())?;
self.session_state = Some(ShellSessionState {
input_buffer: String::new(),
channel_id: channel,
session_id,
});
Ok(())
}
async fn data(
&mut self,
channel: ChannelId,
data: &[u8],
session: &mut Session,
) -> Result<(), Self::Error> {
// Take session state temporarily to avoid borrow conflicts
let mut state = match self.session_state.take() {
Some(s) => s,
None => return Ok(()),
};
for &byte in data {
match byte {
// Enter
b'\r' | b'\n' => {
session.data(channel, "\r\n".into())?;
let line = state.input_buffer.clone();
state.input_buffer.clear();
let handle = session.handle();
let sid = state.session_id;
if self.process_line(&line, &handle, channel, &sid).await {
// Session ended — don't put state back
return Ok(());
}
}
// Backspace / DEL
0x7f | 0x08 => {
if !state.input_buffer.is_empty() {
state.input_buffer.pop();
session.data(channel, "\x08 \x08".into())?;
}
}
// Ctrl-C
0x03 => {
state.input_buffer.clear();
session.data(channel, "^C\r\n".into())?;
let prompt = self.prompt();
session.data(channel, prompt.into())?;
}
// Ctrl-D (EOF)
0x04 => {
session.data(channel, "\r\nGoodbye.\r\n".into())?;
session.close(channel)?;
return Ok(());
}
// Printable characters
b if b >= 0x20 && b < 0x7f => {
state.input_buffer.push(b as char);
session.data(channel, vec![b].into())?;
}
// Ignore other control characters
_ => {}
}
}
// Put session state back
self.session_state = Some(state);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_namespace_commands() {
let (fn_id, _) = parse_namespace_command("audit", "emit").unwrap();
assert_eq!(fn_id, 0x01);
let (fn_id, _) = parse_namespace_command("crypto", "hash test data").unwrap();
assert_eq!(fn_id, 0x03);
let (fn_id, _) = parse_namespace_command("governance", "gate").unwrap();
assert_eq!(fn_id, 0x01);
let (fn_id, _) = parse_namespace_command("attestation", "posture").unwrap();
assert_eq!(fn_id, 0x01);
let (fn_id, _) = parse_namespace_command("secrets", "get db/password").unwrap();
assert_eq!(fn_id, 0x01);
assert!(parse_namespace_command("audit", "nonexistent").is_err());
}
#[test]
fn test_parse_crypto_hash_payload() {
let (_, payload) = parse_namespace_command("crypto", "hash hello world").unwrap();
let parsed: serde_json::Value = rmp_serde::from_slice(&payload).unwrap();
assert_eq!(parsed["data"], "hello world");
}
#[test]
fn test_parse_secrets_get_path() {
let (_, payload) = parse_namespace_command("secrets", "get db/password").unwrap();
let parsed: serde_json::Value = rmp_serde::from_slice(&payload).unwrap();
assert_eq!(parsed["path"], "db/password");
}
}

View file

@ -0,0 +1,12 @@
# Test config for E2E verification — Python SDK ↔ Rust bascule-agent
[agent]
socket_path = "/tmp/bascule-agent-e2e.sock"
[agent.ssh]
enabled = false
[agent.namespaces]
backend = "soft"
[agent.namespaces.attestation]
default_posture = "normal"

View file

@ -0,0 +1,227 @@
{
"generator": "substrate-sdk-python",
"protocol_version": "1.0",
"namespace_ids": {
"CRYPTO": 1,
"IDENTITY": 2,
"SECRETS": 3,
"GOVERNANCE": 4,
"ATTESTATION": 5,
"AUDIT": 6,
"NETWORK": 7,
"INTELLIGENCE": 8
},
"status_codes": {
"OK": 0,
"ERROR": 1,
"DENIED": 2,
"CEREMONY_REQUIRED": 3
},
"vectors": [
{
"name": "crypto_sign",
"type": "request",
"namespace": 1,
"function": 1,
"session_id_hex": "0102030405060708090a0b0c0d0e0f10",
"payload_hex": "83a66b65795f6964a96167656e742d6b6579a464617461a8614756736247383da9616c676f726974686da765643235353139",
"nonce": 42,
"body_hex": "950101c4100102030405060708090a0b0c0d0e0f10c43283a66b65795f6964a96167656e742d6b6579a464617461a8614756736247383da9616c676f726974686da7656432353531392a",
"full_encoded_hex": "0000004a950101c4100102030405060708090a0b0c0d0e0f10c43283a66b65795f6964a96167656e742d6b6579a464617461a8614756736247383da9616c676f726974686da7656432353531392a"
},
{
"name": "crypto_verify",
"type": "request",
"namespace": 1,
"function": 2,
"session_id_hex": "0102030405060708090a0b0c0d0e0f10",
"payload_hex": "84a66b65795f6964a96167656e742d6b6579a464617461a8614756736247383da97369676e6174757265a463326c6ea9616c676f726974686da765643235353139",
"nonce": 42,
"body_hex": "950102c4100102030405060708090a0b0c0d0e0f10c44184a66b65795f6964a96167656e742d6b6579a464617461a8614756736247383da97369676e6174757265a463326c6ea9616c676f726974686da7656432353531392a",
"full_encoded_hex": "00000059950102c4100102030405060708090a0b0c0d0e0f10c44184a66b65795f6964a96167656e742d6b6579a464617461a8614756736247383da97369676e6174757265a463326c6ea9616c676f726974686da7656432353531392a"
},
{
"name": "identity_auth",
"type": "request",
"namespace": 2,
"function": 1,
"session_id_hex": "0102030405060708090a0b0c0d0e0f10",
"payload_hex": "81a5746f6b656ea665794a2e2e2e",
"nonce": 42,
"body_hex": "950201c4100102030405060708090a0b0c0d0e0f10c40e81a5746f6b656ea665794a2e2e2e2a",
"full_encoded_hex": "00000026950201c4100102030405060708090a0b0c0d0e0f10c40e81a5746f6b656ea665794a2e2e2e2a"
},
{
"name": "identity_authz",
"type": "request",
"namespace": 2,
"function": 2,
"session_id_hex": "0102030405060708090a0b0c0d0e0f10",
"payload_hex": "83a77375626a656374a574796c6572a87265736f75726365ab666c6565742f6e6f646573a6616374696f6ea472656164",
"nonce": 42,
"body_hex": "950202c4100102030405060708090a0b0c0d0e0f10c43083a77375626a656374a574796c6572a87265736f75726365ab666c6565742f6e6f646573a6616374696f6ea4726561642a",
"full_encoded_hex": "00000048950202c4100102030405060708090a0b0c0d0e0f10c43083a77375626a656374a574796c6572a87265736f75726365ab666c6565742f6e6f646573a6616374696f6ea4726561642a"
},
{
"name": "secrets_get",
"type": "request",
"namespace": 3,
"function": 1,
"session_id_hex": "0102030405060708090a0b0c0d0e0f10",
"payload_hex": "81a470617468ab64622f70617373776f7264",
"nonce": 42,
"body_hex": "950301c4100102030405060708090a0b0c0d0e0f10c41281a470617468ab64622f70617373776f72642a",
"full_encoded_hex": "0000002a950301c4100102030405060708090a0b0c0d0e0f10c41281a470617468ab64622f70617373776f72642a"
},
{
"name": "governance_gate",
"type": "request",
"namespace": 4,
"function": 1,
"session_id_hex": "0102030405060708090a0b0c0d0e0f10",
"payload_hex": "83a77375626a656374a574796c6572a87265736f75726365ab666c6565742f6e6f646573a6616374696f6ea66d7574617465",
"nonce": 42,
"body_hex": "950401c4100102030405060708090a0b0c0d0e0f10c43283a77375626a656374a574796c6572a87265736f75726365ab666c6565742f6e6f646573a6616374696f6ea66d75746174652a",
"full_encoded_hex": "0000004a950401c4100102030405060708090a0b0c0d0e0f10c43283a77375626a656374a574796c6572a87265736f75726365ab666c6565742f6e6f646573a6616374696f6ea66d75746174652a"
},
{
"name": "governance_propose",
"type": "request",
"namespace": 4,
"function": 2,
"session_id_hex": "0102030405060708090a0b0c0d0e0f10",
"payload_hex": "82ad636572656d6f6e795f74797065ae53696e676c65417070726f76616ca77061796c6f616481a6696e74656e74aa7363616c652d646f776e",
"nonce": 42,
"body_hex": "950402c4100102030405060708090a0b0c0d0e0f10c43982ad636572656d6f6e795f74797065ae53696e676c65417070726f76616ca77061796c6f616481a6696e74656e74aa7363616c652d646f776e2a",
"full_encoded_hex": "00000051950402c4100102030405060708090a0b0c0d0e0f10c43982ad636572656d6f6e795f74797065ae53696e676c65417070726f76616ca77061796c6f616481a6696e74656e74aa7363616c652d646f776e2a"
},
{
"name": "attestation_posture",
"type": "request",
"namespace": 5,
"function": 1,
"session_id_hex": "0102030405060708090a0b0c0d0e0f10",
"payload_hex": "80",
"nonce": 42,
"body_hex": "950501c4100102030405060708090a0b0c0d0e0f10c401802a",
"full_encoded_hex": "00000019950501c4100102030405060708090a0b0c0d0e0f10c401802a"
},
{
"name": "attestation_sat",
"type": "request",
"namespace": 5,
"function": 2,
"session_id_hex": "0102030405060708090a0b0c0d0e0f10",
"payload_hex": "80",
"nonce": 42,
"body_hex": "950502c4100102030405060708090a0b0c0d0e0f10c401802a",
"full_encoded_hex": "00000019950502c4100102030405060708090a0b0c0d0e0f10c401802a"
},
{
"name": "audit_emit",
"type": "request",
"namespace": 6,
"function": 1,
"session_id_hex": "0102030405060708090a0b0c0d0e0f10",
"payload_hex": "85aa6576656e745f74797065ab6170692e72657175657374a77375626a656374a574796c6572a87265736f75726365ae2f6170692f76312f666c6565742fa6616374696f6ea46c697374a76f7574636f6d65a773756363657373",
"nonce": 42,
"body_hex": "950601c4100102030405060708090a0b0c0d0e0f10c45a85aa6576656e745f74797065ab6170692e72657175657374a77375626a656374a574796c6572a87265736f75726365ae2f6170692f76312f666c6565742fa6616374696f6ea46c697374a76f7574636f6d65a7737563636573732a",
"full_encoded_hex": "00000072950601c4100102030405060708090a0b0c0d0e0f10c45a85aa6576656e745f74797065ab6170692e72657175657374a77375626a656374a574796c6572a87265736f75726365ae2f6170692f76312f666c6565742fa6616374696f6ea46c697374a76f7574636f6d65a7737563636573732a"
},
{
"name": "audit_anchor",
"type": "request",
"namespace": 6,
"function": 2,
"session_id_hex": "0102030405060708090a0b0c0d0e0f10",
"payload_hex": "81a96576656e745f69647392a76576742d303031a76576742d303032",
"nonce": 42,
"body_hex": "950602c4100102030405060708090a0b0c0d0e0f10c41c81a96576656e745f69647392a76576742d303031a76576742d3030322a",
"full_encoded_hex": "00000034950602c4100102030405060708090a0b0c0d0e0f10c41c81a96576656e745f69647392a76576742d303031a76576742d3030322a"
},
{
"name": "network_classify",
"type": "request",
"namespace": 7,
"function": 1,
"session_id_hex": "0102030405060708090a0b0c0d0e0f10",
"payload_hex": "84a6736f75726365a831302e302e302e31ab64657374696e6174696f6ea831302e302e302e32a4706f7274cd1538a870726f746f636f6ca3746370",
"nonce": 42,
"body_hex": "950701c4100102030405060708090a0b0c0d0e0f10c43b84a6736f75726365a831302e302e302e31ab64657374696e6174696f6ea831302e302e302e32a4706f7274cd1538a870726f746f636f6ca37463702a",
"full_encoded_hex": "00000053950701c4100102030405060708090a0b0c0d0e0f10c43b84a6736f75726365a831302e302e302e31ab64657374696e6174696f6ea831302e302e302e32a4706f7274cd1538a870726f746f636f6ca37463702a"
},
{
"name": "intelligence_infer",
"type": "request",
"namespace": 8,
"function": 1,
"session_id_hex": "0102030405060708090a0b0c0d0e0f10",
"payload_hex": "82a56d6f64656cb8636c617564652d736f6e6e65742d342d3230323530353134a670726f6d7074a548656c6c6f",
"nonce": 42,
"body_hex": "950801c4100102030405060708090a0b0c0d0e0f10c42d82a56d6f64656cb8636c617564652d736f6e6e65742d342d3230323530353134a670726f6d7074a548656c6c6f2a",
"full_encoded_hex": "00000045950801c4100102030405060708090a0b0c0d0e0f10c42d82a56d6f64656cb8636c617564652d736f6e6e65742d342d3230323530353134a670726f6d7074a548656c6c6f2a"
},
{
"name": "response_ok",
"type": "response",
"status": 0,
"session_id_hex": "0102030405060708090a0b0c0d0e0f10",
"payload_hex": "82a86576656e745f6964a76576742d313233ab6d65726b6c655f6c656166a6616263313233",
"nonce": 42,
"body_hex": "9400c4100102030405060708090a0b0c0d0e0f10c42582a86576656e745f6964a76576742d313233ab6d65726b6c655f6c656166a66162633132332a",
"full_encoded_hex": "0000003c9400c4100102030405060708090a0b0c0d0e0f10c42582a86576656e745f6964a76576742d313233ab6d65726b6c655f6c656166a66162633132332a"
},
{
"name": "response_error",
"type": "response",
"status": 1,
"session_id_hex": "0102030405060708090a0b0c0d0e0f10",
"payload_hex": "81a76d657373616765ae496e7465726e616c206572726f72",
"nonce": 42,
"body_hex": "9401c4100102030405060708090a0b0c0d0e0f10c41881a76d657373616765ae496e7465726e616c206572726f722a",
"full_encoded_hex": "0000002f9401c4100102030405060708090a0b0c0d0e0f10c41881a76d657373616765ae496e7465726e616c206572726f722a"
},
{
"name": "response_denied",
"type": "response",
"status": 2,
"session_id_hex": "0102030405060708090a0b0c0d0e0f10",
"payload_hex": "82a76d657373616765b2496e73756666696369656e742073636f7065a87265717569726564ac666c6565743a6d7574617465",
"nonce": 42,
"body_hex": "9402c4100102030405060708090a0b0c0d0e0f10c43282a76d657373616765b2496e73756666696369656e742073636f7065a87265717569726564ac666c6565743a6d75746174652a",
"full_encoded_hex": "000000499402c4100102030405060708090a0b0c0d0e0f10c43282a76d657373616765b2496e73756666696369656e742073636f7065a87265717569726564ac666c6565743a6d75746174652a"
},
{
"name": "response_ceremony",
"type": "response",
"status": 3,
"session_id_hex": "0102030405060708090a0b0c0d0e0f10",
"payload_hex": "82ab636572656d6f6e795f6964a76365722d343536ad636572656d6f6e795f74797065ae53696e676c65417070726f76616c",
"nonce": 42,
"body_hex": "9403c4100102030405060708090a0b0c0d0e0f10c43282ab636572656d6f6e795f6964a76365722d343536ad636572656d6f6e795f74797065ae53696e676c65417070726f76616c2a",
"full_encoded_hex": "000000499403c4100102030405060708090a0b0c0d0e0f10c43282ab636572656d6f6e795f6964a76365722d343536ad636572656d6f6e795f74797065ae53696e676c65417070726f76616c2a"
},
{
"name": "empty_payload",
"type": "request",
"namespace": 5,
"function": 1,
"session_id_hex": "0102030405060708090a0b0c0d0e0f10",
"payload_hex": "80",
"nonce": 0,
"body_hex": "950501c4100102030405060708090a0b0c0d0e0f10c4018000",
"full_encoded_hex": "00000019950501c4100102030405060708090a0b0c0d0e0f10c4018000"
},
{
"name": "max_nonce",
"type": "request",
"namespace": 1,
"function": 1,
"session_id_hex": "0102030405060708090a0b0c0d0e0f10",
"payload_hex": "81a66b65795f6964a16b",
"nonce": 18446744073709551615,
"body_hex": "950101c4100102030405060708090a0b0c0d0e0f10c40a81a66b65795f6964a16bcfffffffffffffffff",
"full_encoded_hex": "0000002a950101c4100102030405060708090a0b0c0d0e0f10c40a81a66b65795f6964a16bcfffffffffffffffff"
}
]
}

23
bascule-core/Cargo.toml Normal file
View file

@ -0,0 +1,23 @@
[package]
name = "bascule-core"
version = "0.1.0"
edition = "2021"
description = "Shared types for the Bascule governance-mediated access control system"
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
serde_json_canonicalizer = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
sha2 = { workspace = true }
hex = { workspace = true }
thiserror = { workspace = true }
async-trait = { workspace = true }
tokio = { workspace = true }
# Governance ceremony state machine (extracted)
ceremony-engine = { workspace = true }
# Cross-workspace path deps — Guildhouse governance primitives.
accord-core = { path = "../../guildhouse/services/accord-core" }
registry-protocol = { path = "../../guildhouse/services/registry-protocol" }

92
bascule-core/src/audit.rs Normal file
View file

@ -0,0 +1,92 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::command::{ChangeClassification, CommandRecord, ResourceRef};
use crate::session::OperatorIdentity;
/// A complete audit event for a command execution.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditEvent {
pub event_id: Uuid,
pub session_id: Uuid,
pub operator_identity: OperatorIdentity,
pub timestamp: DateTime<Utc>,
pub command: CommandRecord,
pub classification: ChangeClassification,
pub policy_decision: PolicyDecision,
pub execution_result: ExecutionResult,
pub target_resources: Vec<ResourceRef>,
pub target_profile_hash: Option<String>,
}
/// The result of policy evaluation for a command.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PolicyDecision {
pub allowed: bool,
pub policy_bundle_hash: String,
pub accord_version: String,
pub evaluation_duration_ms: u32,
pub denied_reason: Option<String>,
}
impl PolicyDecision {
/// Create an allow-all stub decision (used in Phase 1 when OPA is not deployed).
pub fn allow_all_stub() -> Self {
Self {
allowed: true,
policy_bundle_hash: "stub".into(),
accord_version: "none".into(),
evaluation_duration_ms: 0,
denied_reason: None,
}
}
}
/// The result of executing a command.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExecutionResult {
pub status: ExecutionStatus,
pub summary: String,
pub resources_affected: u32,
pub mutations_applied: u32,
}
/// Execution outcome.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExecutionStatus {
Success,
Denied,
Error,
Timeout,
}
impl ExecutionResult {
pub fn success(summary: String) -> Self {
Self {
status: ExecutionStatus::Success,
summary,
resources_affected: 0,
mutations_applied: 0,
}
}
pub fn denied(reason: String) -> Self {
Self {
status: ExecutionStatus::Denied,
summary: reason,
resources_affected: 0,
mutations_applied: 0,
}
}
pub fn error(msg: String) -> Self {
Self {
status: ExecutionStatus::Error,
summary: msg,
resources_affected: 0,
mutations_applied: 0,
}
}
}

View file

@ -0,0 +1,67 @@
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::scope::SessionScope;
use crate::session::OperatorIdentity;
/// Types of ceremonies that produce session grants.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CeremonyType {
/// Standing policy auto-approval.
SelfGrant,
/// One human approver required.
SingleApproval,
/// Break-glass with external evidence.
EmergencyAccess,
/// LLM operator with human co-approver.
LlmCoApproval,
}
/// A request to start a ceremony.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CeremonyRequest {
pub ceremony_type: CeremonyType,
pub requestor: OperatorIdentity,
pub requested_scope: SessionScope,
pub evidence: Vec<Evidence>,
pub requested_at: DateTime<Utc>,
}
/// The result of a completed ceremony.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CeremonyGrant {
pub ceremony_id: Uuid,
pub ceremony_type: CeremonyType,
pub requestor: OperatorIdentity,
pub approvers: Vec<OperatorIdentity>,
/// The scope that was actually granted (may be narrower than requested).
pub granted_scope: SessionScope,
pub accord_version: String,
pub evidence: Vec<Evidence>,
pub granted_at: DateTime<Utc>,
pub session_lifetime: Duration,
}
/// External evidence supporting a ceremony (e.g. Jira ticket).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Evidence {
pub evidence_type: EvidenceType,
/// The external reference ("INCIDENT-1234", URL, etc.).
pub reference: String,
/// Whether the gateway verified the reference exists.
pub verified: bool,
pub verified_at: Option<DateTime<Utc>>,
}
/// Types of external evidence.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EvidenceType {
JiraTicket,
GitHubIssue,
SlackThread,
PagerDutyIncident,
Manual,
}

130
bascule-core/src/command.rs Normal file
View file

@ -0,0 +1,130 @@
use serde::{Deserialize, Serialize};
/// A record of a command that was executed (for audit purposes).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommandRecord {
pub verb: String,
pub namespace: Option<String>,
pub resource_type: Option<String>,
pub resource_name: Option<String>,
pub parameters: serde_json::Value,
}
/// Classification of a command's impact.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ChangeClassification {
Read,
Mutative,
Workspace,
Session,
}
/// Metadata about a command verb, used for discovery and classification.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommandDescriptor {
pub verb: String,
pub description: String,
pub classification: ChangeClassification,
pub requires_namespace: bool,
pub requires_resource: bool,
pub streaming: bool,
}
/// Reference to a Kubernetes resource.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResourceRef {
pub api_group: String,
pub kind: String,
pub namespace: String,
pub name: String,
}
/// Returns the built-in command descriptors.
pub fn builtin_commands() -> Vec<CommandDescriptor> {
vec![
// Read commands
CommandDescriptor {
verb: "get".into(),
description: "Get resources".into(),
classification: ChangeClassification::Read,
requires_namespace: true,
requires_resource: true,
streaming: false,
},
CommandDescriptor {
verb: "describe".into(),
description: "Describe a resource in detail".into(),
classification: ChangeClassification::Read,
requires_namespace: true,
requires_resource: true,
streaming: false,
},
CommandDescriptor {
verb: "logs".into(),
description: "View pod logs".into(),
classification: ChangeClassification::Read,
requires_namespace: true,
requires_resource: true,
streaming: true,
},
CommandDescriptor {
verb: "status".into(),
description: "Cluster health summary".into(),
classification: ChangeClassification::Read,
requires_namespace: false,
requires_resource: false,
streaming: false,
},
CommandDescriptor {
verb: "profiles_list".into(),
description: "List capability profiles".into(),
classification: ChangeClassification::Read,
requires_namespace: false,
requires_resource: false,
streaming: false,
},
CommandDescriptor {
verb: "profiles_get".into(),
description: "Show a capability profile".into(),
classification: ChangeClassification::Read,
requires_namespace: false,
requires_resource: true,
streaming: false,
},
// Mutative commands (Phase 2)
CommandDescriptor {
verb: "scale".into(),
description: "Scale a deployment".into(),
classification: ChangeClassification::Mutative,
requires_namespace: true,
requires_resource: true,
streaming: false,
},
CommandDescriptor {
verb: "patch".into(),
description: "Patch a resource".into(),
classification: ChangeClassification::Mutative,
requires_namespace: true,
requires_resource: true,
streaming: false,
},
// Session commands
CommandDescriptor {
verb: "session_status".into(),
description: "Show session scope and lifetime".into(),
classification: ChangeClassification::Session,
requires_namespace: false,
requires_resource: false,
streaming: false,
},
CommandDescriptor {
verb: "session_end".into(),
description: "End the current session".into(),
classification: ChangeClassification::Session,
requires_namespace: false,
requires_resource: false,
streaming: false,
},
]
}

28
bascule-core/src/lib.rs Normal file
View file

@ -0,0 +1,28 @@
pub mod audit;
pub mod ceremony;
pub mod command;
pub mod scope;
pub mod session;
// Governance ceremony engine — extracted to ceremony-engine crate.
// Re-exported here for backward compatibility while consumers migrate.
pub mod ceremony_engine {
pub use ceremony_engine::CeremonyEngine;
}
pub mod ceremony_request {
pub use ceremony_engine::{
ApprovalDecision, CeremonyApproval, CeremonyError, CeremonySubject,
GovernanceCeremonyRequest, GovernanceCeremonyStatus,
};
}
pub mod ceremony_resolution {
pub use ceremony_engine::CeremonyResolution;
}
pub mod ceremony_store {
pub use ceremony_engine::{
CeremonyStore, CeremonyStoreError, InMemoryCeremonyStore,
};
}
pub mod ceremony_artifact {
pub use ceremony_engine::CeremonyVerb;
}

168
bascule-core/src/scope.rs Normal file
View file

@ -0,0 +1,168 @@
use serde::{Deserialize, Serialize};
/// Defines what an operator can do within a session.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionScope {
pub namespaces: Vec<NamespaceScope>,
pub global: GlobalScope,
pub pathways: Vec<ChangePathway>,
/// Maximum mutations before session requires a new ceremony. None = unlimited.
pub mutation_budget: Option<u32>,
pub can_delegate: bool,
}
/// Per-namespace access rules.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NamespaceScope {
pub namespace: String,
pub rules: Vec<ScopeRule>,
/// Workload profile hashes this session can interact with.
/// Empty = all workloads in namespace (if accord permits).
pub workload_profiles: Vec<String>,
/// Capability-negative filter: deny interaction with workloads
/// whose profiles include these capabilities.
pub denied_capabilities: Vec<String>,
}
/// A single permission rule (similar to k8s RBAC but higher-level).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScopeRule {
/// API groups ("" = core, "apps", etc.)
pub api_groups: Vec<String>,
/// Resource types ("pods", "deployments", etc.)
pub resources: Vec<String>,
pub verbs: Vec<Verb>,
}
/// Command verbs that can be granted in a session scope.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Verb {
Get,
List,
Watch,
Create,
Update,
Patch,
Delete,
Exec,
Logs,
Scale,
}
impl Verb {
pub fn as_str(&self) -> &'static str {
match self {
Verb::Get => "get",
Verb::List => "list",
Verb::Watch => "watch",
Verb::Create => "create",
Verb::Update => "update",
Verb::Patch => "patch",
Verb::Delete => "delete",
Verb::Exec => "exec",
Verb::Logs => "logs",
Verb::Scale => "scale",
}
}
}
/// Cross-namespace permissions.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GlobalScope {
pub can_view_audit_trail: bool,
pub can_view_profiles: bool,
pub can_view_topology: bool,
}
/// How mutations flow through the system.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ChangePathway {
/// Apply changes immediately (emergency only).
Direct,
/// Stage in workspace, merge through policy gate.
Workspace,
/// Simulate only, no actual mutations.
DryRunOnly,
}
impl SessionScope {
/// Check if this scope permits the given verb on the given resource in the given namespace.
pub fn permits(&self, namespace: &str, api_group: &str, resource: &str, verb: Verb) -> bool {
self.namespaces.iter().any(|ns| {
ns.namespace == namespace
&& ns.rules.iter().any(|rule| {
(rule.api_groups.contains(&api_group.to_string())
|| rule.api_groups.contains(&"*".to_string()))
&& (rule.resources.contains(&resource.to_string())
|| rule.resources.contains(&"*".to_string()))
&& rule.verbs.contains(&verb)
})
})
}
}
impl Default for GlobalScope {
fn default() -> Self {
Self {
can_view_audit_trail: false,
can_view_profiles: true,
can_view_topology: false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scope_permits_matching_rule() {
let scope = SessionScope {
namespaces: vec![NamespaceScope {
namespace: "default".to_string(),
rules: vec![ScopeRule {
api_groups: vec!["".to_string()],
resources: vec!["pods".to_string()],
verbs: vec![Verb::Get, Verb::List],
}],
workload_profiles: vec![],
denied_capabilities: vec![],
}],
global: GlobalScope::default(),
pathways: vec![ChangePathway::DryRunOnly],
mutation_budget: None,
can_delegate: false,
};
assert!(scope.permits("default", "", "pods", Verb::Get));
assert!(scope.permits("default", "", "pods", Verb::List));
assert!(!scope.permits("default", "", "pods", Verb::Delete));
assert!(!scope.permits("other", "", "pods", Verb::Get));
assert!(!scope.permits("default", "", "deployments", Verb::Get));
}
#[test]
fn scope_wildcard_matches_all() {
let scope = SessionScope {
namespaces: vec![NamespaceScope {
namespace: "default".to_string(),
rules: vec![ScopeRule {
api_groups: vec!["*".to_string()],
resources: vec!["*".to_string()],
verbs: vec![Verb::Get, Verb::List, Verb::Logs],
}],
workload_profiles: vec![],
denied_capabilities: vec![],
}],
global: GlobalScope::default(),
pathways: vec![],
mutation_budget: None,
can_delegate: false,
};
assert!(scope.permits("default", "", "pods", Verb::Get));
assert!(scope.permits("default", "apps", "deployments", Verb::List));
}
}

View file

@ -0,0 +1,72 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::scope::SessionScope;
/// An active operator session.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Session {
pub session_id: Uuid,
pub ceremony_id: Uuid,
pub identity: OperatorIdentity,
pub scope: SessionScope,
pub state: SessionState,
pub mutations_used: u32,
pub valid_from: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
}
/// The identity of an operator interacting with the gateway.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum OperatorIdentity {
/// External operator authenticated via OIDC.
Oidc {
issuer: String,
subject: String,
email: String,
},
/// Cluster-native workload with SPIFFE SVID (future).
Spiffe { svid_uri: String },
}
impl OperatorIdentity {
/// A display-friendly identity string for audit logs.
pub fn display_id(&self) -> String {
match self {
OperatorIdentity::Oidc { email, .. } => format!("oidc:{email}"),
OperatorIdentity::Spiffe { svid_uri } => format!("spiffe:{svid_uri}"),
}
}
}
/// Session lifecycle state.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SessionState {
Active,
Terminated,
Expired,
}
impl Session {
/// Check if this session is still valid for executing commands.
pub fn is_active(&self) -> bool {
self.state == SessionState::Active && Utc::now() < self.expires_at
}
/// Check if the mutation budget is exhausted.
pub fn budget_exhausted(&self) -> bool {
self.scope
.mutation_budget
.map(|budget| self.mutations_used >= budget)
.unwrap_or(false)
}
/// Increment the mutation counter. Returns the new count.
pub fn record_mutation(&mut self) -> u32 {
self.mutations_used += 1;
self.mutations_used
}
}

View file

@ -0,0 +1,13 @@
[package]
name = "bascule-filter-core"
version = "0.1.0"
edition = "2021"
description = "Shared types and traits for the Bascule governed log filter plugin system"
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
chrono = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
anyhow = { workspace = true }

View file

@ -0,0 +1,39 @@
//! LogFilter trait — the extensibility boundary for filter plugins.
use crate::LogLine;
/// Result of applying a filter to a LogLine.
#[derive(Debug, Clone)]
pub enum FilterResult {
/// Emit this line (possibly transformed).
Pass(LogLine),
/// Suppress this line.
Drop,
}
/// A log filter plugin.
///
/// Implementors may be:
/// - In-process (built-in, compiled into bascule-shell)
/// - Out-of-process (standalone binary via ProcessFilter + stdio protocol)
/// - WASM (future — same trait, different runtime)
///
/// The `filter_id()` method enables Chronicle attribution for governed filter plugins.
/// A named, governed filter is a verifiable transform on a governed log stream.
pub trait LogFilter: Send + Sync {
/// Apply this filter to a log line.
///
/// MUST: never drop a line where `line.is_denial == true`.
/// Denial lines are governance events and must always pass through.
fn filter(&self, line: LogLine) -> FilterResult;
/// Human-readable filter name.
fn name(&self) -> &str;
/// Stable identifier for Chronicle attribution.
/// `None` = anonymous built-in filter
/// `Some()` = named governed filter (Chronicle-attributable)
fn filter_id(&self) -> Option<&str> {
None
}
}

View file

@ -0,0 +1,19 @@
//! bascule-filter-core — shared types and traits for the governed log filter plugin system.
//!
//! This crate defines the extensibility boundary for Bascule log filters.
//! Any filter binary (built-in or standalone) depends only on this crate.
//!
//! Three integration modes:
//! 1. In-process (compiled into bascule-shell)
//! 2. Out-of-process (standalone binary via ProcessFilter + stdio protocol)
//! 3. WASM (future — same trait, different runtime)
pub mod filter;
pub mod line;
pub mod process;
pub mod stdio;
pub use filter::{FilterResult, LogFilter};
pub use line::LogLine;
pub use process::ProcessFilter;
pub use stdio::stdio_main;

View file

@ -0,0 +1,80 @@
//! LogLine — the atomic unit of the filter protocol.
//!
//! Serialized as newline-delimited JSON for the stdio filter protocol.
//! Every filter binary receives and emits this type.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
/// A single attributed log line from a pod.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LogLine {
/// Pod name (e.g., "api-svc-7d9f4b-xkp2q").
pub pod: String,
/// Kubernetes namespace.
pub namespace: String,
/// Parsed timestamp if present in line.
#[serde(skip_serializing_if = "Option::is_none")]
pub timestamp: Option<DateTime<Utc>>,
/// Raw line content.
pub content: String,
/// True if this is a governance denial, not actual log output.
/// Denial lines MUST NOT be dropped by any filter — they are governance events.
#[serde(default)]
pub is_denial: bool,
}
impl LogLine {
pub fn new(pod: impl Into<String>, namespace: impl Into<String>, content: impl Into<String>) -> Self {
Self {
pod: pod.into(),
namespace: namespace.into(),
timestamp: None,
content: content.into(),
is_denial: false,
}
}
/// Governance denial line — used by Phase 2 authorization layer.
pub fn denied(pod: impl Into<String>, namespace: impl Into<String>, reason: impl Into<String>) -> Self {
Self {
pod: pod.into(),
namespace: namespace.into(),
timestamp: Some(Utc::now()),
content: format!("[DENIED: {}]", reason.into()),
is_denial: true,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_is_not_denial() {
let l = LogLine::new("p", "ns", "msg");
assert!(!l.is_denial);
}
#[test]
fn denied_sets_flag() {
let l = LogLine::denied("p", "ns", "no capability");
assert!(l.is_denial);
assert!(l.content.contains("DENIED"));
}
#[test]
fn roundtrip_json() {
let l = LogLine::new("api-1", "prod", "ERROR timeout");
let json = serde_json::to_string(&l).unwrap();
let back: LogLine = serde_json::from_str(&json).unwrap();
assert_eq!(back.pod, "api-1");
assert_eq!(back.content, "ERROR timeout");
assert!(!back.is_denial);
}
}

View file

@ -0,0 +1,92 @@
//! ProcessFilter — wraps a standalone filter binary.
//!
//! The binary must implement the stdio protocol:
//! read LogLine JSON from stdin, write back (possibly modified)
//! LogLine JSON or nothing (to drop).
//!
//! This is how future standalone filters plug in:
//! bascule-filter-regex, bascule-filter-jq, bascule-filter-rate, etc.
use std::io::BufReader;
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
use crate::{FilterResult, LogFilter, LogLine};
/// Wraps a standalone filter binary communicating via stdio.
pub struct ProcessFilter {
/// Path to the filter binary.
binary: String,
/// Arguments passed to the binary.
#[allow(dead_code)]
args: Vec<String>,
child: Child,
#[allow(dead_code)]
stdin: ChildStdin,
#[allow(dead_code)]
stdout: BufReader<ChildStdout>,
}
impl ProcessFilter {
/// Spawn the filter binary.
pub fn spawn(
binary: impl Into<String>,
args: impl IntoIterator<Item = impl Into<String>>,
) -> anyhow::Result<Self> {
let binary = binary.into();
let args: Vec<String> = args.into_iter().map(|a| a.into()).collect();
let mut child = Command::new(&binary)
.args(&args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.map_err(|e| anyhow::anyhow!("Failed to spawn filter '{}': {}", binary, e))?;
let stdin = child.stdin.take().unwrap();
let stdout = BufReader::new(child.stdout.take().unwrap());
Ok(Self {
binary,
args,
child,
stdin,
stdout,
})
}
}
impl LogFilter for ProcessFilter {
fn filter(&self, line: LogLine) -> FilterResult {
// Denial lines bypass the external process entirely.
if line.is_denial {
return FilterResult::Pass(line);
}
// NOTE: ProcessFilter requires interior mutability for production use.
// The stdio protocol is: write LogLine JSON to child stdin, read response
// from child stdout. This requires &mut self (stdin write + stdout read).
//
// Phase 2 will use Arc<Mutex<ProcessFilter>> or an async channel-based
// design. For now, this stub passes the line through unchanged and logs
// a warning if actually invoked.
tracing::warn!(
binary = %self.binary,
"ProcessFilter::filter() called — sync I/O stub, line passed through unchanged"
);
FilterResult::Pass(line)
}
fn name(&self) -> &str {
"process"
}
fn filter_id(&self) -> Option<&str> {
Some(&self.binary)
}
}
impl Drop for ProcessFilter {
fn drop(&mut self) {
let _ = self.child.kill();
}
}

View file

@ -0,0 +1,87 @@
//! stdio_main — helper for standalone filter binaries.
//!
//! A complete filter binary:
//! ```ignore
//! fn main() {
//! let filter = MyFilter::new();
//! bascule_filter_core::stdio_main(filter);
//! }
//! ```
//!
//! Reads LogLine JSON from stdin (one per line),
//! applies filter, writes passing lines to stdout.
use std::io::{self, BufRead, Write};
use crate::{FilterResult, LogFilter, LogLine};
/// Run a filter as a stdio pipeline process.
///
/// Reads newline-delimited JSON LogLine from stdin,
/// applies the filter, writes passing lines to stdout.
/// Exits cleanly on EOF or read error.
pub fn stdio_main(filter: impl LogFilter) {
let stdin = io::stdin();
let stdout = io::stdout();
let mut out = stdout.lock();
for line in stdin.lock().lines() {
let raw = match line {
Ok(l) => l,
Err(e) => {
tracing::warn!("stdin read error: {e}");
break;
}
};
if raw.is_empty() {
continue;
}
let log_line: LogLine = match serde_json::from_str(&raw) {
Ok(l) => l,
Err(e) => {
tracing::warn!("LogLine parse error: {e} — raw: {raw}");
continue;
}
};
if let FilterResult::Pass(out_line) = filter.filter(log_line) {
if let Ok(json) = serde_json::to_string(&out_line) {
let _ = writeln!(out, "{json}");
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
struct AlwaysPass;
impl LogFilter for AlwaysPass {
fn filter(&self, line: LogLine) -> FilterResult {
FilterResult::Pass(line)
}
fn name(&self) -> &str {
"always-pass"
}
}
#[test]
fn stdio_protocol_roundtrip() {
// Verify LogLine can be serialized and deserialized
// (the actual stdio_main uses real stdin/stdout,
// so we test the data format, not the I/O loop)
let line = LogLine::new("pod-1", "default", "hello world");
let json = serde_json::to_string(&line).unwrap();
let parsed: LogLine = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.pod, "pod-1");
assert_eq!(parsed.content, "hello world");
// Verify filter works on parsed line
let filter = AlwaysPass;
let result = filter.filter(parsed);
assert!(matches!(result, FilterResult::Pass(_)));
}
}

View file

@ -0,0 +1,69 @@
[package]
name = "bascule-gateway"
version = "0.1.0"
edition = "2021"
description = "Bascule governance gateway — cluster-side API gateway for governed access"
[[bin]]
name = "bascule-gateway"
path = "src/main.rs"
[dependencies]
bascule-core = { workspace = true }
bascule-proto = { workspace = true }
# Cross-workspace path deps — Guildhouse governance/ceremony primitives.
# Future: extract to standalone crates.
accord-core = { path = "../../guildhouse/services/accord-core" }
accord-opa = { path = "../../guildhouse/services/accord-opa" }
qm-core = { path = "../../guildhouse/services/qm-core" }
# Kubernetes
kube = { workspace = true }
k8s-openapi = { workspace = true }
# gRPC
tonic = { workspace = true }
prost = { workspace = true }
prost-types = { workspace = true }
# Auth
jsonwebtoken = { workspace = true }
reqwest = { workspace = true }
# Database
sqlx = { workspace = true }
# Session cache
dashmap = { workspace = true }
# Async
tokio = { workspace = true }
async-trait = { workspace = true }
# Serialization
serde = { workspace = true }
serde_json = { workspace = true }
serde_json_canonicalizer = { workspace = true }
hex = { workspace = true }
sha2 = { workspace = true }
# Observability
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
# HTTP (ceremony approval endpoints)
axum = { workspace = true }
tower-http = { workspace = true }
# Common
uuid = { workspace = true }
chrono = { workspace = true }
thiserror = { workspace = true }
anyhow = { workspace = true }
config = { workspace = true }
rustls = { workspace = true }
tokio-stream = "0.1"
[dev-dependencies]
tower = "0.5"

View file

@ -0,0 +1,299 @@
use std::sync::Arc;
use std::time::Duration;
use bascule_core::audit::{AuditEvent, ExecutionResult};
use bascule_core::command::{ChangeClassification, CommandRecord, ResourceRef};
use bascule_core::session::OperatorIdentity;
use chrono::Utc;
use sha2::{Digest, Sha256};
use sqlx::PgPool;
use tokio::sync::Mutex;
use uuid::Uuid;
/// A leaf ready for merkle anchoring.
struct AuditLeaf {
event_id: Uuid,
session_id: Uuid,
leaf_hash: [u8; 32],
}
/// Buffers audit events and periodically flushes merkle leaf hashes.
pub struct AuditPipeline {
pending: Mutex<Vec<AuditLeaf>>,
db_pool: PgPool,
batch_size: usize,
}
impl AuditPipeline {
pub fn new(db_pool: PgPool, batch_size: usize) -> Self {
Self {
pending: Mutex::new(Vec::new()),
db_pool,
batch_size,
}
}
/// Submit an audit event: insert into PG and queue leaf for anchoring.
pub async fn submit(&self, event: &AuditEvent, notarize: bool) {
// Compute merkle leaf
let canonical = match serde_json_canonicalizer::to_string(&event) {
Ok(c) => c,
Err(e) => {
tracing::error!("Failed to canonicalize audit event: {e}");
return;
}
};
let content_hash = Sha256::digest(canonical.as_bytes());
let leaf_data = format!(
"bascule:{}:{}:{}",
event.session_id,
event.event_id,
hex::encode(content_hash)
);
let leaf_hash = qm_core::merkle::hash_leaf(leaf_data.as_bytes());
// Insert into PG
let command_json = serde_json::to_value(&event.command).unwrap_or_default();
let policy_json = serde_json::to_value(&event.policy_decision).unwrap_or_default();
let result_json = serde_json::to_value(&event.execution_result).unwrap_or_default();
let resources_json = serde_json::to_value(&event.target_resources).unwrap_or_default();
let classification_str = format!("{:?}", event.classification).to_lowercase();
let insert_result = sqlx::query(
r#"
INSERT INTO bascule.audit_events
(event_id, session_id, operator_identity, command, classification,
policy_decision, execution_result, target_resources,
target_profile_hash, notarized, merkle_leaf, time)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
"#,
)
.bind(event.event_id)
.bind(event.session_id)
.bind(event.operator_identity.display_id())
.bind(command_json)
.bind(&classification_str)
.bind(policy_json)
.bind(result_json)
.bind(resources_json)
.bind(&event.target_profile_hash)
.bind(notarize)
.bind(leaf_hash.as_slice())
.bind(event.timestamp)
.execute(&self.db_pool)
.await;
if let Err(e) = insert_result {
tracing::error!("Failed to insert audit event: {e}");
return;
}
// Queue for merkle anchoring if needed
if notarize {
let mut pending = self.pending.lock().await;
pending.push(AuditLeaf {
event_id: event.event_id,
session_id: event.session_id,
leaf_hash,
});
if pending.len() >= self.batch_size {
drop(pending);
self.flush().await;
}
}
}
/// Flush pending leaves to Quartermaster for anchoring.
pub async fn flush(&self) {
let leaves: Vec<AuditLeaf> = {
let mut pending = self.pending.lock().await;
std::mem::take(&mut *pending)
};
if leaves.is_empty() {
return;
}
tracing::info!(count = leaves.len(), "Flushing audit leaves for anchoring");
// Phase 2: mark events as anchored in PG.
// Actual QM gRPC submission is a future enhancement -- for now we
// compute and store the leaf hashes, which is the cryptographic guarantee.
// The anchor_id will be set when we integrate QM's FlushAnchor RPC.
for leaf in &leaves {
let _ = sqlx::query(
"UPDATE bascule.audit_events SET notarized = true WHERE event_id = $1",
)
.bind(leaf.event_id)
.execute(&self.db_pool)
.await;
}
}
/// Start the background flush loop.
pub fn start_flush_loop(
self: Arc<Self>,
interval: Duration,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
let mut timer = tokio::time::interval(interval);
loop {
timer.tick().await;
self.flush().await;
}
})
}
}
/// Build an AuditEvent from the filter chain's request context.
pub fn build_audit_event(
session_id: Uuid,
identity: &OperatorIdentity,
command: &bascule_proto::bascule_v1::ExecuteCommandRequest,
classification: ChangeClassification,
policy_decision: &bascule_core::audit::PolicyDecision,
exec_result: &ExecutionResult,
) -> AuditEvent {
AuditEvent {
event_id: Uuid::new_v4(),
session_id,
operator_identity: identity.clone(),
timestamp: Utc::now(),
command: CommandRecord {
verb: command.verb.clone(),
namespace: command.namespace.clone(),
resource_type: command.resource_type.clone(),
resource_name: command.resource_name.clone(),
parameters: command
.parameters
.as_ref()
.map(|s| prost_struct_to_json(s))
.unwrap_or_default(),
},
classification,
policy_decision: policy_decision.clone(),
execution_result: exec_result.clone(),
target_resources: build_resource_refs(command),
target_profile_hash: None,
}
}
fn build_resource_refs(cmd: &bascule_proto::bascule_v1::ExecuteCommandRequest) -> Vec<ResourceRef> {
if let (Some(rt), Some(ns)) = (&cmd.resource_type, &cmd.namespace) {
let api_group = resolve_api_group(rt);
vec![ResourceRef {
api_group,
kind: rt.clone(),
namespace: ns.clone(),
name: cmd.resource_name.clone().unwrap_or_default(),
}]
} else {
vec![]
}
}
fn resolve_api_group(resource_type: &str) -> String {
match resource_type {
"deployments" | "deployment" | "deploy" | "replicasets" | "replicaset" | "rs"
| "statefulsets" | "statefulset" | "sts" | "daemonsets" | "daemonset" | "ds" => {
"apps".to_string()
}
"jobs" | "job" | "cronjobs" | "cronjob" | "cj" => "batch".to_string(),
_ => String::new(), // core group
}
}
fn prost_struct_to_json(s: &prost_types::Struct) -> serde_json::Value {
// Convert prost Struct fields to a JSON object manually
let mut map = serde_json::Map::new();
for (key, value) in &s.fields {
map.insert(key.clone(), prost_value_to_json(value));
}
serde_json::Value::Object(map)
}
fn prost_value_to_json(v: &prost_types::Value) -> serde_json::Value {
match &v.kind {
Some(prost_types::value::Kind::NullValue(_)) => serde_json::Value::Null,
Some(prost_types::value::Kind::NumberValue(n)) => {
serde_json::Value::Number(serde_json::Number::from_f64(*n).unwrap_or(serde_json::Number::from(0)))
}
Some(prost_types::value::Kind::StringValue(s)) => serde_json::Value::String(s.clone()),
Some(prost_types::value::Kind::BoolValue(b)) => serde_json::Value::Bool(*b),
Some(prost_types::value::Kind::StructValue(s)) => prost_struct_to_json(s),
Some(prost_types::value::Kind::ListValue(l)) => {
serde_json::Value::Array(l.values.iter().map(prost_value_to_json).collect())
}
None => serde_json::Value::Null,
}
}
/// Determine if this event should be notarized based on ledger fidelity.
pub fn should_notarize(classification: ChangeClassification, ledger_fidelity: &str) -> bool {
match ledger_fidelity {
"always_notarize" => true,
"log_only" => false,
_ => {
// Default: notarize mutative operations, log reads
matches!(
classification,
ChangeClassification::Mutative | ChangeClassification::Session
)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_audit_leaf_format() {
let session_id = Uuid::new_v4();
let event_id = Uuid::new_v4();
let content = "test content";
let content_hash = Sha256::digest(content.as_bytes());
let leaf_data = format!(
"bascule:{}:{}:{}",
session_id,
event_id,
hex::encode(content_hash)
);
assert!(leaf_data.starts_with("bascule:"));
assert!(leaf_data.contains(&session_id.to_string()));
assert!(leaf_data.contains(&event_id.to_string()));
// Verify hash_leaf produces a 32-byte hash
let leaf_hash = qm_core::merkle::hash_leaf(leaf_data.as_bytes());
assert_eq!(leaf_hash.len(), 32);
}
#[test]
fn test_should_notarize() {
assert!(should_notarize(
ChangeClassification::Mutative,
"always_notarize"
));
assert!(should_notarize(
ChangeClassification::Read,
"always_notarize"
));
assert!(!should_notarize(ChangeClassification::Read, "log_only"));
assert!(!should_notarize(
ChangeClassification::Mutative,
"log_only"
));
// Default behavior
assert!(should_notarize(ChangeClassification::Mutative, "default"));
assert!(!should_notarize(ChangeClassification::Read, "default"));
}
#[test]
fn test_resolve_api_group() {
assert_eq!(resolve_api_group("deployments"), "apps");
assert_eq!(resolve_api_group("pods"), "");
assert_eq!(resolve_api_group("jobs"), "batch");
}
}

142
bascule-gateway/src/auth.rs Normal file
View file

@ -0,0 +1,142 @@
use bascule_core::session::OperatorIdentity;
use jsonwebtoken::{decode, Algorithm, DecodingKey, TokenData, Validation};
use serde::Deserialize;
use std::sync::Arc;
use tokio::sync::RwLock;
/// Claims extracted from an OIDC ID token.
#[derive(Debug, Deserialize)]
struct OidcClaims {
sub: String,
email: Option<String>,
iss: String,
}
/// OIDC token validator. In Phase 1, validates token structure and extracts claims.
/// Full JWKS signature verification is deferred — the gateway trusts tokens signed
/// by the configured issuer.
pub struct OidcAuthProvider {
issuer: String,
audience: String,
/// Cached JWKS keys (populated on first use).
jwks: Arc<RwLock<Option<jsonwebtoken::jwk::JwkSet>>>,
}
impl OidcAuthProvider {
pub fn new(issuer: &str, audience: &str) -> Self {
Self {
issuer: issuer.to_string(),
audience: audience.to_string(),
jwks: Arc::new(RwLock::new(None)),
}
}
/// Validate a bearer token and return the operator identity.
///
/// Phase 1: Decodes token without full JWKS verification for development.
/// Phase 2+ will add proper JWKS fetching and signature verification.
pub async fn validate_token(&self, token: &str) -> Result<OperatorIdentity, AuthError> {
// Phase 1: Try JWKS validation, fall back to insecure decode for development.
// This allows both Keycloak-issued tokens (with JWKS) and dev tokens to work.
match self.validate_with_jwks(token).await {
Ok(identity) => Ok(identity),
Err(_) => {
// Fallback: decode without signature verification (development only)
tracing::warn!("JWKS validation failed, falling back to insecure decode");
self.insecure_decode(token)
}
}
}
async fn validate_with_jwks(&self, token: &str) -> Result<OperatorIdentity, AuthError> {
// Fetch JWKS if not cached
let jwks = {
let cached = self.jwks.read().await;
cached.clone()
};
let jwks = match jwks {
Some(jwks) => jwks,
None => {
let jwks_url = format!(
"{}/protocol/openid-connect/certs",
self.issuer.trim_end_matches('/')
);
let resp = reqwest::get(&jwks_url)
.await
.map_err(|e| AuthError::JwksFetch(e.to_string()))?;
let jwks: jsonwebtoken::jwk::JwkSet = resp
.json()
.await
.map_err(|e| AuthError::JwksFetch(e.to_string()))?;
let mut cache = self.jwks.write().await;
*cache = Some(jwks.clone());
jwks
}
};
// Decode JWT header to get kid
let header = jsonwebtoken::decode_header(token)
.map_err(|e| AuthError::InvalidToken(e.to_string()))?;
let kid = header.kid.ok_or(AuthError::InvalidToken("missing kid".into()))?;
// Find matching key
let jwk = jwks
.find(&kid)
.ok_or(AuthError::InvalidToken(format!("kid {kid} not found in JWKS")))?;
let decoding_key = DecodingKey::from_jwk(jwk)
.map_err(|e| AuthError::InvalidToken(e.to_string()))?;
let mut validation = Validation::new(Algorithm::RS256);
validation.set_audience(&[&self.audience]);
validation.set_issuer(&[&self.issuer]);
let token_data: TokenData<OidcClaims> = decode(token, &decoding_key, &validation)
.map_err(|e| AuthError::InvalidToken(e.to_string()))?;
let claims = token_data.claims;
let email = claims
.email
.unwrap_or_else(|| format!("{}@unknown", claims.sub));
Ok(OperatorIdentity::Oidc {
issuer: claims.iss,
subject: claims.sub,
email,
})
}
fn insecure_decode(&self, token: &str) -> Result<OperatorIdentity, AuthError> {
let mut validation = Validation::new(Algorithm::RS256);
validation.insecure_disable_signature_validation();
validation.set_audience(&[&self.audience]);
validation.set_issuer(&[&self.issuer]);
let token_data: TokenData<OidcClaims> =
decode(token, &DecodingKey::from_secret(b""), &validation)
.map_err(|e| AuthError::InvalidToken(e.to_string()))?;
let claims = token_data.claims;
let email = claims
.email
.unwrap_or_else(|| format!("{}@unknown", claims.sub));
Ok(OperatorIdentity::Oidc {
issuer: claims.iss,
subject: claims.sub,
email,
})
}
}
#[derive(Debug, thiserror::Error)]
pub enum AuthError {
#[error("missing authorization header")]
MissingToken,
#[error("invalid token: {0}")]
InvalidToken(String),
#[error("failed to fetch JWKS: {0}")]
JwksFetch(String),
}

View file

@ -0,0 +1,408 @@
use bascule_core::ceremony::{CeremonyGrant, CeremonyType, Evidence};
use bascule_core::scope::SessionScope;
use bascule_core::session::OperatorIdentity;
use chrono::{Duration, Utc};
use sqlx::PgPool;
use uuid::Uuid;
/// Status of a ceremony.
#[derive(Debug, Clone)]
pub enum CeremonyStatus {
Pending {
ceremony_id: Uuid,
timeout_at: chrono::DateTime<chrono::Utc>,
},
Approved {
grant: CeremonyGrant,
},
Denied {
reason: String,
},
Expired,
}
/// Result of processing a ceremony request.
#[derive(Debug)]
pub enum CeremonyResponse {
/// Session created immediately (self-grant, break-glass).
Granted(CeremonyGrant),
/// Ceremony is pending approval.
Pending {
ceremony_id: Uuid,
timeout_at: chrono::DateTime<chrono::Utc>,
},
/// Ceremony was denied.
Denied(String),
}
/// Manages ceremony lifecycle: creation, approval, denial, expiry.
pub struct CeremonyManager {
db_pool: PgPool,
default_lifetime_secs: u64,
}
impl CeremonyManager {
pub fn new(db_pool: PgPool, default_lifetime_secs: u64) -> Self {
Self {
db_pool,
default_lifetime_secs,
}
}
/// Process a self-grant ceremony: evaluate and create session immediately.
pub async fn process_self_grant(
&self,
identity: &OperatorIdentity,
requested_scope: &SessionScope,
accord_version: &str,
) -> anyhow::Result<CeremonyResponse> {
let ceremony_id = Uuid::new_v4();
let now = Utc::now();
let lifetime = Duration::seconds(self.default_lifetime_secs as i64);
let (sub, email) = identity_parts(identity);
let scope_json = serde_json::to_value(requested_scope)?;
// Insert approved ceremony record
sqlx::query(
r#"
INSERT INTO bascule.ceremonies
(ceremony_id, ceremony_type, requestor_sub, requestor_email,
requested_scope, granted_scope, status, accord_version,
requested_at, resolved_at, timeout_at)
VALUES ($1, 'self_grant', $2, $3, $4, $4, 'approved', $5, $6, $6, $7)
"#,
)
.bind(ceremony_id)
.bind(&sub)
.bind(&email)
.bind(&scope_json)
.bind(accord_version)
.bind(now)
.bind(now + Duration::minutes(5)) // self-grant timeout is irrelevant but required
.execute(&self.db_pool)
.await?;
let grant = CeremonyGrant {
ceremony_id,
ceremony_type: CeremonyType::SelfGrant,
requestor: identity.clone(),
approvers: vec![],
granted_scope: requested_scope.clone(),
accord_version: accord_version.to_string(),
evidence: vec![],
granted_at: now,
session_lifetime: lifetime,
};
Ok(CeremonyResponse::Granted(grant))
}
/// Process a single-approval ceremony: create pending ceremony.
pub async fn process_single_approval(
&self,
identity: &OperatorIdentity,
requested_scope: &SessionScope,
accord_version: &str,
) -> anyhow::Result<CeremonyResponse> {
let ceremony_id = Uuid::new_v4();
let now = Utc::now();
let timeout_at = now + Duration::minutes(30);
let (sub, email) = identity_parts(identity);
let scope_json = serde_json::to_value(requested_scope)?;
sqlx::query(
r#"
INSERT INTO bascule.ceremonies
(ceremony_id, ceremony_type, requestor_sub, requestor_email,
requested_scope, status, accord_version, requested_at, timeout_at)
VALUES ($1, 'single_approval', $2, $3, $4, 'pending', $5, $6, $7)
"#,
)
.bind(ceremony_id)
.bind(&sub)
.bind(&email)
.bind(&scope_json)
.bind(accord_version)
.bind(now)
.bind(timeout_at)
.execute(&self.db_pool)
.await?;
tracing::info!(
ceremony_id = %ceremony_id,
requestor = %sub,
"Single-approval ceremony created, awaiting approval"
);
Ok(CeremonyResponse::Pending {
ceremony_id,
timeout_at,
})
}
/// Process a break-glass (emergency) ceremony.
pub async fn process_break_glass(
&self,
identity: &OperatorIdentity,
requested_scope: &SessionScope,
evidence: &[Evidence],
accord_version: &str,
) -> anyhow::Result<CeremonyResponse> {
// Require at least one piece of evidence
if evidence.is_empty() {
return Ok(CeremonyResponse::Denied(
"break-glass ceremony requires at least one evidence item".into(),
));
}
let ceremony_id = Uuid::new_v4();
let now = Utc::now();
let lifetime = Duration::seconds(self.default_lifetime_secs as i64);
let (sub, email) = identity_parts(identity);
let scope_json = serde_json::to_value(requested_scope)?;
let evidence_json = serde_json::to_value(evidence)?;
sqlx::query(
r#"
INSERT INTO bascule.ceremonies
(ceremony_id, ceremony_type, requestor_sub, requestor_email,
requested_scope, granted_scope, status, accord_version,
evidence, requested_at, resolved_at, timeout_at)
VALUES ($1, 'emergency_access', $2, $3, $4, $4, 'approved', $5, $6, $7, $7, $8)
"#,
)
.bind(ceremony_id)
.bind(&sub)
.bind(&email)
.bind(&scope_json)
.bind(accord_version)
.bind(&evidence_json)
.bind(now)
.bind(now + Duration::minutes(5))
.execute(&self.db_pool)
.await?;
tracing::warn!(
ceremony_id = %ceremony_id,
requestor = %sub,
evidence_count = evidence.len(),
"Break-glass ceremony approved with evidence"
);
let grant = CeremonyGrant {
ceremony_id,
ceremony_type: CeremonyType::EmergencyAccess,
requestor: identity.clone(),
approvers: vec![],
granted_scope: requested_scope.clone(),
accord_version: accord_version.to_string(),
evidence: evidence.to_vec(),
granted_at: now,
session_lifetime: lifetime,
};
Ok(CeremonyResponse::Granted(grant))
}
/// Approve a pending ceremony. Returns the grant if approved.
pub async fn approve_ceremony(
&self,
ceremony_id: Uuid,
approver: &OperatorIdentity,
) -> anyhow::Result<CeremonyResponse> {
let now = Utc::now();
let lifetime = Duration::seconds(self.default_lifetime_secs as i64);
let approver_json = serde_json::to_value(&[approver])?;
// Fetch and update the ceremony atomically
let row = sqlx::query_as::<_, CeremonyRow>(
r#"
UPDATE bascule.ceremonies
SET status = 'approved',
granted_scope = requested_scope,
approvers = $2,
resolved_at = $3
WHERE ceremony_id = $1 AND status = 'pending'
RETURNING ceremony_id, ceremony_type, requestor_sub, requestor_email,
requested_scope, granted_scope, accord_version, evidence
"#,
)
.bind(ceremony_id)
.bind(&approver_json)
.bind(now)
.fetch_optional(&self.db_pool)
.await?;
match row {
Some(row) => {
let granted_scope: SessionScope =
serde_json::from_value(row.granted_scope.unwrap_or_default())?;
let ceremony_type = parse_ceremony_type(&row.ceremony_type);
let grant = CeremonyGrant {
ceremony_id: row.ceremony_id,
ceremony_type,
requestor: OperatorIdentity::Oidc {
issuer: String::new(),
subject: row.requestor_sub,
email: row.requestor_email,
},
approvers: vec![approver.clone()],
granted_scope,
accord_version: row.accord_version,
evidence: serde_json::from_value(row.evidence).unwrap_or_default(),
granted_at: now,
session_lifetime: lifetime,
};
Ok(CeremonyResponse::Granted(grant))
}
None => Ok(CeremonyResponse::Denied(
"ceremony not found or already resolved".into(),
)),
}
}
/// Deny a pending ceremony.
pub async fn deny_ceremony(
&self,
ceremony_id: Uuid,
reason: &str,
) -> anyhow::Result<()> {
sqlx::query(
"UPDATE bascule.ceremonies SET status = 'denied', resolved_at = NOW() WHERE ceremony_id = $1 AND status = 'pending'",
)
.bind(ceremony_id)
.execute(&self.db_pool)
.await?;
tracing::info!(%ceremony_id, reason, "Ceremony denied");
Ok(())
}
/// Get ceremony status for polling.
pub async fn get_ceremony_status(
&self,
ceremony_id: Uuid,
) -> anyhow::Result<Option<CeremonyStatus>> {
let row = sqlx::query_as::<_, CeremonyStatusRow>(
"SELECT ceremony_id, status, timeout_at FROM bascule.ceremonies WHERE ceremony_id = $1",
)
.bind(ceremony_id)
.fetch_optional(&self.db_pool)
.await?;
match row {
Some(r) => match r.status.as_str() {
"pending" => Ok(Some(CeremonyStatus::Pending {
ceremony_id: r.ceremony_id,
timeout_at: r.timeout_at,
})),
"approved" => {
// Fetch full grant details
let grant_row = sqlx::query_as::<_, CeremonyRow>(
r#"
SELECT ceremony_id, ceremony_type, requestor_sub, requestor_email,
requested_scope, granted_scope, accord_version, evidence
FROM bascule.ceremonies WHERE ceremony_id = $1
"#,
)
.bind(ceremony_id)
.fetch_one(&self.db_pool)
.await?;
let granted_scope: SessionScope =
serde_json::from_value(grant_row.granted_scope.unwrap_or_default())?;
let ceremony_type = parse_ceremony_type(&grant_row.ceremony_type);
let lifetime = Duration::seconds(self.default_lifetime_secs as i64);
Ok(Some(CeremonyStatus::Approved {
grant: CeremonyGrant {
ceremony_id: grant_row.ceremony_id,
ceremony_type,
requestor: OperatorIdentity::Oidc {
issuer: String::new(),
subject: grant_row.requestor_sub,
email: grant_row.requestor_email,
},
approvers: vec![],
granted_scope,
accord_version: grant_row.accord_version,
evidence: serde_json::from_value(grant_row.evidence)
.unwrap_or_default(),
granted_at: Utc::now(),
session_lifetime: lifetime,
},
}))
}
"denied" => Ok(Some(CeremonyStatus::Denied {
reason: "ceremony was denied".into(),
})),
"expired" => Ok(Some(CeremonyStatus::Expired)),
_ => Ok(None),
},
None => Ok(None),
}
}
/// Reap expired pending ceremonies.
pub async fn reap_expired_ceremonies(&self) {
let result = sqlx::query(
"UPDATE bascule.ceremonies SET status = 'expired', resolved_at = NOW() WHERE status = 'pending' AND timeout_at < NOW()",
)
.execute(&self.db_pool)
.await;
match result {
Ok(r) if r.rows_affected() > 0 => {
tracing::info!(count = r.rows_affected(), "Reaped expired ceremonies");
}
Err(e) => tracing::error!("Failed to reap ceremonies: {e}"),
_ => {}
}
}
}
// --- SQL row types ---
#[derive(sqlx::FromRow)]
struct CeremonyRow {
ceremony_id: Uuid,
ceremony_type: String,
requestor_sub: String,
requestor_email: String,
#[allow(dead_code)]
requested_scope: serde_json::Value,
granted_scope: Option<serde_json::Value>,
accord_version: String,
evidence: serde_json::Value,
}
#[derive(sqlx::FromRow)]
struct CeremonyStatusRow {
ceremony_id: Uuid,
status: String,
timeout_at: chrono::DateTime<chrono::Utc>,
}
// --- Helpers ---
fn identity_parts(identity: &OperatorIdentity) -> (String, String) {
match identity {
OperatorIdentity::Oidc {
subject, email, ..
} => (subject.clone(), email.clone()),
OperatorIdentity::Spiffe { svid_uri } => (svid_uri.clone(), String::new()),
}
}
fn parse_ceremony_type(s: &str) -> CeremonyType {
match s {
"self_grant" => CeremonyType::SelfGrant,
"single_approval" => CeremonyType::SingleApproval,
"emergency_access" => CeremonyType::EmergencyAccess,
"llm_co_approval" => CeremonyType::LlmCoApproval,
_ => CeremonyType::SelfGrant,
}
}

View file

@ -0,0 +1,149 @@
use serde::Deserialize;
/// Gateway configuration, loaded from environment variables with BASCULE_ prefix.
#[derive(Debug, Deserialize)]
pub struct BasculeConfig {
/// gRPC listen address (default: 0.0.0.0:50052)
#[serde(default = "default_listen_addr")]
pub listen_addr: String,
/// OIDC issuer URL for token validation
#[serde(default = "default_oidc_issuer")]
pub oidc_issuer: String,
/// Expected OIDC audience (client_id)
#[serde(default = "default_oidc_audience")]
pub oidc_audience: String,
/// Default session lifetime in seconds for self-grant ceremonies
#[serde(default = "default_session_lifetime")]
pub session_lifetime_secs: u64,
// --- Database (QM-provisioned bascule_svc credentials) ---
#[serde(default = "default_db_host")]
pub db_host: String,
#[serde(default = "default_db_port")]
pub db_port: u16,
#[serde(default = "default_db_name")]
pub db_name: String,
#[serde(default = "default_db_user")]
pub db_user: String,
#[serde(default)]
pub db_password: String,
// --- OPA sidecar ---
#[serde(default = "default_opa_url")]
pub opa_url: String,
// --- Quartermaster endpoint ---
#[serde(default = "default_qm_endpoint")]
pub qm_endpoint: String,
// --- Accord ---
#[serde(default = "default_accord_path")]
pub accord_path: String,
// --- Audit pipeline ---
#[serde(default = "default_audit_batch_size")]
pub audit_batch_size: usize,
#[serde(default = "default_audit_flush_interval")]
pub audit_flush_interval_secs: u64,
}
fn default_listen_addr() -> String {
"0.0.0.0:50052".to_string()
}
fn default_oidc_issuer() -> String {
"http://localhost:8080/realms/guildhouse".to_string()
}
fn default_oidc_audience() -> String {
"bascule-gateway".to_string()
}
fn default_session_lifetime() -> u64 {
28800 // 8 hours
}
fn default_db_host() -> String {
"localhost".to_string()
}
fn default_db_port() -> u16 {
5432
}
fn default_db_name() -> String {
"telemetry".to_string()
}
fn default_db_user() -> String {
"bascule_svc".to_string()
}
fn default_opa_url() -> String {
"http://localhost:8181".to_string()
}
fn default_qm_endpoint() -> String {
"http://quartermaster.quartermaster.svc.cluster.local:50051".to_string()
}
fn default_accord_path() -> String {
"/accord/accord.yaml".to_string()
}
fn default_audit_batch_size() -> usize {
50
}
fn default_audit_flush_interval() -> u64 {
10
}
impl BasculeConfig {
pub fn from_env() -> anyhow::Result<Self> {
let config = config::Config::builder()
.add_source(
config::Environment::with_prefix("BASCULE")
.separator("__")
.try_parsing(true),
)
.set_default("listen_addr", default_listen_addr())?
.set_default("oidc_issuer", default_oidc_issuer())?
.set_default("oidc_audience", default_oidc_audience())?
.set_default("session_lifetime_secs", default_session_lifetime() as i64)?
.set_default("db_host", default_db_host())?
.set_default("db_port", default_db_port() as i64)?
.set_default("db_name", default_db_name())?
.set_default("db_user", default_db_user())?
.set_default("db_password", "")?
.set_default("opa_url", default_opa_url())?
.set_default("qm_endpoint", default_qm_endpoint())?
.set_default("accord_path", default_accord_path())?
.set_default("audit_batch_size", default_audit_batch_size() as i64)?
.set_default("audit_flush_interval_secs", default_audit_flush_interval() as i64)?
.build()?;
Ok(config.try_deserialize()?)
}
/// HTTP listen address for ceremony approval endpoints.
pub fn http_listen_addr(&self) -> String {
std::env::var("BASCULE__HTTP_LISTEN_ADDR").unwrap_or_else(|_| "0.0.0.0:8443".to_string())
}
/// Build a PostgreSQL connection URL from the individual config fields.
pub fn database_url(&self) -> String {
format!(
"postgresql://{}:{}@{}:{}/{}",
self.db_user, self.db_password, self.db_host, self.db_port, self.db_name
)
}
}

View file

@ -0,0 +1,461 @@
use bascule_core::audit::ExecutionResult;
use k8s_openapi::api::core::v1::{Node, Pod};
use kube::api::{Api, ApiResource, DynamicObject, ListParams, LogParams, Patch, PatchParams};
use kube::Client;
/// Kubernetes executor for read and mutative commands.
pub struct KubernetesExecutor {
client: Client,
}
impl KubernetesExecutor {
pub fn new(client: Client) -> Self {
Self { client }
}
pub async fn execute(
&self,
verb: &str,
namespace: Option<&str>,
resource_type: Option<&str>,
resource_name: Option<&str>,
output_format: &str,
) -> ExecutionResult {
let result = match verb {
"get" => {
self.cmd_get(namespace, resource_type, resource_name, output_format)
.await
}
"describe" => self.cmd_describe(namespace, resource_type, resource_name).await,
"logs" => self.cmd_logs(namespace, resource_name).await,
"status" => self.cmd_status().await,
_ => Err(format!("unsupported verb: {verb}")),
};
match result {
Ok((output, count)) => {
let mut r = ExecutionResult::success(output);
r.resources_affected = count;
r
}
Err(e) => ExecutionResult::error(e),
}
}
async fn cmd_get(
&self,
namespace: Option<&str>,
resource_type: Option<&str>,
resource_name: Option<&str>,
output_format: &str,
) -> Result<(String, u32), String> {
let resource_type = resource_type.ok_or("resource_type required for get")?;
let ar =
resolve_resource(resource_type).ok_or_else(|| format!("unknown resource type: {resource_type}"))?;
let api: Api<DynamicObject> = match namespace {
Some(ns) => Api::namespaced_with(self.client.clone(), ns, &ar),
None => Api::all_with(self.client.clone(), &ar),
};
if let Some(name) = resource_name {
let obj = api.get(name).await.map_err(|e| e.to_string())?;
let output = format_output(&obj, output_format)?;
Ok((output, 1))
} else {
let list = api
.list(&ListParams::default())
.await
.map_err(|e| e.to_string())?;
let count = list.items.len() as u32;
let output = format_list(&list.items, output_format)?;
Ok((output, count))
}
}
async fn cmd_describe(
&self,
namespace: Option<&str>,
resource_type: Option<&str>,
resource_name: Option<&str>,
) -> Result<(String, u32), String> {
let resource_type = resource_type.ok_or("resource_type required for describe")?;
let resource_name = resource_name.ok_or("resource_name required for describe")?;
let ar =
resolve_resource(resource_type).ok_or_else(|| format!("unknown resource type: {resource_type}"))?;
let api: Api<DynamicObject> = match namespace {
Some(ns) => Api::namespaced_with(self.client.clone(), ns, &ar),
None => Api::all_with(self.client.clone(), &ar),
};
let obj = api
.get(resource_name)
.await
.map_err(|e| e.to_string())?;
let output = serde_json::to_string_pretty(&obj).map_err(|e| e.to_string())?;
Ok((output, 1))
}
async fn cmd_logs(
&self,
namespace: Option<&str>,
resource_name: Option<&str>,
) -> Result<(String, u32), String> {
let namespace = namespace.ok_or("namespace required for logs")?;
let pod_name = resource_name.ok_or("pod name required for logs")?;
let pods: Api<Pod> = Api::namespaced(self.client.clone(), namespace);
let logs = pods
.logs(
pod_name,
&LogParams {
tail_lines: Some(100),
..Default::default()
},
)
.await
.map_err(|e| e.to_string())?;
Ok((logs, 1))
}
async fn cmd_status(&self) -> Result<(String, u32), String> {
let nodes: Api<Node> = Api::all(self.client.clone());
let node_list = nodes
.list(&ListParams::default())
.await
.map_err(|e| e.to_string())?;
let mut lines = vec!["Cluster Status".to_string(), "=".repeat(40)];
let mut ready_count = 0u32;
let total = node_list.items.len() as u32;
for node in &node_list.items {
let name = node.metadata.name.as_deref().unwrap_or("unknown");
let ready = node
.status
.as_ref()
.and_then(|s| s.conditions.as_ref())
.and_then(|conditions| conditions.iter().find(|c| c.type_ == "Ready"))
.map(|c| c.status.as_str())
.unwrap_or("Unknown");
if ready == "True" {
ready_count += 1;
}
lines.push(format!(" {name}: {ready}"));
}
lines.push(String::new());
lines.push(format!("Nodes: {ready_count}/{total} Ready"));
Ok((lines.join("\n"), total))
}
/// Scale a deployment or statefulset.
pub async fn cmd_scale(
&self,
namespace: Option<&str>,
resource_type: Option<&str>,
resource_name: Option<&str>,
parameters: &Option<prost_types::Struct>,
) -> ExecutionResult {
let namespace = match namespace {
Some(ns) => ns,
None => return ExecutionResult::error("namespace required for scale".into()),
};
let resource_type = match resource_type {
Some(rt) => rt,
None => return ExecutionResult::error("resource_type required for scale".into()),
};
let name = match resource_name {
Some(n) => n,
None => return ExecutionResult::error("resource_name required for scale".into()),
};
// Extract replicas from parameters
let replicas = parameters
.as_ref()
.and_then(|s| s.fields.get("replicas"))
.and_then(|v| match &v.kind {
Some(prost_types::value::Kind::NumberValue(n)) => Some(*n as u32),
_ => None,
});
let replicas = match replicas {
Some(r) => r,
None => return ExecutionResult::error("replicas parameter required for scale".into()),
};
let ar = match resolve_resource(resource_type) {
Some(ar) => ar,
None => {
return ExecutionResult::error(format!("unknown resource type: {resource_type}"))
}
};
let api: Api<DynamicObject> =
Api::namespaced_with(self.client.clone(), namespace, &ar);
let patch = serde_json::json!({
"spec": { "replicas": replicas }
});
match api
.patch(name, &PatchParams::apply("bascule-gateway"), &Patch::Merge(&patch))
.await
{
Ok(_) => {
let mut r = ExecutionResult::success(format!(
"Scaled {resource_type}/{name} to {replicas} replicas in {namespace}"
));
r.resources_affected = 1;
r.mutations_applied = 1;
r
}
Err(e) => ExecutionResult::error(format!("scale failed: {e}")),
}
}
/// Apply a JSON merge patch to a resource.
pub async fn cmd_patch(
&self,
namespace: Option<&str>,
resource_type: Option<&str>,
resource_name: Option<&str>,
parameters: &Option<prost_types::Struct>,
) -> ExecutionResult {
let namespace = match namespace {
Some(ns) => ns,
None => return ExecutionResult::error("namespace required for patch".into()),
};
let resource_type = match resource_type {
Some(rt) => rt,
None => return ExecutionResult::error("resource_type required for patch".into()),
};
let name = match resource_name {
Some(n) => n,
None => return ExecutionResult::error("resource_name required for patch".into()),
};
// Extract patch body from parameters
let patch_body = parameters
.as_ref()
.and_then(|s| s.fields.get("patch"))
.and_then(|v| match &v.kind {
Some(prost_types::value::Kind::StringValue(s)) => {
serde_json::from_str::<serde_json::Value>(s).ok()
}
Some(prost_types::value::Kind::StructValue(s)) => {
Some(prost_struct_to_json(s))
}
_ => None,
});
let patch_body = match patch_body {
Some(p) => p,
None => return ExecutionResult::error("patch parameter required (JSON body)".into()),
};
let ar = match resolve_resource(resource_type) {
Some(ar) => ar,
None => {
return ExecutionResult::error(format!("unknown resource type: {resource_type}"))
}
};
let api: Api<DynamicObject> =
Api::namespaced_with(self.client.clone(), namespace, &ar);
match api
.patch(
name,
&PatchParams::apply("bascule-gateway"),
&Patch::Merge(&patch_body),
)
.await
{
Ok(_) => {
let mut r = ExecutionResult::success(format!(
"Patched {resource_type}/{name} in {namespace}"
));
r.resources_affected = 1;
r.mutations_applied = 1;
r
}
Err(e) => ExecutionResult::error(format!("patch failed: {e}")),
}
}
/// Rollback a deployment to the previous revision.
pub async fn cmd_rollback(
&self,
namespace: Option<&str>,
resource_name: Option<&str>,
parameters: &Option<prost_types::Struct>,
) -> ExecutionResult {
let namespace = match namespace {
Some(ns) => ns,
None => return ExecutionResult::error("namespace required for rollback".into()),
};
let name = match resource_name {
Some(n) => n,
None => {
return ExecutionResult::error("resource_name required for rollback".into())
}
};
// Extract optional revision from parameters
let _revision = parameters
.as_ref()
.and_then(|s| s.fields.get("revision"))
.and_then(|v| match &v.kind {
Some(prost_types::value::Kind::NumberValue(n)) => Some(*n as u64),
_ => None,
});
// Rollback by patching with a restart annotation (triggers rollout undo)
let ar = resolve_resource("deployments").unwrap();
let api: Api<DynamicObject> =
Api::namespaced_with(self.client.clone(), namespace, &ar);
// Use the kubectl.kubernetes.io/restartedAt annotation to force a rollout
let now = chrono::Utc::now().to_rfc3339();
let patch = serde_json::json!({
"spec": {
"template": {
"metadata": {
"annotations": {
"kubectl.kubernetes.io/restartedAt": now
}
}
}
}
});
match api
.patch(name, &PatchParams::apply("bascule-gateway"), &Patch::Merge(&patch))
.await
{
Ok(_) => {
let mut r = ExecutionResult::success(format!(
"Rolled back deployment/{name} in {namespace}"
));
r.resources_affected = 1;
r.mutations_applied = 1;
r
}
Err(e) => ExecutionResult::error(format!("rollback failed: {e}")),
}
}
}
fn prost_struct_to_json(s: &prost_types::Struct) -> serde_json::Value {
let mut map = serde_json::Map::new();
for (key, value) in &s.fields {
map.insert(key.clone(), prost_value_to_json(value));
}
serde_json::Value::Object(map)
}
fn prost_value_to_json(v: &prost_types::Value) -> serde_json::Value {
match &v.kind {
Some(prost_types::value::Kind::NullValue(_)) => serde_json::Value::Null,
Some(prost_types::value::Kind::NumberValue(n)) => {
serde_json::Value::Number(serde_json::Number::from_f64(*n).unwrap_or(serde_json::Number::from(0)))
}
Some(prost_types::value::Kind::StringValue(s)) => serde_json::Value::String(s.clone()),
Some(prost_types::value::Kind::BoolValue(b)) => serde_json::Value::Bool(*b),
Some(prost_types::value::Kind::StructValue(s)) => prost_struct_to_json(s),
Some(prost_types::value::Kind::ListValue(l)) => {
serde_json::Value::Array(l.values.iter().map(prost_value_to_json).collect())
}
None => serde_json::Value::Null,
}
}
/// Resolve a resource type string to an ApiResource for the kube dynamic API.
fn resolve_resource(resource_type: &str) -> Option<ApiResource> {
let (group, version, kind, plural) = match resource_type {
"pods" | "pod" | "po" => ("", "v1", "Pod", "pods"),
"deployments" | "deployment" | "deploy" => ("apps", "v1", "Deployment", "deployments"),
"services" | "service" | "svc" => ("", "v1", "Service", "services"),
"configmaps" | "configmap" | "cm" => ("", "v1", "ConfigMap", "configmaps"),
"secrets" | "secret" => ("", "v1", "Secret", "secrets"),
"nodes" | "node" | "no" => ("", "v1", "Node", "nodes"),
"namespaces" | "namespace" | "ns" => ("", "v1", "Namespace", "namespaces"),
"replicasets" | "replicaset" | "rs" => ("apps", "v1", "ReplicaSet", "replicasets"),
"statefulsets" | "statefulset" | "sts" => ("apps", "v1", "StatefulSet", "statefulsets"),
"daemonsets" | "daemonset" | "ds" => ("apps", "v1", "DaemonSet", "daemonsets"),
"jobs" | "job" => ("batch", "v1", "Job", "jobs"),
"cronjobs" | "cronjob" | "cj" => ("batch", "v1", "CronJob", "cronjobs"),
_ => return None,
};
Some(ApiResource {
group: group.into(),
version: version.into(),
api_version: if group.is_empty() {
version.into()
} else {
format!("{group}/{version}")
},
kind: kind.into(),
plural: plural.into(),
})
}
fn format_output(obj: &DynamicObject, format: &str) -> Result<String, String> {
match format {
"json" => serde_json::to_string_pretty(obj).map_err(|e| e.to_string()),
_ => {
// Table format: show basic info
let name = obj.metadata.name.as_deref().unwrap_or("unknown");
let ns = obj.metadata.namespace.as_deref().unwrap_or("");
let age = obj
.metadata
.creation_timestamp
.as_ref()
.map(|t| format_age(&t.0))
.unwrap_or_else(|| "unknown".into());
Ok(format!("{name}\t{ns}\t{age}"))
}
}
}
fn format_list(items: &[DynamicObject], format: &str) -> Result<String, String> {
match format {
"json" => serde_json::to_string_pretty(items).map_err(|e| e.to_string()),
_ => {
// Table format
let mut lines = vec![format!(
"{:<40} {:<20} {}",
"NAME", "NAMESPACE", "AGE"
)];
for obj in items {
let name = obj.metadata.name.as_deref().unwrap_or("unknown");
let ns = obj.metadata.namespace.as_deref().unwrap_or("");
let age = obj
.metadata
.creation_timestamp
.as_ref()
.map(|t| format_age(&t.0))
.unwrap_or_else(|| "unknown".into());
lines.push(format!("{:<40} {:<20} {}", name, ns, age));
}
Ok(lines.join("\n"))
}
}
}
fn format_age(created: &chrono::DateTime<chrono::Utc>) -> String {
let duration = chrono::Utc::now() - *created;
if duration.num_days() > 0 {
format!("{}d", duration.num_days())
} else if duration.num_hours() > 0 {
format!("{}h", duration.num_hours())
} else {
format!("{}m", duration.num_minutes())
}
}

View file

@ -0,0 +1,68 @@
pub mod k8s;
use bascule_core::audit::ExecutionResult;
use crate::filter::RequestContext;
/// Registry of command executors, dispatching by verb.
pub struct ExecutorRegistry {
k8s: k8s::KubernetesExecutor,
}
impl ExecutorRegistry {
pub fn new(client: kube::Client) -> Self {
Self {
k8s: k8s::KubernetesExecutor::new(client),
}
}
/// Route a command to the appropriate executor and return the result.
pub async fn execute(&self, ctx: &RequestContext) -> ExecutionResult {
match ctx.command.verb.as_str() {
// Read operations
"get" | "describe" | "logs" | "status" => {
self.k8s
.execute(
&ctx.command.verb,
ctx.command.namespace.as_deref(),
ctx.command.resource_type.as_deref(),
ctx.command.resource_name.as_deref(),
&ctx.command.output_format,
)
.await
}
// Mutative operations
"scale" => {
self.k8s
.cmd_scale(
ctx.command.namespace.as_deref(),
ctx.command.resource_type.as_deref(),
ctx.command.resource_name.as_deref(),
&ctx.command.parameters,
)
.await
}
"patch" => {
self.k8s
.cmd_patch(
ctx.command.namespace.as_deref(),
ctx.command.resource_type.as_deref(),
ctx.command.resource_name.as_deref(),
&ctx.command.parameters,
)
.await
}
"rollback" => {
self.k8s
.cmd_rollback(
ctx.command.namespace.as_deref(),
ctx.command.resource_name.as_deref(),
&ctx.command.parameters,
)
.await
}
// Session commands are handled directly by the server, not routed here
verb => ExecutionResult::error(format!("unknown verb: {verb}")),
}
}
}

View file

@ -0,0 +1,102 @@
use std::sync::Arc;
use bascule_core::audit::{ExecutionResult, ExecutionStatus, PolicyDecision};
use bascule_core::command::ChangeClassification;
use super::RequestContext;
use crate::audit_pipeline::{build_audit_event, should_notarize, AuditPipeline};
/// Log the audit event and submit to the pipeline. Returns an AuditRef for the response.
pub async fn log_and_submit(
pipeline: &Arc<AuditPipeline>,
ctx: &RequestContext,
exec_result: Option<&ExecutionResult>,
) -> bascule_proto::bascule_v1::AuditRef {
let elapsed = ctx.started_at.elapsed();
let classification = ctx.classification.unwrap_or(ChangeClassification::Read);
// Build execution result (use provided or create a default)
let result = exec_result.cloned().unwrap_or_else(|| ExecutionResult {
status: if ctx
.policy_decision
.as_ref()
.map(|d| !d.allowed)
.unwrap_or(false)
{
ExecutionStatus::Denied
} else {
ExecutionStatus::Success
},
summary: String::new(),
resources_affected: ctx.resources_affected,
mutations_applied: 0,
});
let policy_decision = ctx
.policy_decision
.clone()
.unwrap_or_else(PolicyDecision::allow_all_stub);
// Log via tracing
tracing::info!(
session_id = ctx.session.as_ref().map(|s| s.session_id.to_string()).unwrap_or_default(),
operator = ctx.identity.as_ref().map(|i| i.display_id()).unwrap_or_default(),
verb = ctx.command.verb,
namespace = ctx.command.namespace.as_deref().unwrap_or(""),
resource_type = ctx.command.resource_type.as_deref().unwrap_or(""),
resource_name = ctx.command.resource_name.as_deref().unwrap_or(""),
classification = ?classification,
allowed = policy_decision.allowed,
status = ?result.status,
resources_affected = result.resources_affected,
elapsed_ms = elapsed.as_millis() as u64,
"audit: command executed"
);
// Build the full audit event
let session_id = ctx
.session
.as_ref()
.map(|s| s.session_id)
.unwrap_or_default();
if let Some(identity) = &ctx.identity {
// Determine ledger fidelity from OPA decision
let fidelity = ctx
.opa_decision
.as_ref()
.map(|d| d.ledger_fidelity.as_str())
.unwrap_or("default");
let notarize = should_notarize(classification, fidelity);
let event = build_audit_event(
session_id,
identity,
&ctx.command,
classification,
&policy_decision,
&result,
);
let event_id = event.event_id.to_string();
// Submit asynchronously — don't block the response
let pipeline = pipeline.clone();
tokio::spawn(async move {
pipeline.submit(&event, notarize).await;
});
bascule_proto::bascule_v1::AuditRef {
event_id,
classification: format!("{classification:?}").to_lowercase(),
notarized: notarize,
}
} else {
bascule_proto::bascule_v1::AuditRef {
event_id: uuid::Uuid::new_v4().to_string(),
classification: format!("{classification:?}").to_lowercase(),
notarized: false,
}
}
}

View file

@ -0,0 +1,27 @@
use std::sync::Arc;
use super::{deny_response, FilterResult, RequestContext};
use crate::auth::OidcAuthProvider;
/// Validate the bearer token and extract operator identity.
pub async fn apply(
provider: &Arc<OidcAuthProvider>,
ctx: &mut RequestContext,
) -> FilterResult {
let token = match &ctx.bearer_token {
Some(t) => t.clone(),
None => return FilterResult::Respond(deny_response("missing authorization token")),
};
match provider.validate_token(&token).await {
Ok(identity) => {
tracing::debug!(identity = %identity.display_id(), "Auth filter: identity verified");
ctx.identity = Some(identity);
FilterResult::Continue
}
Err(e) => {
tracing::warn!(error = %e, "Auth filter: token validation failed");
FilterResult::Respond(deny_response(&format!("authentication failed: {e}")))
}
}
}

View file

@ -0,0 +1,30 @@
use std::sync::Arc;
use bascule_core::command::ChangeClassification;
use super::{deny_response, FilterResult, RequestContext};
use crate::session_manager::SessionManager;
/// Check the mutation budget before allowing mutative commands.
pub async fn apply(manager: &Arc<SessionManager>, ctx: &mut RequestContext) -> FilterResult {
// Only check budget for mutative commands
if ctx.classification != Some(ChangeClassification::Mutative) {
return FilterResult::Continue;
}
let session = match &ctx.session {
Some(s) => s,
None => return FilterResult::Continue,
};
if session.budget_exhausted() {
return FilterResult::Respond(deny_response(
"mutation budget exhausted — request a new session",
));
}
// Record the mutation (updates both DashMap and PG)
manager.record_mutation(&session.session_id).await;
FilterResult::Continue
}

View file

@ -0,0 +1,62 @@
use bascule_core::command::ChangeClassification;
use super::{FilterResult, RequestContext};
/// Classify the command verb into read/mutative/workspace/session.
pub fn apply(ctx: &mut RequestContext) -> FilterResult {
let classification = classify_verb(&ctx.command.verb);
ctx.classification = Some(classification);
FilterResult::Continue
}
fn classify_verb(verb: &str) -> ChangeClassification {
match verb {
// Read operations
"get" | "describe" | "logs" | "status" | "profiles_list" | "profiles_get" | "topology" => {
ChangeClassification::Read
}
// Mutative operations
"scale" | "patch" | "apply" | "rollback" | "exec" | "drain" => {
ChangeClassification::Mutative
}
// Workspace operations
"ws_status" | "ws_diff" | "ws_simulate" | "ws_commit" | "ws_submit" | "ws_abandon" => {
ChangeClassification::Workspace
}
// Session operations
"session_status" | "session_extend" | "session_delegate" | "session_end" => {
ChangeClassification::Session
}
// Audit operations (read classification)
"audit_trail" | "audit_verify" | "audit_session" => ChangeClassification::Read,
// Unknown defaults to mutative (most restrictive)
_ => {
tracing::warn!(verb, "Unknown command verb, classifying as mutative");
ChangeClassification::Mutative
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn read_verbs_classified_correctly() {
assert_eq!(classify_verb("get"), ChangeClassification::Read);
assert_eq!(classify_verb("describe"), ChangeClassification::Read);
assert_eq!(classify_verb("logs"), ChangeClassification::Read);
assert_eq!(classify_verb("status"), ChangeClassification::Read);
}
#[test]
fn mutative_verbs_classified_correctly() {
assert_eq!(classify_verb("scale"), ChangeClassification::Mutative);
assert_eq!(classify_verb("patch"), ChangeClassification::Mutative);
}
#[test]
fn unknown_verbs_default_to_mutative() {
assert_eq!(classify_verb("unknown"), ChangeClassification::Mutative);
}
}

View file

@ -0,0 +1,190 @@
pub mod audit;
pub mod auth;
pub mod budget;
pub mod classify;
pub mod policy;
pub mod response;
pub mod session;
use std::sync::Arc;
use std::time::Instant;
use accord_core::schema::Accord;
use accord_opa::OpaClient;
use bascule_core::audit::PolicyDecision;
use bascule_core::command::ChangeClassification;
use bascule_core::session::{OperatorIdentity, Session};
use bascule_proto::bascule_v1::{ExecuteCommandRequest, ExecuteCommandResponse};
use crate::audit_pipeline::AuditPipeline;
use crate::auth::OidcAuthProvider;
use crate::executor::ExecutorRegistry;
use crate::session_manager::SessionManager;
/// Context accumulated as a request passes through the filter chain.
pub struct RequestContext {
pub identity: Option<OperatorIdentity>,
pub session: Option<Session>,
pub classification: Option<ChangeClassification>,
pub policy_decision: Option<PolicyDecision>,
/// OPA policy decision from accord-opa (richer than bascule-core's PolicyDecision).
pub opa_decision: Option<accord_opa::PolicyDecision>,
pub command: ExecuteCommandRequest,
pub started_at: Instant,
/// The bearer token extracted from gRPC metadata.
pub bearer_token: Option<String>,
/// Output from execution.
pub output: Option<String>,
pub resources_affected: u32,
}
impl RequestContext {
pub fn new(command: ExecuteCommandRequest, bearer_token: Option<String>) -> Self {
Self {
identity: None,
session: None,
classification: None,
policy_decision: None,
opa_decision: None,
command,
started_at: Instant::now(),
bearer_token,
output: None,
resources_affected: 0,
}
}
}
/// Result of a filter step.
pub enum FilterResult {
/// Continue to the next filter.
Continue,
/// Short-circuit with this response.
Respond(ExecuteCommandResponse),
}
/// The ordered filter chain that processes every command.
pub struct FilterChain {
auth_provider: Arc<OidcAuthProvider>,
session_manager: Arc<SessionManager>,
executor_registry: Arc<ExecutorRegistry>,
opa_client: Arc<OpaClient>,
accord: Arc<Accord>,
audit_pipeline: Arc<AuditPipeline>,
}
impl FilterChain {
pub fn new(
auth_provider: Arc<OidcAuthProvider>,
session_manager: Arc<SessionManager>,
executor_registry: Arc<ExecutorRegistry>,
opa_client: Arc<OpaClient>,
accord: Arc<Accord>,
audit_pipeline: Arc<AuditPipeline>,
) -> Self {
Self {
auth_provider,
session_manager,
executor_registry,
opa_client,
accord,
audit_pipeline,
}
}
/// Execute the full filter chain for a command request.
pub async fn execute(
&self,
bearer_token: Option<String>,
command: ExecuteCommandRequest,
) -> ExecuteCommandResponse {
let mut ctx = RequestContext::new(command, bearer_token);
// 1. Auth filter — validate token, extract identity
match auth::apply(&self.auth_provider, &mut ctx).await {
FilterResult::Respond(resp) => return resp,
FilterResult::Continue => {}
}
// 2. Session filter — look up session, verify active
match session::apply(&self.session_manager, &mut ctx) {
FilterResult::Respond(resp) => return resp,
FilterResult::Continue => {}
}
// 3. Classification filter — classify the command verb
match classify::apply(&mut ctx) {
FilterResult::Respond(resp) => return resp,
FilterResult::Continue => {}
}
// 4. Policy filter — evaluate via OPA
match policy::apply(&self.opa_client, &self.accord, &mut ctx).await {
FilterResult::Respond(resp) => {
// Audit denied commands too
audit::log_and_submit(&self.audit_pipeline, &ctx, None).await;
return resp;
}
FilterResult::Continue => {}
}
// 5. Budget filter — check mutation budget
match budget::apply(&self.session_manager, &mut ctx).await {
FilterResult::Respond(resp) => {
audit::log_and_submit(&self.audit_pipeline, &ctx, None).await;
return resp;
}
FilterResult::Continue => {}
}
// 6. Route to executor
let exec_result = self.executor_registry.execute(&ctx).await;
ctx.output = Some(exec_result.summary.clone());
ctx.resources_affected = exec_result.resources_affected;
// 7. Response filter — sanitize output
let output = response::sanitize(ctx.output.as_deref().unwrap_or(""));
// 8. Audit filter — log and submit event
let audit_ref = audit::log_and_submit(&self.audit_pipeline, &ctx, Some(&exec_result)).await;
// Build final response
let session_expired = ctx
.session
.as_ref()
.map(|s| !s.is_active())
.unwrap_or(false);
ExecuteCommandResponse {
allowed: true,
denied_reason: String::new(),
result: Some(
bascule_proto::bascule_v1::execute_command_response::Result::Success(
bascule_proto::bascule_v1::CommandResult {
output,
resources_affected: ctx.resources_affected,
session_expired_warning: session_expired,
},
),
),
audit: Some(audit_ref),
}
}
}
/// Helper to build a denial response.
pub fn deny_response(reason: &str) -> ExecuteCommandResponse {
ExecuteCommandResponse {
allowed: false,
denied_reason: reason.to_string(),
result: Some(
bascule_proto::bascule_v1::execute_command_response::Result::Error(
bascule_proto::bascule_v1::CommandError {
message: reason.to_string(),
code: "DENIED".to_string(),
},
),
),
audit: None,
}
}

View file

@ -0,0 +1,161 @@
use std::sync::Arc;
use std::time::Instant;
use accord_core::schema::Accord;
use accord_opa::input::{
GrantedScope, PolicyInput, RequestContext as OpaRequestContext, ResourceRef as OpaResourceRef,
SessionContext,
};
use accord_opa::OpaClient;
use bascule_core::audit::PolicyDecision;
use bascule_core::command::ChangeClassification;
use super::{deny_response, FilterResult, RequestContext};
/// Evaluate the command against OPA policy. Fail-closed: if OPA is unreachable, deny.
pub async fn apply(
opa: &Arc<OpaClient>,
accord: &Arc<Accord>,
ctx: &mut RequestContext,
) -> FilterResult {
let session = match &ctx.session {
Some(s) => s,
None => return FilterResult::Continue,
};
let identity = match &ctx.identity {
Some(i) => i,
None => return FilterResult::Continue,
};
let is_mutative = ctx.classification == Some(ChangeClassification::Mutative);
// Build the OPA input
let session_ctx = SessionContext {
operator_identity: identity.display_id(),
operator_roles: vec![],
ceremony_ref: session.ceremony_id.to_string(),
ceremony_type: "self_grant".to_string(),
granted_scopes: session
.scope
.namespaces
.iter()
.map(|ns| GrantedScope {
namespace: ns.namespace.clone(),
api_groups: ns
.rules
.iter()
.flat_map(|r| r.api_groups.clone())
.collect(),
resources: ns
.rules
.iter()
.flat_map(|r| r.resources.clone())
.collect(),
verbs: ns
.rules
.iter()
.flat_map(|r| r.verbs.iter().map(|v| v.as_str().to_string()))
.collect(),
})
.collect(),
session_start: session.valid_from,
session_expires: session.expires_at,
mutations_used: session.mutations_used,
mutation_budget: session.scope.mutation_budget,
};
let resources: Vec<OpaResourceRef> =
if let (Some(rt), Some(ns)) = (&ctx.command.resource_type, &ctx.command.namespace) {
vec![OpaResourceRef {
api_group: resolve_api_group(rt),
kind: rt.clone(),
namespace: ns.clone(),
name: ctx.command.resource_name.clone().unwrap_or_default(),
}]
} else {
vec![]
};
let request_ctx = OpaRequestContext {
pathway: "imperative".to_string(),
operation: ctx.command.verb.clone(),
resources,
is_mutative,
};
let input = PolicyInput::for_command(request_ctx, session_ctx, None, accord);
let start = Instant::now();
let result = opa.evaluate_policy(&input).await;
let eval_ms = start.elapsed().as_millis() as u32;
match result {
Ok(decision) => {
tracing::debug!(
allowed = decision.allowed,
classification = %decision.classification,
requires_ceremony = decision.requires_ceremony,
eval_ms,
"OPA policy evaluated"
);
// Convert OPA decision to bascule-core PolicyDecision
ctx.policy_decision = Some(PolicyDecision {
allowed: decision.is_permitted(),
policy_bundle_hash: String::new(),
accord_version: accord.metadata.version.clone(),
evaluation_duration_ms: eval_ms,
denied_reason: if decision.denial_reasons.is_empty() {
None
} else {
Some(decision.denial_reasons.join("; "))
},
});
ctx.opa_decision = Some(decision.clone());
if decision.is_permitted() {
FilterResult::Continue
} else {
let reason = if !decision.denial_reasons.is_empty() {
decision.denial_reasons.join("; ")
} else if decision.requires_ceremony {
format!(
"insufficient ceremony: requires {}",
decision.ceremony_type
)
} else {
"policy denied".to_string()
};
FilterResult::Respond(deny_response(&reason))
}
}
Err(e) => {
tracing::error!("OPA evaluation failed (fail-closed): {e}");
ctx.policy_decision = Some(PolicyDecision {
allowed: false,
policy_bundle_hash: String::new(),
accord_version: accord.metadata.version.clone(),
evaluation_duration_ms: eval_ms,
denied_reason: Some(format!("policy evaluation failed: {e}")),
});
FilterResult::Respond(deny_response(&format!(
"policy evaluation unavailable: {e}"
)))
}
}
}
fn resolve_api_group(resource_type: &str) -> String {
match resource_type {
"deployments" | "deployment" | "deploy" | "replicasets" | "replicaset" | "rs"
| "statefulsets" | "statefulset" | "sts" | "daemonsets" | "daemonset" | "ds" => {
"apps".to_string()
}
"jobs" | "job" | "cronjobs" | "cronjob" | "cj" => "batch".to_string(),
"clusterroles" | "clusterrolebindings" | "roles" | "rolebindings" => {
"rbac.authorization.k8s.io".to_string()
}
_ => String::new(),
}
}

View file

@ -0,0 +1,49 @@
/// Sanitize output before returning to the shell.
/// Strips known secret patterns from command output.
pub fn sanitize(output: &str) -> String {
let mut sanitized = output.to_string();
// Strip base64-encoded data fields from Kubernetes Secrets
// Pattern: data fields in Secret output that might contain credentials
let secret_patterns = [
"password:",
"token:",
"secret-key:",
"aws-secret-access-key:",
"client-secret:",
];
for pattern in &secret_patterns {
if let Some(idx) = sanitized.to_lowercase().find(pattern) {
// Find the end of the value (next newline or end of string)
let value_start = idx + pattern.len();
let value_end = sanitized[value_start..]
.find('\n')
.map(|i| value_start + i)
.unwrap_or(sanitized.len());
sanitized.replace_range(value_start..value_end, " <REDACTED>");
}
}
sanitized
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sanitize_strips_password_values() {
let input = "username: admin\npassword: s3cret123\nhost: localhost";
let output = sanitize(input);
assert!(output.contains("<REDACTED>"));
assert!(!output.contains("s3cret123"));
assert!(output.contains("admin"));
}
#[test]
fn sanitize_preserves_non_secret_output() {
let input = "NAME READY STATUS RESTARTS AGE\nnginx-abc 1/1 Running 0 5m";
assert_eq!(sanitize(input), input);
}
}

View file

@ -0,0 +1,28 @@
use std::sync::Arc;
use super::{deny_response, FilterResult, RequestContext};
use crate::session_manager::SessionManager;
/// Look up the session and verify it is still active.
pub fn apply(manager: &Arc<SessionManager>, ctx: &mut RequestContext) -> FilterResult {
let session_id = match uuid::Uuid::parse_str(&ctx.command.session_id) {
Ok(id) => id,
Err(_) => {
return FilterResult::Respond(deny_response("invalid session_id format"));
}
};
let session = match manager.get_session(&session_id) {
Some(s) => s,
None => {
return FilterResult::Respond(deny_response("session not found"));
}
};
if !session.is_active() {
return FilterResult::Respond(deny_response("session is no longer active"));
}
ctx.session = Some(session);
FilterResult::Continue
}

View file

@ -0,0 +1,745 @@
//! Governance Ceremony gRPC service — implements the CeremonyService
//! proto for multi-stakeholder approval flows.
use std::sync::Arc;
use bascule_core::ceremony_engine::CeremonyEngine;
use bascule_core::ceremony_request::{
ApprovalDecision, CeremonySubject, GovernanceCeremonyStatus,
};
use bascule_core::ceremony_resolution::CeremonyResolution;
use bascule_core::ceremony_store::CeremonyStore;
use bascule_proto::bascule_v1::*;
use tonic::{Request, Response, Status};
use tracing::info;
pub struct GovernanceCeremonyService {
store: Arc<dyn CeremonyStore>,
}
impl GovernanceCeremonyService {
pub fn new(store: Arc<dyn CeremonyStore>) -> Self {
Self { store }
}
}
fn status_to_string(status: GovernanceCeremonyStatus) -> String {
match status {
GovernanceCeremonyStatus::Pending => "pending".to_string(),
GovernanceCeremonyStatus::Approved => "approved".to_string(),
GovernanceCeremonyStatus::Denied => "denied".to_string(),
GovernanceCeremonyStatus::Expired => "expired".to_string(),
GovernanceCeremonyStatus::Cancelled => "cancelled".to_string(),
}
}
fn to_proto_timestamp(dt: &chrono::DateTime<chrono::Utc>) -> prost_types::Timestamp {
prost_types::Timestamp {
seconds: dt.timestamp(),
nanos: dt.timestamp_subsec_nanos() as i32,
}
}
fn ceremony_to_response(
cer: &bascule_core::ceremony_request::GovernanceCeremonyRequest,
) -> GetCeremonyResponse {
GetCeremonyResponse {
ceremony_id: cer.ceremony_id.clone(),
ceremony_type: format!("{:?}", cer.ceremony_type).to_lowercase(),
subject: Some(CeremonySubjectMsg {
subject_type: match &cer.subject {
CeremonySubject::MutationIntent { .. } => "mutation_intent".to_string(),
CeremonySubject::PipelineMerge { .. } => "pipeline_merge".to_string(),
CeremonySubject::SchematicPublish { .. } => "schematic_publish".to_string(),
CeremonySubject::Custom { .. } => "custom".to_string(),
CeremonySubject::GitOpsSync { .. } => "gitops_sync".to_string(),
},
reference_id: match &cer.subject {
CeremonySubject::MutationIntent { intent_id, .. } => intent_id.clone(),
CeremonySubject::PipelineMerge { run_id, .. } => run_id.clone(),
CeremonySubject::SchematicPublish {
schematic_name,
version,
..
} => format!("{schematic_name}:{version}"),
CeremonySubject::Custom { reference_id, .. } => reference_id.clone(),
CeremonySubject::GitOpsSync {
resource_name,
resource_namespace,
..
} => format!("{resource_namespace}/{resource_name}"),
},
description: cer.subject.display_label(),
metadata: std::collections::HashMap::new(),
}),
status: status_to_string(cer.status),
required_approvals: cer.required_approvals,
current_approvals: cer.approval_count(),
approvals: cer
.approvals
.iter()
.map(|a| CeremonyApprovalMsg {
approver_identity: a.approver_identity.clone(),
approver_role: a.approver_role.clone(),
decision: match a.decision {
ApprovalDecision::Approve => "approve".to_string(),
ApprovalDecision::Deny => "deny".to_string(),
},
comment: a.comment.clone().unwrap_or_default(),
decided_at: Some(to_proto_timestamp(&a.decided_at)),
})
.collect(),
created_at: Some(to_proto_timestamp(&cer.created_at)),
expires_at: Some(to_proto_timestamp(&cer.expires_at)),
intent_id: cer.intent_id.clone().unwrap_or_default(),
run_id: cer.run_id.clone().unwrap_or_default(),
pr_number: cer.pr_number.unwrap_or(0),
remote_name: cer.remote_name.clone().unwrap_or_default(),
error: String::new(),
}
}
fn parse_ceremony_type(s: &str) -> accord_core::schema::CeremonyType {
match s {
"self_grant" | "selfgrant" => accord_core::schema::CeremonyType::SelfGrant,
"autonomous" => accord_core::schema::CeremonyType::Autonomous,
"break_glass" | "breakglass" => accord_core::schema::CeremonyType::BreakGlass,
"single_approval" | "singleapproval" => {
accord_core::schema::CeremonyType::SingleApproval
}
"quorum_approval" | "quorumapproval" => {
accord_core::schema::CeremonyType::QuorumApproval
}
"inherit" => accord_core::schema::CeremonyType::Inherit,
_ => accord_core::schema::CeremonyType::SingleApproval,
}
}
fn parse_subject(msg: &CeremonySubjectMsg) -> CeremonySubject {
match msg.subject_type.as_str() {
"mutation_intent" => CeremonySubject::MutationIntent {
intent_id: msg.reference_id.clone(),
registry_type: msg.metadata.get("registry_type").cloned().unwrap_or_default(),
verb: msg.metadata.get("verb").cloned().unwrap_or_default(),
artifact_scope: msg.metadata.get("artifact_scope").cloned().unwrap_or_default(),
tenant_id: msg.metadata.get("tenant_id").cloned().unwrap_or_default(),
},
"pipeline_merge" => CeremonySubject::PipelineMerge {
run_id: msg.reference_id.clone(),
pipeline_name: msg.metadata.get("pipeline_name").cloned().unwrap_or_default(),
branch: msg.metadata.get("branch").cloned().unwrap_or_default(),
commit_hash: msg.metadata.get("commit_hash").cloned().unwrap_or_default(),
remote_name: msg.metadata.get("remote_name").cloned().unwrap_or_default(),
},
"schematic_publish" => {
let parts: Vec<&str> = msg.reference_id.splitn(2, ':').collect();
CeremonySubject::SchematicPublish {
schematic_name: parts.first().unwrap_or(&"").to_string(),
version: parts.get(1).unwrap_or(&"").to_string(),
tree_hash: msg.metadata.get("tree_hash").cloned().unwrap_or_default(),
}
}
_ => CeremonySubject::Custom {
subject_type: msg.subject_type.clone(),
reference_id: msg.reference_id.clone(),
description: msg.description.clone(),
},
}
}
#[tonic::async_trait]
impl ceremony_service_server::CeremonyService for GovernanceCeremonyService {
async fn create_ceremony(
&self,
request: Request<CreateCeremonyRequest>,
) -> Result<Response<CreateCeremonyResponse>, Status> {
let req = request.into_inner();
let ceremony_type = parse_ceremony_type(&req.ceremony_type);
let subject = req
.subject
.as_ref()
.map(parse_subject)
.ok_or_else(|| Status::invalid_argument("subject is required"))?;
let ceremony_id = uuid::Uuid::new_v4().to_string();
let ttl = if req.ttl_hours == 0 { 24 } else { req.ttl_hours };
let reqs = accord_core::schema::CeremonyReqs {
approver_roles: if req.approver_roles.is_empty() {
None
} else {
Some(req.approver_roles.clone())
},
quorum: if req.required_approvals > 0 {
Some(req.required_approvals)
} else {
None
},
..Default::default()
};
let mut ceremony =
CeremonyEngine::create_request(ceremony_id, &ceremony_type, &reqs, subject, ttl);
// Attach optional links
if !req.intent_id.is_empty() {
ceremony.intent_id = Some(req.intent_id);
}
if !req.run_id.is_empty() {
ceremony.run_id = Some(req.run_id);
}
if req.pr_number > 0 {
ceremony.pr_number = Some(req.pr_number);
}
if !req.remote_name.is_empty() {
ceremony.remote_name = Some(req.remote_name);
}
self.store.create(&ceremony).await.map_err(|e| {
Status::internal(format!("failed to create ceremony: {e}"))
})?;
info!(
ceremony_id = %ceremony.ceremony_id,
ceremony_type = ?ceremony.ceremony_type,
status = ?ceremony.status,
"Governance ceremony created"
);
Ok(Response::new(CreateCeremonyResponse {
ceremony_id: ceremony.ceremony_id.clone(),
status: status_to_string(ceremony.status),
expires_at: Some(to_proto_timestamp(&ceremony.expires_at)),
error: String::new(),
}))
}
async fn approve_ceremony(
&self,
request: Request<ApproveCeremonyRequest>,
) -> Result<Response<ApproveCeremonyResponse>, Status> {
let req = request.into_inner();
let mut ceremony = self
.store
.get(&req.ceremony_id)
.await
.map_err(|e| Status::internal(e.to_string()))?
.ok_or_else(|| Status::not_found("ceremony not found"))?;
let comment = if req.comment.is_empty() {
None
} else {
Some(req.comment)
};
ceremony
.record_decision(
&req.approver_identity,
&req.approver_role,
ApprovalDecision::Approve,
comment,
)
.map_err(|e| Status::failed_precondition(e.to_string()))?;
// Evaluate if threshold is met
CeremonyEngine::evaluate(&mut ceremony);
self.store.update(&ceremony).await.map_err(|e| {
Status::internal(format!("failed to update ceremony: {e}"))
})?;
info!(
ceremony_id = %ceremony.ceremony_id,
approver = %req.approver_identity,
status = ?ceremony.status,
"Ceremony approval recorded"
);
Ok(Response::new(ApproveCeremonyResponse {
success: true,
status: status_to_string(ceremony.status),
error: String::new(),
}))
}
async fn deny_ceremony(
&self,
request: Request<DenyCeremonyRequest>,
) -> Result<Response<DenyCeremonyResponse>, Status> {
let req = request.into_inner();
let mut ceremony = self
.store
.get(&req.ceremony_id)
.await
.map_err(|e| Status::internal(e.to_string()))?
.ok_or_else(|| Status::not_found("ceremony not found"))?;
let comment = if req.comment.is_empty() {
None
} else {
Some(req.comment)
};
ceremony
.record_decision(
&req.approver_identity,
&req.approver_role,
ApprovalDecision::Deny,
comment,
)
.map_err(|e| Status::failed_precondition(e.to_string()))?;
CeremonyEngine::evaluate(&mut ceremony);
self.store.update(&ceremony).await.map_err(|e| {
Status::internal(format!("failed to update ceremony: {e}"))
})?;
info!(
ceremony_id = %ceremony.ceremony_id,
denier = %req.approver_identity,
"Ceremony denied"
);
Ok(Response::new(DenyCeremonyResponse {
success: true,
status: status_to_string(ceremony.status),
error: String::new(),
}))
}
async fn cancel_ceremony(
&self,
request: Request<CancelCeremonyRequest>,
) -> Result<Response<CancelCeremonyResponse>, Status> {
let req = request.into_inner();
let mut ceremony = self
.store
.get(&req.ceremony_id)
.await
.map_err(|e| Status::internal(e.to_string()))?
.ok_or_else(|| Status::not_found("ceremony not found"))?;
ceremony
.cancel()
.map_err(|e| Status::failed_precondition(e.to_string()))?;
self.store.update(&ceremony).await.map_err(|e| {
Status::internal(format!("failed to update ceremony: {e}"))
})?;
info!(ceremony_id = %ceremony.ceremony_id, "Ceremony cancelled");
Ok(Response::new(CancelCeremonyResponse {
success: true,
error: String::new(),
}))
}
async fn get_ceremony(
&self,
request: Request<GetCeremonyRequest>,
) -> Result<Response<GetCeremonyResponse>, Status> {
let req = request.into_inner();
let ceremony = self
.store
.get(&req.ceremony_id)
.await
.map_err(|e| Status::internal(e.to_string()))?
.ok_or_else(|| Status::not_found("ceremony not found"))?;
Ok(Response::new(ceremony_to_response(&ceremony)))
}
async fn list_pending_ceremonies(
&self,
request: Request<ListPendingCeremoniesRequest>,
) -> Result<Response<ListPendingCeremoniesResponse>, Status> {
let req = request.into_inner();
let intent_filter = if req.intent_id.is_empty() {
None
} else {
Some(req.intent_id.as_str())
};
let ceremonies = self
.store
.list_pending(intent_filter)
.await
.map_err(|e| Status::internal(e.to_string()))?;
Ok(Response::new(ListPendingCeremoniesResponse {
ceremonies: ceremonies.iter().map(ceremony_to_response).collect(),
}))
}
async fn get_ceremony_proof(
&self,
request: Request<GetCeremonyProofRequest>,
) -> Result<Response<GetCeremonyProofResponse>, Status> {
let req = request.into_inner();
let ceremony = self
.store
.get(&req.ceremony_id)
.await
.map_err(|e| Status::internal(e.to_string()))?
.ok_or_else(|| Status::not_found("ceremony not found"))?;
if !ceremony.status.is_terminal() {
return Err(Status::failed_precondition(
"ceremony has not been resolved yet",
));
}
let resolution = CeremonyResolution::from_ceremony(
&ceremony.ceremony_id,
ceremony.status,
&ceremony.subject,
&ceremony.approvals,
);
Ok(Response::new(GetCeremonyProofResponse {
ceremony_id: resolution.ceremony_id,
status: status_to_string(resolution.status),
proof_hash: resolution.proof_hash,
approvals: resolution
.approvals
.iter()
.map(|a| CeremonyApprovalMsg {
approver_identity: a.approver_identity.clone(),
approver_role: a.approver_role.clone(),
decision: match a.decision {
ApprovalDecision::Approve => "approve".to_string(),
ApprovalDecision::Deny => "deny".to_string(),
},
comment: a.comment.clone().unwrap_or_default(),
decided_at: Some(to_proto_timestamp(&a.decided_at)),
})
.collect(),
resolved_at: Some(to_proto_timestamp(&resolution.resolved_at)),
error: String::new(),
}))
}
}
impl GovernanceCeremonyService {
/// Expire all pending ceremonies that have passed their `expires_at`.
/// Returns the number of ceremonies expired.
pub async fn expire_pending(&self) -> usize {
let now = chrono::Utc::now();
let expired = match self.store.find_expired(now).await {
Ok(e) => e,
Err(e) => {
tracing::error!(error = %e, "Failed to find expired ceremonies");
return 0;
}
};
let mut count = 0;
for mut cer in expired {
if CeremonyEngine::evaluate(&mut cer) {
if let Err(e) = self.store.update(&cer).await {
tracing::error!(
ceremony_id = %cer.ceremony_id,
error = %e,
"Failed to expire ceremony"
);
} else {
info!(
ceremony_id = %cer.ceremony_id,
"Ceremony expired"
);
count += 1;
}
}
}
count
}
}
#[cfg(test)]
mod tests {
use super::*;
use bascule_core::ceremony_store::InMemoryCeremonyStore;
use bascule_proto::bascule_v1::ceremony_service_server::CeremonyService;
fn build_service() -> GovernanceCeremonyService {
GovernanceCeremonyService::new(Arc::new(InMemoryCeremonyStore::new()))
}
fn merge_subject_msg() -> CeremonySubjectMsg {
let mut metadata = std::collections::HashMap::new();
metadata.insert("pipeline_name".to_string(), "deploy".to_string());
metadata.insert("branch".to_string(), "main".to_string());
metadata.insert("commit_hash".to_string(), "abc123".to_string());
metadata.insert("remote_name".to_string(), "origin".to_string());
CeremonySubjectMsg {
subject_type: "pipeline_merge".to_string(),
reference_id: "run-001".to_string(),
description: "merge deploy (main)".to_string(),
metadata,
}
}
#[tokio::test]
async fn create_and_get_ceremony() {
let svc = build_service();
let resp = svc
.create_ceremony(Request::new(CreateCeremonyRequest {
ceremony_type: "single_approval".to_string(),
subject: Some(merge_subject_msg()),
required_approvals: 1,
approver_roles: vec!["msp-ops".to_string()],
ttl_hours: 24,
intent_id: String::new(),
run_id: "run-001".to_string(),
pr_number: 42,
remote_name: "origin".to_string(),
}))
.await
.unwrap()
.into_inner();
assert_eq!(resp.status, "pending");
assert!(!resp.ceremony_id.is_empty());
// Get it back
let get_resp = svc
.get_ceremony(Request::new(GetCeremonyRequest {
ceremony_id: resp.ceremony_id.clone(),
}))
.await
.unwrap()
.into_inner();
assert_eq!(get_resp.ceremony_id, resp.ceremony_id);
assert_eq!(get_resp.status, "pending");
assert_eq!(get_resp.pr_number, 42);
assert_eq!(get_resp.run_id, "run-001");
}
#[tokio::test]
async fn approve_resolves_single_approval() {
let svc = build_service();
let create_resp = svc
.create_ceremony(Request::new(CreateCeremonyRequest {
ceremony_type: "single_approval".to_string(),
subject: Some(merge_subject_msg()),
required_approvals: 1,
approver_roles: vec!["msp-ops".to_string()],
ttl_hours: 24,
..Default::default()
}))
.await
.unwrap()
.into_inner();
let approve_resp = svc
.approve_ceremony(Request::new(ApproveCeremonyRequest {
ceremony_id: create_resp.ceremony_id.clone(),
approver_identity: "alice@ops".to_string(),
approver_role: "msp-ops".to_string(),
comment: "LGTM".to_string(),
}))
.await
.unwrap()
.into_inner();
assert!(approve_resp.success);
assert_eq!(approve_resp.status, "approved");
}
#[tokio::test]
async fn deny_resolves_ceremony() {
let svc = build_service();
let create_resp = svc
.create_ceremony(Request::new(CreateCeremonyRequest {
ceremony_type: "single_approval".to_string(),
subject: Some(merge_subject_msg()),
required_approvals: 1,
approver_roles: vec!["msp-ops".to_string()],
ttl_hours: 24,
..Default::default()
}))
.await
.unwrap()
.into_inner();
let deny_resp = svc
.deny_ceremony(Request::new(DenyCeremonyRequest {
ceremony_id: create_resp.ceremony_id.clone(),
approver_identity: "bob@ops".to_string(),
approver_role: "msp-ops".to_string(),
comment: "unacceptable risk".to_string(),
}))
.await
.unwrap()
.into_inner();
assert!(deny_resp.success);
assert_eq!(deny_resp.status, "denied");
}
#[tokio::test]
async fn cancel_pending_ceremony() {
let svc = build_service();
let create_resp = svc
.create_ceremony(Request::new(CreateCeremonyRequest {
ceremony_type: "single_approval".to_string(),
subject: Some(merge_subject_msg()),
required_approvals: 1,
approver_roles: vec!["msp-ops".to_string()],
ttl_hours: 24,
..Default::default()
}))
.await
.unwrap()
.into_inner();
let cancel_resp = svc
.cancel_ceremony(Request::new(CancelCeremonyRequest {
ceremony_id: create_resp.ceremony_id.clone(),
}))
.await
.unwrap()
.into_inner();
assert!(cancel_resp.success);
let get_resp = svc
.get_ceremony(Request::new(GetCeremonyRequest {
ceremony_id: create_resp.ceremony_id,
}))
.await
.unwrap()
.into_inner();
assert_eq!(get_resp.status, "cancelled");
}
#[tokio::test]
async fn list_pending_ceremonies() {
let svc = build_service();
// Create 2 ceremonies
for i in 0..2 {
svc.create_ceremony(Request::new(CreateCeremonyRequest {
ceremony_type: "single_approval".to_string(),
subject: Some(CeremonySubjectMsg {
subject_type: "custom".to_string(),
reference_id: format!("ref-{i}"),
description: format!("test {i}"),
metadata: std::collections::HashMap::new(),
}),
required_approvals: 1,
approver_roles: vec![],
ttl_hours: 24,
..Default::default()
}))
.await
.unwrap();
}
let list_resp = svc
.list_pending_ceremonies(Request::new(ListPendingCeremoniesRequest {
intent_id: String::new(),
}))
.await
.unwrap()
.into_inner();
assert_eq!(list_resp.ceremonies.len(), 2);
}
#[tokio::test]
async fn self_grant_auto_approved() {
let svc = build_service();
let resp = svc
.create_ceremony(Request::new(CreateCeremonyRequest {
ceremony_type: "self_grant".to_string(),
subject: Some(merge_subject_msg()),
required_approvals: 0,
approver_roles: vec![],
ttl_hours: 24,
..Default::default()
}))
.await
.unwrap()
.into_inner();
assert_eq!(resp.status, "approved");
}
#[tokio::test]
async fn get_proof_for_resolved_ceremony() {
let svc = build_service();
let create_resp = svc
.create_ceremony(Request::new(CreateCeremonyRequest {
ceremony_type: "single_approval".to_string(),
subject: Some(merge_subject_msg()),
required_approvals: 1,
approver_roles: vec!["msp-ops".to_string()],
ttl_hours: 24,
..Default::default()
}))
.await
.unwrap()
.into_inner();
svc.approve_ceremony(Request::new(ApproveCeremonyRequest {
ceremony_id: create_resp.ceremony_id.clone(),
approver_identity: "alice@ops".to_string(),
approver_role: "msp-ops".to_string(),
comment: String::new(),
}))
.await
.unwrap();
let proof = svc
.get_ceremony_proof(Request::new(GetCeremonyProofRequest {
ceremony_id: create_resp.ceremony_id,
}))
.await
.unwrap()
.into_inner();
assert_eq!(proof.status, "approved");
assert!(!proof.proof_hash.is_empty());
assert_eq!(proof.proof_hash.len(), 64); // SHA-256 hex
assert_eq!(proof.approvals.len(), 1);
}
#[tokio::test]
async fn proof_on_pending_fails() {
let svc = build_service();
let create_resp = svc
.create_ceremony(Request::new(CreateCeremonyRequest {
ceremony_type: "single_approval".to_string(),
subject: Some(merge_subject_msg()),
required_approvals: 1,
approver_roles: vec!["msp-ops".to_string()],
ttl_hours: 24,
..Default::default()
}))
.await
.unwrap()
.into_inner();
let err = svc
.get_ceremony_proof(Request::new(GetCeremonyProofRequest {
ceremony_id: create_resp.ceremony_id,
}))
.await
.unwrap_err();
assert_eq!(err.code(), tonic::Code::FailedPrecondition);
}
}

View file

@ -0,0 +1,495 @@
//! HTTP endpoints for browser-based ceremony approval.
//!
//! Provides REST endpoints that complement the gRPC CeremonyService,
//! enabling stakeholders to approve/deny ceremonies through a web browser
//! or simple HTTP clients.
//!
//! Endpoints:
//! - `GET /ceremonies` — list pending ceremonies
//! - `GET /ceremonies/:id` — get ceremony details
//! - `POST /ceremonies/:id/approve` — approve a ceremony
//! - `POST /ceremonies/:id/deny` — deny a ceremony
//! - `GET /health` — health check
use std::sync::Arc;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::routing::{get, post};
use axum::{Json, Router};
use bascule_core::ceremony_engine::CeremonyEngine;
use bascule_core::ceremony_request::{ApprovalDecision, GovernanceCeremonyStatus};
use bascule_core::ceremony_resolution::CeremonyResolution;
use bascule_core::ceremony_store::CeremonyStore;
use serde::{Deserialize, Serialize};
use tracing::info;
/// Shared state for HTTP handlers.
#[derive(Clone)]
pub struct CeremonyHttpState {
pub store: Arc<dyn CeremonyStore>,
}
/// Build the axum Router for ceremony HTTP endpoints.
pub fn ceremony_router(state: CeremonyHttpState) -> Router {
Router::new()
.route("/health", get(health))
.route("/ceremonies", get(list_pending))
.route("/ceremonies/{id}", get(get_ceremony))
.route("/ceremonies/{id}/approve", post(approve_ceremony))
.route("/ceremonies/{id}/deny", post(deny_ceremony))
.with_state(state)
}
#[derive(Serialize)]
struct HealthResponse {
status: String,
}
async fn health() -> impl IntoResponse {
Json(HealthResponse {
status: "ok".to_string(),
})
}
#[derive(Serialize, Deserialize)]
struct CeremonyListResponse {
ceremonies: Vec<CeremonySummary>,
}
#[derive(Serialize, Deserialize)]
struct CeremonySummary {
ceremony_id: String,
ceremony_type: String,
subject_label: String,
status: String,
required_approvals: u32,
current_approvals: u32,
created_at: String,
expires_at: String,
}
async fn list_pending(
State(state): State<CeremonyHttpState>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let ceremonies = state
.store
.list_pending(None)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let summaries: Vec<CeremonySummary> = ceremonies
.iter()
.map(|c| CeremonySummary {
ceremony_id: c.ceremony_id.clone(),
ceremony_type: format!("{:?}", c.ceremony_type).to_lowercase(),
subject_label: c.subject.display_label(),
status: status_str(c.status),
required_approvals: c.required_approvals,
current_approvals: c.approval_count(),
created_at: c.created_at.to_rfc3339(),
expires_at: c.expires_at.to_rfc3339(),
})
.collect();
Ok(Json(CeremonyListResponse {
ceremonies: summaries,
}))
}
#[derive(Serialize, Deserialize)]
struct CeremonyDetail {
ceremony_id: String,
ceremony_type: String,
subject_label: String,
status: String,
required_approvals: u32,
current_approvals: u32,
approvals: Vec<ApprovalSummary>,
created_at: String,
expires_at: String,
intent_id: Option<String>,
run_id: Option<String>,
pr_number: Option<u64>,
remote_name: Option<String>,
proof_hash: Option<String>,
}
#[derive(Serialize, Deserialize)]
struct ApprovalSummary {
approver_identity: String,
approver_role: String,
decision: String,
comment: Option<String>,
decided_at: String,
}
async fn get_ceremony(
State(state): State<CeremonyHttpState>,
Path(id): Path<String>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let ceremony = state
.store
.get(&id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "ceremony not found".to_string()))?;
let proof_hash = if ceremony.status.is_terminal() {
let resolution = CeremonyResolution::from_ceremony(
&ceremony.ceremony_id,
ceremony.status,
&ceremony.subject,
&ceremony.approvals,
);
Some(resolution.proof_hash)
} else {
None
};
let detail = CeremonyDetail {
ceremony_id: ceremony.ceremony_id.clone(),
ceremony_type: format!("{:?}", ceremony.ceremony_type).to_lowercase(),
subject_label: ceremony.subject.display_label(),
status: status_str(ceremony.status),
required_approvals: ceremony.required_approvals,
current_approvals: ceremony.approval_count(),
approvals: ceremony
.approvals
.iter()
.map(|a| ApprovalSummary {
approver_identity: a.approver_identity.clone(),
approver_role: a.approver_role.clone(),
decision: match a.decision {
ApprovalDecision::Approve => "approve".to_string(),
ApprovalDecision::Deny => "deny".to_string(),
},
comment: a.comment.clone(),
decided_at: a.decided_at.to_rfc3339(),
})
.collect(),
created_at: ceremony.created_at.to_rfc3339(),
expires_at: ceremony.expires_at.to_rfc3339(),
intent_id: ceremony.intent_id.clone(),
run_id: ceremony.run_id.clone(),
pr_number: ceremony.pr_number,
remote_name: ceremony.remote_name.clone(),
proof_hash,
};
Ok(Json(detail))
}
#[derive(Serialize, Deserialize)]
struct ApproveRequest {
approver_identity: String,
approver_role: String,
comment: Option<String>,
}
#[derive(Serialize, Deserialize)]
struct ApproveResponse {
success: bool,
status: String,
error: Option<String>,
}
async fn approve_ceremony(
State(state): State<CeremonyHttpState>,
Path(id): Path<String>,
Json(body): Json<ApproveRequest>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let mut ceremony = state
.store
.get(&id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "ceremony not found".to_string()))?;
if let Err(e) = ceremony.record_decision(
&body.approver_identity,
&body.approver_role,
ApprovalDecision::Approve,
body.comment,
) {
return Ok((
StatusCode::CONFLICT,
Json(ApproveResponse {
success: false,
status: status_str(ceremony.status),
error: Some(e.to_string()),
}),
));
}
CeremonyEngine::evaluate(&mut ceremony);
state
.store
.update(&ceremony)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
info!(
ceremony_id = %id,
approver = %body.approver_identity,
status = ?ceremony.status,
"HTTP ceremony approval"
);
Ok((
StatusCode::OK,
Json(ApproveResponse {
success: true,
status: status_str(ceremony.status),
error: None,
}),
))
}
async fn deny_ceremony(
State(state): State<CeremonyHttpState>,
Path(id): Path<String>,
Json(body): Json<ApproveRequest>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let mut ceremony = state
.store
.get(&id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "ceremony not found".to_string()))?;
if let Err(e) = ceremony.record_decision(
&body.approver_identity,
&body.approver_role,
ApprovalDecision::Deny,
body.comment,
) {
return Ok((
StatusCode::CONFLICT,
Json(ApproveResponse {
success: false,
status: status_str(ceremony.status),
error: Some(e.to_string()),
}),
));
}
CeremonyEngine::evaluate(&mut ceremony);
state
.store
.update(&ceremony)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
info!(
ceremony_id = %id,
denier = %body.approver_identity,
"HTTP ceremony denial"
);
Ok((
StatusCode::OK,
Json(ApproveResponse {
success: true,
status: status_str(ceremony.status),
error: None,
}),
))
}
fn status_str(status: GovernanceCeremonyStatus) -> String {
match status {
GovernanceCeremonyStatus::Pending => "pending".to_string(),
GovernanceCeremonyStatus::Approved => "approved".to_string(),
GovernanceCeremonyStatus::Denied => "denied".to_string(),
GovernanceCeremonyStatus::Expired => "expired".to_string(),
GovernanceCeremonyStatus::Cancelled => "cancelled".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use axum::http::Request;
use bascule_core::ceremony_engine::CeremonyEngine;
use bascule_core::ceremony_request::CeremonySubject;
use bascule_core::ceremony_store::InMemoryCeremonyStore;
use tower::ServiceExt;
fn build_app() -> (Router, Arc<InMemoryCeremonyStore>) {
let store = Arc::new(InMemoryCeremonyStore::new());
let state = CeremonyHttpState {
store: store.clone(),
};
(ceremony_router(state), store)
}
async fn create_test_ceremony(store: &InMemoryCeremonyStore) -> String {
let reqs = accord_core::schema::CeremonyReqs {
approver_roles: Some(vec!["msp-ops".to_string()]),
quorum: Some(1),
..Default::default()
};
let ceremony = CeremonyEngine::create_request(
"cer-http-001".to_string(),
&accord_core::schema::CeremonyType::SingleApproval,
&reqs,
CeremonySubject::PipelineMerge {
run_id: "run-1".to_string(),
pipeline_name: "deploy".to_string(),
branch: "main".to_string(),
commit_hash: "abc".to_string(),
remote_name: "origin".to_string(),
},
24,
);
let id = ceremony.ceremony_id.clone();
store.create(&ceremony).await.unwrap();
id
}
#[tokio::test]
async fn health_check() {
let (app, _) = build_app();
let resp = app
.oneshot(
Request::builder()
.uri("/health")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn list_empty_ceremonies() {
let (app, _) = build_app();
let resp = app
.oneshot(
Request::builder()
.uri("/ceremonies")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let list: CeremonyListResponse = serde_json::from_slice(&body).unwrap();
assert!(list.ceremonies.is_empty());
}
#[tokio::test]
async fn get_ceremony_detail() {
let (app, store) = build_app();
let id = create_test_ceremony(&store).await;
let resp = app
.oneshot(
Request::builder()
.uri(&format!("/ceremonies/{id}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let detail: CeremonyDetail = serde_json::from_slice(&body).unwrap();
assert_eq!(detail.ceremony_id, id);
assert_eq!(detail.status, "pending");
}
#[tokio::test]
async fn approve_via_http() {
let (app, store) = build_app();
let id = create_test_ceremony(&store).await;
let body = serde_json::to_string(&ApproveRequest {
approver_identity: "alice@ops".to_string(),
approver_role: "msp-ops".to_string(),
comment: Some("LGTM".to_string()),
})
.unwrap();
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri(&format!("/ceremonies/{id}/approve"))
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let resp_body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let result: ApproveResponse = serde_json::from_slice(&resp_body).unwrap();
assert!(result.success);
assert_eq!(result.status, "approved");
}
#[tokio::test]
async fn deny_via_http() {
let (app, store) = build_app();
let id = create_test_ceremony(&store).await;
let body = serde_json::to_string(&ApproveRequest {
approver_identity: "bob@ops".to_string(),
approver_role: "msp-ops".to_string(),
comment: Some("needs rework".to_string()),
})
.unwrap();
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri(&format!("/ceremonies/{id}/deny"))
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let resp_body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let result: ApproveResponse = serde_json::from_slice(&resp_body).unwrap();
assert!(result.success);
assert_eq!(result.status, "denied");
}
#[tokio::test]
async fn not_found_ceremony() {
let (app, _) = build_app();
let resp = app
.oneshot(
Request::builder()
.uri("/ceremonies/nonexistent")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
}

241
bascule-gateway/src/main.rs Normal file
View file

@ -0,0 +1,241 @@
mod audit_pipeline;
mod auth;
mod ceremony;
mod config;
mod executor;
mod filter;
mod governance_ceremony;
mod http_ceremony;
mod migrations;
mod server;
mod session_manager;
use std::sync::Arc;
use crate::config::BasculeConfig;
use crate::executor::ExecutorRegistry;
use crate::filter::FilterChain;
use crate::server::BasculeGatewayService;
use crate::session_manager::SessionManager;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// Workspace has both ring and aws-lc-rs rustls features.
rustls::crypto::ring::default_provider()
.install_default()
.expect("Failed to install rustls crypto provider");
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.json()
.init();
let config = BasculeConfig::from_env()?;
tracing::info!(listen_addr = %config.listen_addr, "Starting Bascule Gateway");
// 1. Load accord
let accord = match std::fs::read_to_string(&config.accord_path) {
Ok(yaml) => {
let accord = accord_core::schema::Accord::load(&yaml)?;
tracing::info!(version = %accord.metadata.version, "Accord loaded");
Arc::new(accord)
}
Err(e) => {
tracing::warn!(path = %config.accord_path, error = %e,
"Accord file not found — OPA policy will deny all unclassified operations");
// Minimal empty accord
let empty_yaml = r#"
apiVersion: guildhouse.io/v1alpha1
kind: Accord
metadata:
name: empty
version: "0.0.0"
previousVersionHash: "none"
authorizingCeremony: bootstrap
effectiveAt: "2025-01-01T00:00:00Z"
expiresAt: "2099-01-01T00:00:00Z"
spec:
trustDomain: guildhouse.local
policy:
bundleHash: "none"
bundlePath: "/policies"
classifications: []
ceremonies: []
ledger:
fidelity: always_notarize
notarize: []
logOnly: []
sampled: []
sampleRate: 1
reconciliation:
defaultWindow: "24h"
onExpiry: alert
driftResponses: []
controllers: []
roles: []
"#;
Arc::new(accord_core::schema::Accord::load(empty_yaml)
.expect("empty accord must parse"))
}
};
// 2. Connect to database (optional — degrade gracefully for dev without PG)
let db_pool = match sqlx::PgPool::connect(&config.database_url()).await {
Ok(pool) => {
tracing::info!("PostgreSQL connected");
// 3. Run migrations
migrations::run_migrations(&pool).await?;
Some(pool)
}
Err(e) => {
tracing::warn!("PostgreSQL not available ({e}) — running in memory-only mode");
None
}
};
// 4. Create OPA client
let opa_client = Arc::new(accord_opa::OpaClient::new(&config.opa_url));
match opa_client.health_check().await {
Ok(true) => tracing::info!("OPA sidecar is healthy"),
_ => tracing::warn!("OPA sidecar not available — policy filter will deny all requests"),
}
// 5. Build the Kubernetes client (in-cluster or from kubeconfig)
let kube_client = kube::Client::try_default().await?;
tracing::info!("Kubernetes client initialized");
// 6. Session manager (dual-store: DashMap + PG)
let session_manager = Arc::new(SessionManager::new(db_pool.clone()));
if db_pool.is_some() {
let restored = session_manager.restore_from_db().await?;
if restored > 0 {
tracing::info!(restored, "Restored sessions from database");
}
}
// 7. Ceremony manager
let ceremony_manager = if let Some(pool) = &db_pool {
Some(Arc::new(ceremony::CeremonyManager::new(
pool.clone(),
config.session_lifetime_secs,
)))
} else {
None
};
// 8. Audit pipeline
let audit_pipeline = if let Some(pool) = &db_pool {
let pipeline = Arc::new(audit_pipeline::AuditPipeline::new(
pool.clone(),
config.audit_batch_size,
));
let _flush_handle = pipeline.clone().start_flush_loop(
std::time::Duration::from_secs(config.audit_flush_interval_secs),
);
pipeline
} else {
// No PG — create a pipeline with a lazy pool (will error on submit)
Arc::new(audit_pipeline::AuditPipeline::new(
sqlx::PgPool::connect_lazy("postgresql://unused:unused@localhost/unused")?,
config.audit_batch_size,
))
};
// 9. Build executor registry
let executor_registry = Arc::new(ExecutorRegistry::new(kube_client.clone()));
// 10. Build filter chain
let auth_provider = Arc::new(auth::OidcAuthProvider::new(
&config.oidc_issuer,
&config.oidc_audience,
));
let filter_chain = Arc::new(FilterChain::new(
auth_provider.clone(),
session_manager.clone(),
executor_registry,
opa_client,
accord,
audit_pipeline,
));
// 11. Spawn background tasks
let reaper_manager = session_manager.clone();
tokio::spawn(async move {
reaper_manager.run_reaper().await;
});
if let Some(cm) = &ceremony_manager {
let cm = cm.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(30));
loop {
interval.tick().await;
cm.reap_expired_ceremonies().await;
}
});
}
// 12. Governance ceremony service (in-memory store + expiry loop)
let gov_ceremony_store: Arc<dyn bascule_core::ceremony_store::CeremonyStore> =
Arc::new(bascule_core::ceremony_store::InMemoryCeremonyStore::new());
let gov_ceremony_svc = Arc::new(governance_ceremony::GovernanceCeremonyService::new(
gov_ceremony_store.clone(),
));
{
let svc = gov_ceremony_svc.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(30));
loop {
interval.tick().await;
let expired = svc.expire_pending().await;
if expired > 0 {
tracing::info!(expired, "Governance ceremonies expired");
}
}
});
}
// 13. Build gRPC server
let service = BasculeGatewayService::new(
filter_chain,
session_manager,
auth_provider,
ceremony_manager,
);
let addr = config.listen_addr.parse()?;
tracing::info!(%addr, "Bascule Gateway listening");
// Spawn HTTP ceremony server on separate port
let http_state = http_ceremony::CeremonyHttpState {
store: gov_ceremony_store,
};
let http_app = http_ceremony::ceremony_router(http_state);
let http_addr: std::net::SocketAddr = config
.http_listen_addr()
.parse()
.unwrap_or_else(|_| "0.0.0.0:8443".parse().unwrap());
tracing::info!(%http_addr, "Ceremony HTTP server listening");
tokio::spawn(async move {
let listener = tokio::net::TcpListener::bind(http_addr).await.unwrap();
axum::serve(listener, http_app).await.unwrap();
});
tonic::transport::Server::builder()
.add_service(
bascule_proto::bascule_v1::bascule_gateway_server::BasculeGatewayServer::new(service),
)
.add_service(
bascule_proto::bascule_v1::ceremony_service_server::CeremonyServiceServer::from_arc(
gov_ceremony_svc,
),
)
.serve(addr)
.await?;
Ok(())
}

View file

@ -0,0 +1,122 @@
use sqlx::PgPool;
/// Run database migrations for the bascule schema.
/// Creates schema and tables if they don't exist.
pub async fn run_migrations(pool: &PgPool) -> anyhow::Result<()> {
tracing::info!("Running bascule schema migrations");
// Create schema
sqlx::query("CREATE SCHEMA IF NOT EXISTS bascule")
.execute(pool)
.await?;
// Ceremonies table (must exist before sessions due to FK)
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS bascule.ceremonies (
ceremony_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
ceremony_type TEXT NOT NULL,
requestor_sub TEXT NOT NULL,
requestor_email TEXT NOT NULL,
requested_scope JSONB NOT NULL,
granted_scope JSONB,
status TEXT NOT NULL DEFAULT 'pending',
accord_version TEXT NOT NULL,
evidence JSONB DEFAULT '[]',
approvers JSONB DEFAULT '[]',
requested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
resolved_at TIMESTAMPTZ,
timeout_at TIMESTAMPTZ NOT NULL,
merkle_leaf BYTEA
)
"#,
)
.execute(pool)
.await?;
// Sessions table
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS bascule.sessions (
session_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
ceremony_id UUID NOT NULL REFERENCES bascule.ceremonies(ceremony_id),
operator_sub TEXT NOT NULL,
operator_email TEXT NOT NULL,
scope JSONB NOT NULL,
state TEXT NOT NULL DEFAULT 'active',
mutations_used INTEGER NOT NULL DEFAULT 0,
mutation_budget INTEGER,
valid_from TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
terminated_at TIMESTAMPTZ,
merkle_leaf BYTEA
)
"#,
)
.execute(pool)
.await?;
// Session indexes
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_sessions_operator ON bascule.sessions (operator_sub, state)",
)
.execute(pool)
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_sessions_active ON bascule.sessions (state) WHERE state = 'active'",
)
.execute(pool)
.await?;
// Audit events table
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS bascule.audit_events (
time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
event_id UUID NOT NULL DEFAULT gen_random_uuid(),
session_id UUID NOT NULL,
operator_identity TEXT NOT NULL,
command JSONB NOT NULL,
classification TEXT NOT NULL,
policy_decision JSONB NOT NULL,
execution_result JSONB NOT NULL,
target_resources JSONB DEFAULT '[]',
target_profile_hash TEXT,
notarized BOOLEAN NOT NULL DEFAULT false,
anchor_id UUID,
leaf_index INTEGER,
merkle_leaf BYTEA
)
"#,
)
.execute(pool)
.await?;
// Try to create hypertable (only works if TimescaleDB is available)
match sqlx::query(
"SELECT create_hypertable('bascule.audit_events', 'time', if_not_exists => true)",
)
.execute(pool)
.await
{
Ok(_) => tracing::info!("audit_events hypertable created"),
Err(e) => tracing::warn!("TimescaleDB not available, skipping hypertable: {e}"),
}
// Audit event indexes
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_audit_session ON bascule.audit_events (session_id, time DESC)",
)
.execute(pool)
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_audit_operator ON bascule.audit_events (operator_identity, time DESC)",
)
.execute(pool)
.await?;
tracing::info!("Bascule schema migrations complete");
Ok(())
}

View file

@ -0,0 +1,499 @@
use std::sync::Arc;
use bascule_core::ceremony::CeremonyType;
use bascule_core::command::builtin_commands;
use bascule_core::scope::{
ChangePathway, GlobalScope, NamespaceScope, ScopeRule, SessionScope, Verb,
};
use tonic::{Request, Response, Status};
use uuid::Uuid;
use crate::auth::OidcAuthProvider;
use crate::ceremony::{CeremonyManager, CeremonyResponse};
use crate::filter::FilterChain;
use crate::session_manager::SessionManager;
pub struct BasculeGatewayService {
filter_chain: Arc<FilterChain>,
session_manager: Arc<SessionManager>,
auth_provider: Arc<OidcAuthProvider>,
ceremony_manager: Option<Arc<CeremonyManager>>,
}
impl BasculeGatewayService {
pub fn new(
filter_chain: Arc<FilterChain>,
session_manager: Arc<SessionManager>,
auth_provider: Arc<OidcAuthProvider>,
ceremony_manager: Option<Arc<CeremonyManager>>,
) -> Self {
Self {
filter_chain,
session_manager,
auth_provider,
ceremony_manager,
}
}
}
#[tonic::async_trait]
impl bascule_proto::bascule_v1::bascule_gateway_server::BasculeGateway for BasculeGatewayService {
async fn request_session(
&self,
request: Request<bascule_proto::bascule_v1::RequestSessionRequest>,
) -> Result<Response<bascule_proto::bascule_v1::RequestSessionResponse>, Status> {
let token = extract_bearer_token(request.metadata())
.ok_or_else(|| Status::unauthenticated("missing authorization header"))?;
let identity = self
.auth_provider
.validate_token(&token)
.await
.map_err(|e| Status::unauthenticated(e.to_string()))?;
let inner = request.into_inner();
let scope = inner
.requested_scope
.as_ref()
.map(proto_scope_to_core)
.unwrap_or_else(|| SessionManager::default_read_scope(&["default".into()]));
if let Some(cm) = &self.ceremony_manager {
let response = match inner.ceremony_type.as_str() {
"self_grant" => cm
.process_self_grant(&identity, &scope, "1.0.0")
.await
.map_err(|e| Status::internal(e.to_string()))?,
"single_approval" => cm
.process_single_approval(&identity, &scope, "1.0.0")
.await
.map_err(|e| Status::internal(e.to_string()))?,
"emergency_access" | "break_glass" => {
let evidence: Vec<bascule_core::ceremony::Evidence> = inner
.evidence
.iter()
.map(|e| bascule_core::ceremony::Evidence {
evidence_type: parse_evidence_type(&e.evidence_type),
reference: e.reference.clone(),
verified: false,
verified_at: None,
})
.collect();
cm.process_break_glass(&identity, &scope, &evidence, "1.0.0")
.await
.map_err(|e| Status::internal(e.to_string()))?
}
other => {
return Ok(Response::new(bascule_proto::bascule_v1::RequestSessionResponse {
result: Some(
bascule_proto::bascule_v1::request_session_response::Result::Denied(
bascule_proto::bascule_v1::CeremonyDenied {
reason: format!("unsupported ceremony type: {other}"),
},
),
),
}));
}
};
match response {
CeremonyResponse::Granted(grant) => {
let session = self
.session_manager
.create_session(&grant)
.await
.map_err(|e| Status::internal(e.to_string()))?;
Ok(Response::new(bascule_proto::bascule_v1::RequestSessionResponse {
result: Some(
bascule_proto::bascule_v1::request_session_response::Result::Granted(
bascule_proto::bascule_v1::SessionGranted {
session_id: session.session_id.to_string(),
granted_scope: Some(core_scope_to_proto(&session.scope)),
expires_at: Some(to_proto_timestamp(&session.expires_at)),
ceremony_id: session.ceremony_id.to_string(),
},
),
),
}))
}
CeremonyResponse::Pending {
ceremony_id,
timeout_at,
} => Ok(Response::new(bascule_proto::bascule_v1::RequestSessionResponse {
result: Some(
bascule_proto::bascule_v1::request_session_response::Result::Pending(
bascule_proto::bascule_v1::CeremonyPending {
ceremony_id: ceremony_id.to_string(),
message: "Awaiting approval".to_string(),
timeout_at: Some(to_proto_timestamp(&timeout_at)),
},
),
),
})),
CeremonyResponse::Denied(reason) => {
Ok(Response::new(bascule_proto::bascule_v1::RequestSessionResponse {
result: Some(
bascule_proto::bascule_v1::request_session_response::Result::Denied(
bascule_proto::bascule_v1::CeremonyDenied { reason },
),
),
}))
}
}
} else {
// No ceremony manager (memory-only mode)
if inner.ceremony_type != "self_grant" {
return Ok(Response::new(bascule_proto::bascule_v1::RequestSessionResponse {
result: Some(
bascule_proto::bascule_v1::request_session_response::Result::Denied(
bascule_proto::bascule_v1::CeremonyDenied {
reason: format!(
"ceremony type '{}' requires database (not available)",
inner.ceremony_type
),
},
),
),
}));
}
let grant = bascule_core::ceremony::CeremonyGrant {
ceremony_id: Uuid::new_v4(),
ceremony_type: CeremonyType::SelfGrant,
requestor: identity,
approvers: vec![],
granted_scope: scope,
accord_version: "none".into(),
evidence: vec![],
granted_at: chrono::Utc::now(),
session_lifetime: chrono::Duration::hours(8),
};
let session = self
.session_manager
.create_session(&grant)
.await
.map_err(|e| Status::internal(e.to_string()))?;
Ok(Response::new(bascule_proto::bascule_v1::RequestSessionResponse {
result: Some(
bascule_proto::bascule_v1::request_session_response::Result::Granted(
bascule_proto::bascule_v1::SessionGranted {
session_id: session.session_id.to_string(),
granted_scope: Some(core_scope_to_proto(&session.scope)),
expires_at: Some(to_proto_timestamp(&session.expires_at)),
ceremony_id: session.ceremony_id.to_string(),
},
),
),
}))
}
}
async fn get_session_status(
&self,
request: Request<bascule_proto::bascule_v1::GetSessionStatusRequest>,
) -> Result<Response<bascule_proto::bascule_v1::GetSessionStatusResponse>, Status> {
let inner = request.into_inner();
let session_id: Uuid = inner
.session_id
.parse()
.map_err(|_| Status::invalid_argument("invalid session_id"))?;
let session = self
.session_manager
.get_session(&session_id)
.ok_or_else(|| Status::not_found("session not found"))?;
Ok(Response::new(bascule_proto::bascule_v1::GetSessionStatusResponse {
session_id: session.session_id.to_string(),
state: format!("{:?}", session.state).to_lowercase(),
scope: Some(core_scope_to_proto(&session.scope)),
expires_at: Some(to_proto_timestamp(&session.expires_at)),
mutations_used: session.mutations_used,
mutation_budget: session.scope.mutation_budget,
}))
}
async fn end_session(
&self,
request: Request<bascule_proto::bascule_v1::EndSessionRequest>,
) -> Result<Response<bascule_proto::bascule_v1::EndSessionResponse>, Status> {
let inner = request.into_inner();
let session_id: Uuid = inner
.session_id
.parse()
.map_err(|_| Status::invalid_argument("invalid session_id"))?;
let session = self
.session_manager
.end_session(&session_id)
.await
.ok_or_else(|| Status::not_found("session not found"))?;
Ok(Response::new(bascule_proto::bascule_v1::EndSessionResponse {
success: true,
total_commands: 0,
total_mutations: session.mutations_used,
}))
}
async fn get_ceremony_status(
&self,
request: Request<bascule_proto::bascule_v1::GetCeremonyStatusRequest>,
) -> Result<Response<bascule_proto::bascule_v1::GetCeremonyStatusResponse>, Status> {
let cm = self
.ceremony_manager
.as_ref()
.ok_or_else(|| Status::unimplemented("ceremony manager not available"))?;
let inner = request.into_inner();
let ceremony_id: Uuid = inner
.ceremony_id
.parse()
.map_err(|_| Status::invalid_argument("invalid ceremony_id"))?;
let status = cm
.get_ceremony_status(ceremony_id)
.await
.map_err(|e| Status::internal(e.to_string()))?
.ok_or_else(|| Status::not_found("ceremony not found"))?;
match status {
crate::ceremony::CeremonyStatus::Pending {
ceremony_id,
..
} => Ok(Response::new(bascule_proto::bascule_v1::GetCeremonyStatusResponse {
ceremony_id: ceremony_id.to_string(),
status: "pending".to_string(),
session: None,
})),
crate::ceremony::CeremonyStatus::Approved { grant } => {
let session = self
.session_manager
.create_session(&grant)
.await
.map_err(|e| Status::internal(e.to_string()))?;
Ok(Response::new(bascule_proto::bascule_v1::GetCeremonyStatusResponse {
ceremony_id: grant.ceremony_id.to_string(),
status: "approved".to_string(),
session: Some(bascule_proto::bascule_v1::SessionGranted {
session_id: session.session_id.to_string(),
granted_scope: Some(core_scope_to_proto(&session.scope)),
expires_at: Some(to_proto_timestamp(&session.expires_at)),
ceremony_id: grant.ceremony_id.to_string(),
}),
}))
}
crate::ceremony::CeremonyStatus::Denied { reason } => {
Ok(Response::new(bascule_proto::bascule_v1::GetCeremonyStatusResponse {
ceremony_id: ceremony_id.to_string(),
status: format!("denied: {reason}"),
session: None,
}))
}
crate::ceremony::CeremonyStatus::Expired => {
Ok(Response::new(bascule_proto::bascule_v1::GetCeremonyStatusResponse {
ceremony_id: ceremony_id.to_string(),
status: "expired".to_string(),
session: None,
}))
}
}
}
async fn execute_command(
&self,
request: Request<bascule_proto::bascule_v1::ExecuteCommandRequest>,
) -> Result<Response<bascule_proto::bascule_v1::ExecuteCommandResponse>, Status> {
let bearer_token = extract_bearer_token(request.metadata());
let command = request.into_inner();
let response = self.filter_chain.execute(bearer_token, command).await;
Ok(Response::new(response))
}
type StreamCommandStream =
tokio_stream::wrappers::ReceiverStream<Result<bascule_proto::bascule_v1::CommandStreamChunk, Status>>;
async fn stream_command(
&self,
_request: Request<bascule_proto::bascule_v1::ExecuteCommandRequest>,
) -> Result<Response<Self::StreamCommandStream>, Status> {
Err(Status::unimplemented(
"streaming commands not implemented in Phase 2",
))
}
async fn discover_commands(
&self,
request: Request<bascule_proto::bascule_v1::DiscoverCommandsRequest>,
) -> Result<Response<bascule_proto::bascule_v1::DiscoverCommandsResponse>, Status> {
let inner = request.into_inner();
let session_id: Uuid = inner
.session_id
.parse()
.map_err(|_| Status::invalid_argument("invalid session_id"))?;
let _session = self
.session_manager
.get_session(&session_id)
.filter(|s| s.is_active())
.ok_or_else(|| Status::not_found("session not found or expired"))?;
let commands = builtin_commands()
.into_iter()
.map(|cmd| bascule_proto::bascule_v1::CommandDescriptor {
verb: cmd.verb,
description: cmd.description,
classification: format!("{:?}", cmd.classification).to_lowercase(),
parameters: vec![],
requires_namespace: cmd.requires_namespace,
requires_resource: cmd.requires_resource,
streaming: cmd.streaming,
})
.collect();
Ok(Response::new(bascule_proto::bascule_v1::DiscoverCommandsResponse {
commands,
}))
}
}
// --- Helper functions ---
fn extract_bearer_token(metadata: &tonic::metadata::MetadataMap) -> Option<String> {
metadata
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.map(|s| s.to_string())
}
fn to_proto_timestamp(dt: &chrono::DateTime<chrono::Utc>) -> prost_types::Timestamp {
prost_types::Timestamp {
seconds: dt.timestamp(),
nanos: dt.timestamp_subsec_nanos() as i32,
}
}
fn parse_evidence_type(s: &str) -> bascule_core::ceremony::EvidenceType {
match s {
"jira_ticket" => bascule_core::ceremony::EvidenceType::JiraTicket,
"github_issue" => bascule_core::ceremony::EvidenceType::GitHubIssue,
"slack_thread" => bascule_core::ceremony::EvidenceType::SlackThread,
"pagerduty_incident" => bascule_core::ceremony::EvidenceType::PagerDutyIncident,
_ => bascule_core::ceremony::EvidenceType::Manual,
}
}
// --- Scope conversion: proto ↔ core ---
fn proto_scope_to_core(proto: &bascule_proto::bascule_v1::SessionScope) -> SessionScope {
SessionScope {
namespaces: proto.namespaces.iter().map(proto_ns_to_core).collect(),
global: proto
.global
.as_ref()
.map(proto_global_to_core)
.unwrap_or_default(),
pathways: proto.pathways.iter().map(|p| parse_pathway(p)).collect(),
mutation_budget: proto.mutation_budget,
can_delegate: proto.can_delegate,
}
}
fn proto_ns_to_core(proto: &bascule_proto::bascule_v1::NamespaceScope) -> NamespaceScope {
NamespaceScope {
namespace: proto.namespace.clone(),
rules: proto
.rules
.iter()
.map(|r| ScopeRule {
api_groups: r.api_groups.clone(),
resources: r.resources.clone(),
verbs: r.verbs.iter().filter_map(|v| parse_verb(v)).collect(),
})
.collect(),
workload_profiles: proto.workload_profiles.clone(),
denied_capabilities: proto.denied_capabilities.clone(),
}
}
fn proto_global_to_core(proto: &bascule_proto::bascule_v1::GlobalScope) -> GlobalScope {
GlobalScope {
can_view_audit_trail: proto.can_view_audit_trail,
can_view_profiles: proto.can_view_profiles,
can_view_topology: proto.can_view_topology,
}
}
fn parse_pathway(s: &str) -> ChangePathway {
match s {
"workspace" => ChangePathway::Workspace,
"dry_run_only" => ChangePathway::DryRunOnly,
_ => ChangePathway::Direct,
}
}
fn parse_verb(s: &str) -> Option<Verb> {
match s {
"get" => Some(Verb::Get),
"list" => Some(Verb::List),
"watch" => Some(Verb::Watch),
"create" => Some(Verb::Create),
"update" => Some(Verb::Update),
"patch" => Some(Verb::Patch),
"delete" => Some(Verb::Delete),
"exec" => Some(Verb::Exec),
"logs" => Some(Verb::Logs),
"scale" => Some(Verb::Scale),
_ => None,
}
}
fn core_scope_to_proto(core: &SessionScope) -> bascule_proto::bascule_v1::SessionScope {
bascule_proto::bascule_v1::SessionScope {
namespaces: core.namespaces.iter().map(core_ns_to_proto).collect(),
global: Some(core_global_to_proto(&core.global)),
pathways: core
.pathways
.iter()
.map(|p| format!("{p:?}").to_lowercase())
.collect(),
mutation_budget: core.mutation_budget,
can_delegate: core.can_delegate,
}
}
fn core_ns_to_proto(core: &NamespaceScope) -> bascule_proto::bascule_v1::NamespaceScope {
bascule_proto::bascule_v1::NamespaceScope {
namespace: core.namespace.clone(),
rules: core
.rules
.iter()
.map(|r| bascule_proto::bascule_v1::ScopeRule {
api_groups: r.api_groups.clone(),
resources: r.resources.clone(),
verbs: r
.verbs
.iter()
.map(|v| format!("{v:?}").to_lowercase())
.collect(),
})
.collect(),
workload_profiles: core.workload_profiles.clone(),
denied_capabilities: core.denied_capabilities.clone(),
}
}
fn core_global_to_proto(core: &GlobalScope) -> bascule_proto::bascule_v1::GlobalScope {
bascule_proto::bascule_v1::GlobalScope {
can_view_audit_trail: core.can_view_audit_trail,
can_view_profiles: core.can_view_profiles,
can_view_topology: core.can_view_topology,
}
}

View file

@ -0,0 +1,251 @@
use bascule_core::ceremony::CeremonyGrant;
use bascule_core::scope::SessionScope;
use bascule_core::session::{OperatorIdentity, Session, SessionState};
use chrono::Utc;
use dashmap::DashMap;
use sqlx::PgPool;
use uuid::Uuid;
/// Dual-store session manager: DashMap (hot cache) + PostgreSQL (persistence).
pub struct SessionManager {
sessions: DashMap<Uuid, Session>,
db_pool: Option<PgPool>,
}
impl SessionManager {
/// Create a new session manager. Pass None for db_pool in tests or Phase 1 mode.
pub fn new(db_pool: Option<PgPool>) -> Self {
Self {
sessions: DashMap::new(),
db_pool,
}
}
/// Create a session from a ceremony grant.
pub async fn create_session(&self, grant: &CeremonyGrant) -> anyhow::Result<Session> {
let session_id = Uuid::new_v4();
let now = Utc::now();
let session = Session {
session_id,
ceremony_id: grant.ceremony_id,
identity: grant.requestor.clone(),
scope: grant.granted_scope.clone(),
state: SessionState::Active,
mutations_used: 0,
valid_from: now,
expires_at: now + grant.session_lifetime,
};
// Insert into hot cache
self.sessions.insert(session_id, session.clone());
// Persist to PG
if let Some(pool) = &self.db_pool {
let (sub, email) = identity_parts(&grant.requestor);
let scope_json = serde_json::to_value(&grant.granted_scope)?;
sqlx::query(
r#"
INSERT INTO bascule.sessions
(session_id, ceremony_id, operator_sub, operator_email,
scope, state, mutations_used, mutation_budget,
valid_from, expires_at)
VALUES ($1, $2, $3, $4, $5, 'active', 0, $6, $7, $8)
"#,
)
.bind(session_id)
.bind(grant.ceremony_id)
.bind(&sub)
.bind(&email)
.bind(&scope_json)
.bind(grant.granted_scope.mutation_budget.map(|b| b as i32))
.bind(session.valid_from)
.bind(session.expires_at)
.execute(pool)
.await?;
}
tracing::info!(
session_id = %session_id,
ceremony_id = %grant.ceremony_id,
"Session created"
);
Ok(session)
}
/// Look up a session by ID. Checks DashMap first, then PG.
pub fn get_session(&self, session_id: &Uuid) -> Option<Session> {
self.sessions.get(session_id).map(|s| s.clone())
}
/// End a session explicitly. Updates both stores.
pub async fn end_session(&self, session_id: &Uuid) -> Option<Session> {
let session = self.sessions.get_mut(session_id).map(|mut s| {
s.state = SessionState::Terminated;
s.clone()
});
if session.is_some() {
if let Some(pool) = &self.db_pool {
let _ = sqlx::query(
"UPDATE bascule.sessions SET state = 'terminated', terminated_at = NOW() WHERE session_id = $1",
)
.bind(session_id)
.execute(pool)
.await;
}
tracing::info!(%session_id, "Session terminated");
}
session
}
/// Record a mutation against a session. Updates both stores.
/// Returns the new mutation count.
pub async fn record_mutation(&self, session_id: &Uuid) -> Option<u32> {
let count = self.sessions.get_mut(session_id).map(|mut s| {
s.mutations_used += 1;
s.mutations_used
});
if let (Some(count), Some(pool)) = (count, &self.db_pool) {
let _ = sqlx::query(
"UPDATE bascule.sessions SET mutations_used = $2 WHERE session_id = $1",
)
.bind(session_id)
.bind(count as i32)
.execute(pool)
.await;
}
count
}
/// Restore active sessions from PG into DashMap (crash recovery).
pub async fn restore_from_db(&self) -> anyhow::Result<usize> {
let pool = match &self.db_pool {
Some(p) => p,
None => return Ok(0),
};
let rows = sqlx::query_as::<_, SessionRow>(
r#"
SELECT session_id, ceremony_id, operator_sub, operator_email,
scope, state, mutations_used, mutation_budget,
valid_from, expires_at
FROM bascule.sessions
WHERE state = 'active' AND expires_at > NOW()
"#,
)
.fetch_all(pool)
.await?;
let count = rows.len();
for row in rows {
let scope: SessionScope = serde_json::from_value(row.scope)?;
let session = Session {
session_id: row.session_id,
ceremony_id: row.ceremony_id,
identity: OperatorIdentity::Oidc {
issuer: String::new(),
subject: row.operator_sub,
email: row.operator_email,
},
scope,
state: SessionState::Active,
mutations_used: row.mutations_used as u32,
valid_from: row.valid_from,
expires_at: row.expires_at,
};
self.sessions.insert(session.session_id, session);
}
if count > 0 {
tracing::info!(count, "Restored active sessions from database");
}
Ok(count)
}
/// Background reaper for expired sessions (runs every 30 seconds).
pub async fn run_reaper(&self) {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(30));
loop {
interval.tick().await;
let now = Utc::now();
let mut expired = 0u32;
for mut entry in self.sessions.iter_mut() {
if entry.state == SessionState::Active && now >= entry.expires_at {
entry.state = SessionState::Expired;
expired += 1;
}
}
if expired > 0 {
tracing::info!(expired, "Reaped expired sessions");
// Update PG for expired sessions
if let Some(pool) = &self.db_pool {
let _ = sqlx::query(
"UPDATE bascule.sessions SET state = 'expired' WHERE state = 'active' AND expires_at < NOW()",
)
.execute(pool)
.await;
}
}
}
}
/// Create a default read-only scope for the given namespaces.
pub fn default_read_scope(namespaces: &[String]) -> SessionScope {
use bascule_core::scope::{
ChangePathway, GlobalScope, NamespaceScope, ScopeRule, Verb,
};
let ns_scopes = namespaces
.iter()
.map(|ns| NamespaceScope {
namespace: ns.clone(),
rules: vec![ScopeRule {
api_groups: vec!["".into(), "apps".into(), "batch".into()],
resources: vec!["*".into()],
verbs: vec![Verb::Get, Verb::List, Verb::Logs],
}],
workload_profiles: vec![],
denied_capabilities: vec![],
})
.collect();
SessionScope {
namespaces: ns_scopes,
global: GlobalScope::default(),
pathways: vec![ChangePathway::DryRunOnly],
mutation_budget: Some(0),
can_delegate: false,
}
}
}
#[derive(sqlx::FromRow)]
struct SessionRow {
session_id: Uuid,
ceremony_id: Uuid,
operator_sub: String,
operator_email: String,
scope: serde_json::Value,
#[allow(dead_code)]
state: String,
mutations_used: i32,
#[allow(dead_code)]
mutation_budget: Option<i32>,
valid_from: chrono::DateTime<chrono::Utc>,
expires_at: chrono::DateTime<chrono::Utc>,
}
fn identity_parts(identity: &OperatorIdentity) -> (String, String) {
match identity {
OperatorIdentity::Oidc {
subject, email, ..
} => (subject.clone(), email.clone()),
OperatorIdentity::Spiffe { svid_uri } => (svid_uri.clone(), String::new()),
}
}

View file

@ -0,0 +1,190 @@
apiVersion: guildhouse.io/v1alpha1
kind: Accord
metadata:
name: genesis-accord
version: "1.0.0"
previousVersionHash: "0000000000000000000000000000000000000000000000000000000000000000"
authorizingCeremony: bootstrap
effectiveAt: "2025-06-01T00:00:00Z"
expiresAt: "2027-06-01T00:00:00Z"
spec:
trustDomain: guildhouse.local
policy:
bundleHash: "sha256:genesis"
bundlePath: ".guildhouse/policies/"
classifications:
- name: read-access
description: Read-only
pathways: [imperative, declarative]
resourceSelectors:
- apiGroups: ["*"]
resources: ["*"]
verbs: ["get", "list", "watch"]
- name: workload-scaling
description: Scale workload replicas
pathways: [imperative, declarative]
resourceSelectors:
- apiGroups: ["apps"]
resources: ["deployments", "statefulsets"]
fields: ["spec.replicas"]
verbs: ["patch", "update"]
- name: workload-deployment
description: Deploy and update workloads
pathways: [imperative, declarative]
resourceSelectors:
- apiGroups: ["apps"]
resources: ["deployments", "statefulsets", "daemonsets"]
verbs: ["create", "update", "patch", "delete"]
- name: rbac-modification
description: Modify RBAC resources
pathways: [declarative]
resourceSelectors:
- apiGroups: ["rbac.authorization.k8s.io"]
resources: ["clusterroles", "clusterrolebindings", "roles", "rolebindings"]
verbs: ["*"]
- name: network-policy
description: Manage network policies
pathways: [declarative]
resourceSelectors:
- apiGroups: ["networking.k8s.io"]
resources: ["networkpolicies"]
verbs: ["*"]
- name: secret-management
description: Manage secrets
pathways: [declarative]
resourceSelectors:
- apiGroups: [""]
resources: ["secrets"]
verbs: ["create", "update", "patch", "delete"]
- name: emergency-access
description: Emergency break-glass access
pathways: [imperative]
resourceSelectors:
- apiGroups: ["*"]
resources: ["*"]
verbs: ["delete"]
- name: accord-change
description: Changes to the accord document
pathways: [declarative]
resourceSelectors:
- paths:
- ".guildhouse/accord.yaml"
- ".guildhouse/policies/**"
- name: workspace-merge
description: Merge workspace changes
pathways: [declarative]
resourceSelectors:
- paths:
- "namespaces/**"
- name: reconciliation-merge
description: Automated reconciliation
pathways: [autonomous]
resourceSelectors:
- apiGroups: ["*"]
resources: ["*"]
verbs: ["*"]
ceremonies:
- classification: read-access
type: self_grant
requirements:
maxDuration: "8h"
scopeConstraints:
verbs: ["get", "list", "watch"]
- classification: workload-scaling
type: single_approval
requirements:
approverRoles: ["namespace-admin"]
maxDuration: "4h"
mutationBudget: 10
requiresCapabilityProfile: true
- classification: workload-deployment
type: single_approval
requirements:
approverRoles: ["namespace-admin"]
maxDuration: "4h"
mutationBudget: 5
requiresTwinValidation: true
- classification: rbac-modification
type: quorum_approval
requirements:
approverRoles: ["namespace-admin"]
quorum: 2
maxDuration: "2h"
mutationBudget: 3
- classification: network-policy
type: single_approval
requirements:
approverRoles: ["namespace-admin"]
maxDuration: "4h"
- classification: secret-management
type: quorum_approval
requirements:
approverRoles: ["namespace-admin"]
quorum: 2
maxDuration: "2h"
mutationBudget: 3
- classification: emergency-access
type: break_glass
requirements:
maxDuration: "30m"
mandatoryPostIncidentReview: true
externalEvidence:
type: jira_ticket
project: INCIDENT
status: ["Active", "In Progress"]
- classification: accord-change
type: quorum_approval
requirements:
quorum: 2
requiresRegoTestsPass: true
requiresSchemaValidation: true
- classification: workspace-merge
type: single_approval
requirements:
approverRoles: ["namespace-admin"]
- classification: reconciliation-merge
type: autonomous
requirements:
controllerSvidMatch: "spiffe://guildhouse.local/ns/*/sa/reconciler"
ledger:
alwaysNotarize:
- ceremony_completion
- session_creation
- mutation_applied
logOnly:
- read_access
- session_heartbeat
sampled:
events:
- health_check
sampleRate: 100
reconciliation:
defaultWindow: "24h"
onExpiry: alert
driftCheckInterval: "5m"
driftResponses:
- resourceSelector:
apiGroups: [""]
resources: ["secrets"]
action: alert
- resourceSelector:
apiGroups: ["apps"]
resources: ["deployments"]
action: auto_reconcile
controllers:
- svid: "spiffe://guildhouse.local/ns/argocd/sa/argocd-application-controller"
classification: workload-deployment
permittedMutations:
- apiGroups: ["apps"]
resources: ["deployments", "statefulsets"]
verbs: ["create", "update", "patch"]
ledgerFidelity: full
roles:
- name: namespace-admin
members:
- identity: "spiffe://guildhouse.local/ns/capstone/sa/admin"
- identity: "oidc:tking@guildhouse.local"
namespaces: ["capstone", "quartermaster"]
- name: cluster-admin
members:
- identity: "oidc:tking@guildhouse.local"

View file

@ -0,0 +1,39 @@
[package]
name = "bascule-node-agent"
version = "0.1.0"
edition = "2021"
description = "Bascule node agent — DaemonSet for per-node shell admission and webhook emission"
[[bin]]
name = "bascule-node-agent"
path = "src/main.rs"
[dependencies]
# Kubernetes
kube = { workspace = true }
k8s-openapi = { workspace = true }
# HTTP (webhook emission)
reqwest = { workspace = true }
# Async
tokio = { workspace = true }
tokio-util = "0.7"
futures = "0.3"
# Serialization
serde = { workspace = true }
serde_json = { workspace = true }
# Observability
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
# Common
uuid = { workspace = true }
chrono = { workspace = true }
thiserror = { workspace = true }
anyhow = { workspace = true }
# TLS (required by kube)
rustls = { workspace = true }

View file

@ -0,0 +1,140 @@
use tokio::net::UnixListener;
use tokio::sync::mpsc;
use crate::events::NodeEvent;
/// Listens on a Unix socket for CNI ADD notifications from substrate-cni.
///
/// **substrate-cni does not exist yet.** This is a stub listener that
/// accepts connections, reads a JSON payload, and emits a `ShellBound`
/// event when valid data arrives.
///
/// Expected CNI ADD payload (future):
/// ```json
/// {"pod_uid": "...", "namespace": "...", "cgroup_id": 12345,
/// "container_id": "...", "did": "did:substrate:..."}
/// ```
pub struct AdmissionListener {
socket_path: String,
event_tx: mpsc::Sender<NodeEvent>,
node_name: String,
}
impl AdmissionListener {
pub fn new(
socket_path: String,
event_tx: mpsc::Sender<NodeEvent>,
node_name: String,
) -> Self {
Self {
socket_path,
event_tx,
node_name,
}
}
/// Run the admission listener. Never returns under normal operation.
pub async fn run(self) -> anyhow::Result<()> {
// Clean up stale socket file
let _ = std::fs::remove_file(&self.socket_path);
// Ensure parent directory exists
if let Some(parent) = std::path::Path::new(&self.socket_path).parent() {
let _ = std::fs::create_dir_all(parent);
}
let listener = UnixListener::bind(&self.socket_path)?;
// Restrict socket permissions — only bascule-node-agent should connect.
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(
&self.socket_path,
std::fs::Permissions::from_mode(0o660),
)?;
tracing::info!(path = %self.socket_path, "Admission listener started");
loop {
match listener.accept().await {
Ok((stream, _addr)) => {
let tx = self.event_tx.clone();
let node = self.node_name.clone();
tokio::spawn(async move {
if let Err(e) = handle_connection(stream, &tx, &node).await {
tracing::debug!(error = %e, "Admission connection error");
}
});
}
Err(e) => {
tracing::error!(error = %e, "Failed to accept admission connection");
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
}
}
}
}
/// Handle a single CNI ADD connection.
async fn handle_connection(
stream: tokio::net::UnixStream,
event_tx: &mpsc::Sender<NodeEvent>,
node_name: &str,
) -> anyhow::Result<()> {
use tokio::io::AsyncReadExt;
let mut buf = vec![0u8; 4096];
let mut stream = stream;
let n = stream.read(&mut buf).await?;
if n == 0 {
return Ok(());
}
let payload: serde_json::Value = serde_json::from_slice(&buf[..n])?;
let pod_uid = payload
.get("pod_uid")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let namespace = payload
.get("namespace")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let cgroup_id = payload
.get("cgroup_id")
.and_then(|v| v.as_u64())
.unwrap_or_default();
let did = payload
.get("did")
.and_then(|v| v.as_str())
.unwrap_or("did:substrate:unknown")
.to_string();
if pod_uid.is_empty() || namespace.is_empty() || cgroup_id == 0 {
tracing::warn!("Incomplete CNI ADD payload: {payload}");
return Ok(());
}
let session_id = uuid::Uuid::new_v4().to_string();
tracing::info!(
pod_uid = %pod_uid,
namespace = %namespace,
cgroup_id,
"CNI admission received"
);
let event = NodeEvent::ShellBound {
session_id,
did,
cgroup_id,
namespace,
tenant: None, // Tenant resolved by the webhook handler
node: node_name.to_string(),
pod_uid,
};
event_tx.send(event).await?;
Ok(())
}

View file

@ -0,0 +1,47 @@
/// Shell BPF map manager.
///
/// Programs the `shell_map` eBPF map when a pod is admitted with a
/// known `cgroup_id`.
///
/// **STUB**: Real implementation requires `aya` and the shell BPF
/// programs from `substrate/shell/ebpf/`. The interface is defined
/// here; implementation is deferred until substrate-cni and the shell
/// BPF programs are available.
pub struct BpfManager;
impl BpfManager {
pub fn new() -> Self {
BpfManager
}
/// Program `shell_map` for a new pod.
///
/// STUB: logs the cgroup_id and returns Ok.
/// Real implementation will write a `shell_state` entry to the
/// BPF hash map keyed by cgroup_id.
pub async fn admit_pod(
&self,
cgroup_id: u64,
namespace: &str,
tenant: Option<&str>,
) -> anyhow::Result<()> {
tracing::info!(
cgroup_id,
namespace,
tenant,
"BpfManager::admit_pod (stub — shell_map programming not yet implemented)"
);
Ok(())
}
/// Remove `shell_map` entry for a terminated pod.
///
/// STUB: logs and returns Ok.
pub async fn terminate_pod(&self, cgroup_id: u64) -> anyhow::Result<()> {
tracing::info!(
cgroup_id,
"BpfManager::terminate_pod (stub — shell_map cleanup not yet implemented)"
);
Ok(())
}
}

View file

@ -0,0 +1,69 @@
/// Node agent configuration, loaded from environment variables.
///
/// Required: `NODE_NAME` (from Kubernetes Downward API).
/// All other values have sensible defaults for development.
#[derive(Debug, Clone)]
pub struct BasculeNodeConfig {
/// This node's name (from Downward API: `spec.nodeName`).
pub node_name: String,
/// Dashboard webhook URL.
pub dashboard_webhook_url: String,
/// Dashboard webhook shared secret (Bearer token).
pub dashboard_webhook_secret: String,
/// Unix socket path for CNI admission notifications.
pub admission_socket: String,
/// Namespace label used for tenant resolution.
pub tenant_label: String,
/// Max events per webhook POST batch.
pub webhook_batch_size: usize,
/// Seconds between webhook flushes.
pub webhook_flush_interval_secs: u64,
}
impl BasculeNodeConfig {
pub fn from_env() -> anyhow::Result<Self> {
let node_name = std::env::var("NODE_NAME")
.map_err(|_| anyhow::anyhow!("NODE_NAME environment variable is required (set via Kubernetes Downward API)"))?;
Ok(Self {
node_name,
dashboard_webhook_url: std::env::var("BASCULE_DASHBOARD_WEBHOOK_URL")
.unwrap_or_else(|_| "http://guildhouse-dashboard:8000/api/v1/governance/bascule/events/".into()),
dashboard_webhook_secret: std::env::var("BASCULE_DASHBOARD_WEBHOOK_SECRET")
.unwrap_or_default(),
admission_socket: std::env::var("BASCULE_ADMISSION_SOCKET")
.unwrap_or_else(|_| "/run/bascule/admission.sock".into()),
tenant_label: std::env::var("BASCULE_TENANT_LABEL")
.unwrap_or_else(|_| "guildhouse.dev/tenant".into()),
webhook_batch_size: std::env::var("BASCULE_WEBHOOK_BATCH_SIZE")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(10),
webhook_flush_interval_secs: std::env::var("BASCULE_WEBHOOK_FLUSH_INTERVAL_SECS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(5),
})
}
pub fn validate(&self) -> anyhow::Result<()> {
if self.dashboard_webhook_secret.is_empty() {
anyhow::bail!(
"BASCULE_DASHBOARD_WEBHOOK_SECRET must not be empty"
);
}
if self.dashboard_webhook_secret == "dev-secret-change-in-production" {
tracing::warn!(
"Using default dev webhook secret. \
Set BASCULE_DASHBOARD_WEBHOOK_SECRET in production."
);
}
Ok(())
}
}

View file

@ -0,0 +1,99 @@
/// Node-level events emitted by the pod watcher and admission listener.
///
/// Each variant maps to an AuditBridge wire-format event that the dashboard
/// webhook expects.
#[derive(Debug, Clone, serde::Serialize)]
#[serde(tag = "type")]
pub enum NodeEvent {
PodAdmitted {
pod_uid: String,
pod_name: String,
namespace: String,
tenant: Option<String>,
node: String,
cgroup_id: Option<u64>,
},
PodTerminated {
pod_uid: String,
namespace: String,
tenant: Option<String>,
node: String,
},
ShellBound {
session_id: String,
did: String,
cgroup_id: u64,
namespace: String,
tenant: Option<String>,
node: String,
pod_uid: String,
},
}
impl NodeEvent {
/// The AuditBridge event_kind string for the dashboard webhook.
pub fn event_kind(&self) -> &str {
match self {
NodeEvent::PodAdmitted { .. } => "PodAdmitted",
NodeEvent::PodTerminated { .. } => "SessionTerminated",
NodeEvent::ShellBound { .. } => "SessionEstablished",
}
}
/// Convert to the pipe-delimited AuditBridge payload format
/// that the dashboard webhook parser expects.
pub fn to_payload(&self) -> String {
match self {
NodeEvent::PodAdmitted {
pod_uid,
pod_name,
namespace,
tenant,
node,
cgroup_id,
} => {
let mut s = format!(
"SessionEstablished|session={pod_uid} did=pod:{namespace}/{pod_name} node={node}"
);
if let Some(t) = tenant {
s.push_str(&format!(" tenant={t}"));
}
if let Some(cg) = cgroup_id {
s.push_str(&format!(" cgroup_id={cg}"));
}
s
}
NodeEvent::PodTerminated {
pod_uid,
namespace,
tenant,
node,
} => {
let mut s = format!(
"SessionTerminated|session={pod_uid} reason=pod_deleted namespace={namespace} node={node}"
);
if let Some(t) = tenant {
s.push_str(&format!(" tenant={t}"));
}
s
}
NodeEvent::ShellBound {
session_id,
did,
cgroup_id,
namespace,
tenant,
node,
pod_uid,
} => {
let mut s = format!(
"SessionEstablished|session={session_id} did={did} cgroup_id={cgroup_id} namespace={namespace} node={node} pod_uid={pod_uid}"
);
if let Some(t) = tenant {
s.push_str(&format!(" tenant={t}"));
}
s
}
}
}
}

View file

@ -0,0 +1,99 @@
//! Bascule node agent — per-node DaemonSet for shell admission and webhook emission.
//!
//! Responsibilities:
//! - Watch pods on this node (via Kubernetes API)
//! - Accept CNI ADD notifications (via Unix socket)
//! - Emit admission events to the dashboard webhook
//! - Program shell BPF maps (stub — deferred to substrate-cni)
//!
//! This is NOT bascule-gateway. No gRPC server, no ceremony engine,
//! no PostgreSQL, no session manager.
mod admission;
mod bpf_manager;
mod config;
mod events;
mod pod_watcher;
mod tenant_resolver;
mod webhook_emitter;
use std::sync::Arc;
use crate::admission::AdmissionListener;
use crate::config::BasculeNodeConfig;
use crate::pod_watcher::PodWatcher;
use crate::tenant_resolver::TenantResolver;
use crate::webhook_emitter::WebhookEmitter;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// rustls provider must be installed before kube::Client.
rustls::crypto::ring::default_provider()
.install_default()
.ok();
// JSON tracing output for Kubernetes log aggregation.
tracing_subscriber::fmt()
.json()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.init();
let cfg = BasculeNodeConfig::from_env()?;
cfg.validate()?;
tracing::info!(node = %cfg.node_name, "bascule-node-agent starting");
// Kubernetes client (in-cluster or kubeconfig fallback).
let kube_client = kube::Client::try_default().await?;
tracing::info!("Kubernetes client connected");
// Event channel: pod_watcher + admission_listener → webhook_emitter
let (tx, rx) = tokio::sync::mpsc::channel::<events::NodeEvent>(256);
// Shared tenant resolver
let tenant_resolver = Arc::new(TenantResolver::new(
kube_client.clone(),
cfg.tenant_label.clone(),
));
// Pod watcher (Kubernetes API)
let pod_watcher = PodWatcher::new(
kube_client.clone(),
cfg.node_name.clone(),
tx.clone(),
tenant_resolver,
);
// CNI admission listener (Unix socket, stub until substrate-cni exists)
let admission_listener = AdmissionListener::new(
cfg.admission_socket.clone(),
tx,
cfg.node_name.clone(),
);
// Webhook emitter (HTTP POST to dashboard)
let webhook_emitter = WebhookEmitter::new(&cfg, rx);
tracing::info!(
webhook_url = %cfg.dashboard_webhook_url,
admission_socket = %cfg.admission_socket,
"All components initialized, starting event loop"
);
// Run all tasks. If any exits, the agent restarts (crash-loop).
tokio::select! {
res = pod_watcher.run() => {
tracing::error!(?res, "Pod watcher exited");
}
res = admission_listener.run() => {
tracing::error!(?res, "Admission listener exited");
}
res = webhook_emitter.run() => {
tracing::error!(?res, "Webhook emitter exited");
}
}
anyhow::bail!("A core task exited — agent should be restarted by Kubernetes")
}

View file

@ -0,0 +1,128 @@
use std::sync::Arc;
use kube::runtime::watcher;
use kube::runtime::WatchStreamExt;
use kube::Api;
use tokio::sync::mpsc;
use crate::events::NodeEvent;
use crate::tenant_resolver::TenantResolver;
/// Watches pods on this node and emits admission/termination events.
///
/// Uses a Kubernetes field selector (`spec.nodeName`) to limit the watch
/// to pods scheduled on the current node.
pub struct PodWatcher {
client: kube::Client,
node_name: String,
event_tx: mpsc::Sender<NodeEvent>,
tenant_resolver: Arc<TenantResolver>,
}
impl PodWatcher {
pub fn new(
client: kube::Client,
node_name: String,
event_tx: mpsc::Sender<NodeEvent>,
tenant_resolver: Arc<TenantResolver>,
) -> Self {
Self {
client,
node_name,
event_tx,
tenant_resolver,
}
}
/// Run the pod watcher loop. Reconnects on error (never returns Ok).
pub async fn run(self) -> anyhow::Result<()> {
use futures::StreamExt;
let pods: Api<k8s_openapi::api::core::v1::Pod> = Api::all(self.client.clone());
let watcher_config = watcher::Config::default()
.fields(&format!("spec.nodeName={}", self.node_name));
tracing::info!(node = %self.node_name, "Starting pod watcher");
// watcher automatically reconnects on 410 Gone.
let mut stream =
std::pin::pin!(watcher::watcher(pods, watcher_config).applied_objects());
// We track known UIDs to distinguish ADDED vs MODIFIED
let mut known_uids: std::collections::HashSet<String> = std::collections::HashSet::new();
loop {
match stream.next().await {
Some(Ok(pod)) => {
self.handle_pod_event(&pod, &mut known_uids).await;
}
Some(Err(e)) => {
tracing::error!(error = %e, "Pod watcher error, will reconnect");
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
break;
}
None => {
tracing::warn!("Pod watcher stream ended, restarting");
break;
}
}
}
// Return error to trigger restart in main select!
anyhow::bail!("Pod watcher stream ended")
}
async fn handle_pod_event(
&self,
pod: &k8s_openapi::api::core::v1::Pod,
known_uids: &mut std::collections::HashSet<String>,
) {
let meta = &pod.metadata;
let uid = match meta.uid.as_deref() {
Some(u) => u.to_string(),
None => return,
};
let name = meta.name.clone().unwrap_or_default();
let namespace = meta.namespace.clone().unwrap_or_else(|| "default".into());
// Check if pod is being deleted
if meta.deletion_timestamp.is_some() {
if known_uids.remove(&uid) {
let tenant = self.tenant_resolver.resolve(&namespace).await;
let event = NodeEvent::PodTerminated {
pod_uid: uid,
namespace,
tenant,
node: self.node_name.clone(),
};
if let Err(e) = self.event_tx.send(event).await {
tracing::error!(error = %e, "Failed to send PodTerminated event");
}
}
return;
}
// New pod — only emit PodAdmitted when phase is Running
let phase = pod
.status
.as_ref()
.and_then(|s| s.phase.as_deref())
.unwrap_or("Unknown");
if known_uids.insert(uid.clone()) && phase == "Running" {
let tenant = self.tenant_resolver.resolve(&namespace).await;
let event = NodeEvent::PodAdmitted {
pod_uid: uid,
pod_name: name,
namespace,
tenant,
node: self.node_name.clone(),
cgroup_id: None, // Populated later by CNI admission
};
if let Err(e) = self.event_tx.send(event).await {
tracing::error!(error = %e, "Failed to send PodAdmitted event");
}
}
}
}

View file

@ -0,0 +1,68 @@
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
/// Resolves Kubernetes namespace → tenant slug via namespace labels.
///
/// Reads the label specified by `tenant_label` (default: `guildhouse.dev/tenant`)
/// from the Namespace object. Results are cached to avoid repeated API calls.
pub struct TenantResolver {
client: kube::Client,
tenant_label: String,
cache: Arc<RwLock<HashMap<String, Option<String>>>>,
}
impl TenantResolver {
pub fn new(client: kube::Client, tenant_label: String) -> Self {
Self {
client,
tenant_label,
cache: Arc::new(RwLock::new(HashMap::new())),
}
}
/// Resolve the tenant slug for a namespace.
///
/// Returns `Some(slug)` if the namespace has the tenant label,
/// `None` otherwise. Results (including `None`) are cached.
pub async fn resolve(&self, namespace: &str) -> Option<String> {
// 1. Check cache
{
let cache = self.cache.read().await;
if let Some(cached) = cache.get(namespace) {
return cached.clone();
}
}
// 2. Fetch namespace from K8s API
let result = self.fetch_tenant_label(namespace).await;
// 3. Cache (even None, to avoid repeated lookups for unlabelled namespaces)
{
let mut cache = self.cache.write().await;
cache.insert(namespace.to_string(), result.clone());
}
result
}
async fn fetch_tenant_label(&self, namespace: &str) -> Option<String> {
use kube::Api;
let ns_api: Api<k8s_openapi::api::core::v1::Namespace> =
Api::all(self.client.clone());
match ns_api.get(namespace).await {
Ok(ns) => ns
.metadata
.labels
.as_ref()
.and_then(|labels| labels.get(&self.tenant_label).cloned()),
Err(e) => {
tracing::warn!(namespace, error = %e, "Failed to fetch namespace for tenant resolution");
None
}
}
}
}

View file

@ -0,0 +1,116 @@
use std::time::Duration;
use tokio::sync::mpsc;
use crate::config::BasculeNodeConfig;
use crate::events::NodeEvent;
/// Batches node events and POSTs them to the dashboard webhook.
///
/// Collects events until `batch_size` or `flush_interval`, whichever comes
/// first. Never crashes on HTTP errors — logs and continues.
pub struct WebhookEmitter {
client: reqwest::Client,
webhook_url: String,
webhook_secret: String,
batch_size: usize,
flush_interval: Duration,
rx: mpsc::Receiver<NodeEvent>,
}
impl WebhookEmitter {
pub fn new(config: &BasculeNodeConfig, rx: mpsc::Receiver<NodeEvent>) -> Self {
Self {
client: reqwest::Client::new(),
webhook_url: config.dashboard_webhook_url.clone(),
webhook_secret: config.dashboard_webhook_secret.clone(),
batch_size: config.webhook_batch_size,
flush_interval: Duration::from_secs(config.webhook_flush_interval_secs),
rx,
}
}
/// Main event loop. Runs until the channel is closed.
pub async fn run(mut self) -> anyhow::Result<()> {
tracing::info!(
url = %self.webhook_url,
batch_size = self.batch_size,
flush_interval_secs = self.flush_interval.as_secs(),
"Webhook emitter started"
);
let mut batch: Vec<NodeEvent> = Vec::with_capacity(self.batch_size);
let mut flush_timer = tokio::time::interval(self.flush_interval);
// First tick completes immediately — skip it.
flush_timer.tick().await;
loop {
tokio::select! {
event = self.rx.recv() => {
match event {
Some(evt) => {
batch.push(evt);
if batch.len() >= self.batch_size {
self.post_batch(&mut batch).await;
}
}
None => {
// Channel closed — flush remaining and exit.
if !batch.is_empty() {
self.post_batch(&mut batch).await;
}
tracing::info!("Event channel closed, webhook emitter shutting down");
return Ok(());
}
}
}
_ = flush_timer.tick() => {
if !batch.is_empty() {
self.post_batch(&mut batch).await;
}
}
}
}
}
async fn post_batch(&self, batch: &mut Vec<NodeEvent>) {
let events: Vec<serde_json::Value> = batch
.drain(..)
.map(|evt| {
serde_json::json!({
"event_kind": evt.event_kind(),
"payload": evt.to_payload(),
})
})
.collect();
let count = events.len();
let body = serde_json::json!({ "events": events });
match self
.client
.post(&self.webhook_url)
.header("Content-Type", "application/json")
.header(
"Authorization",
format!("Bearer {}", self.webhook_secret),
)
.json(&body)
.send()
.await
{
Ok(resp) => {
let status = resp.status();
if status.is_success() || status.as_u16() == 202 {
tracing::debug!(count, status = %status, "Webhook batch posted");
} else {
let text = resp.text().await.unwrap_or_default();
tracing::warn!(count, status = %status, body = %text, "Webhook batch rejected");
}
}
Err(e) => {
tracing::error!(count, error = %e, "Webhook POST failed (will retry on next flush)");
}
}
}
}

13
bascule-proto/Cargo.toml Normal file
View file

@ -0,0 +1,13 @@
[package]
name = "bascule-proto"
version = "0.1.0"
edition = "2021"
description = "Generated gRPC stubs for Bascule protocol services"
[dependencies]
tonic = { workspace = true }
prost = { workspace = true }
prost-types = { workspace = true }
[build-dependencies]
tonic-build = "0.12"

21
bascule-proto/build.rs Normal file
View file

@ -0,0 +1,21 @@
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Proto files live at workspace root: ../proto/
let proto_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.join("proto");
tonic_build::configure()
.build_server(true)
.build_client(true)
.compile_protos(
&[
proto_root.join("bascule/v1/gateway.proto"),
proto_root.join("bascule/v1/session.proto"),
proto_root.join("bascule/v1/command.proto"),
proto_root.join("bascule/v1/ceremony.proto"),
],
&[&proto_root],
)?;
Ok(())
}

8
bascule-proto/src/lib.rs Normal file
View file

@ -0,0 +1,8 @@
pub mod bascule {
pub mod v1 {
tonic::include_proto!("bascule.v1");
}
}
/// Re-export for convenient access.
pub use bascule::v1 as bascule_v1;

62
bascule-shell/Cargo.toml Normal file
View file

@ -0,0 +1,62 @@
[package]
name = "bascule-shell"
version = "0.1.0"
edition = "2021"
description = "Bascule governance shell — CLI for governed cluster access"
[lib]
name = "bascule_shell"
path = "src/lib.rs"
[[bin]]
name = "bascule"
path = "src/main.rs"
[dependencies]
bascule-core = { workspace = true }
bascule-proto = { workspace = true }
# Cross-workspace path deps — Guildhouse services.
# Future: extract to standalone crates.
registry-protocol = { path = "../../guildhouse/services/registry-protocol" }
# workspace::v1 proto for attach command (workspace-controller gRPC)
guildhouse-proto = { path = "../../guildhouse/services/guildhouse-proto" }
# CLI
clap = { workspace = true }
# Command module discovery
which = { workspace = true }
# gRPC
tonic = { workspace = true }
# Async
tokio = { workspace = true }
async-trait = { workspace = true }
# HTTP (for OIDC token exchange)
reqwest = { workspace = true }
# Serialization
serde = { workspace = true }
serde_json = { workspace = true }
serde_yaml = { workspace = true }
# Observability
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
# SSH
russh = { workspace = true }
russh-keys = { workspace = true }
ssh-key = { workspace = true }
rand = { workspace = true }
# Common
chrono = { workspace = true }
anyhow = { workspace = true }
thiserror = { workspace = true }
uuid = { workspace = true }
rustls = { workspace = true }
dirs = { workspace = true }

198
bascule-shell/src/attach.rs Normal file
View file

@ -0,0 +1,198 @@
//! `bascule attach workspace/{name}` — attach to a governed filesystem workspace.
//!
//! Resolves the workspace DID from the DID registry, calls workspace-controller
//! to create a SessionWorkspace (git worktree), then exec's a shell with the
//! worktree path as cwd.
use guildhouse_proto::workspace::v1::{
workspace_controller_client::WorkspaceControllerClient, CreateWorkspaceRequest,
};
use uuid::Uuid;
/// Configuration for the attach command.
pub struct AttachContext {
pub workspace_name: String,
pub domain: String,
pub registry_url: String,
pub workspace_controller_addr: String,
pub caller_name: String,
pub caller_email: String,
}
impl AttachContext {
/// Build from environment variables with sensible defaults.
pub fn from_env(workspace_name: String) -> Self {
Self {
workspace_name,
domain: std::env::var("BASCULE_DID_DOMAIN")
.unwrap_or_else(|_| "guildhouse.local".into()),
registry_url: std::env::var("DID_REGISTRY_URL")
.unwrap_or_else(|_| "http://did-registry.guildhouse-system.svc:3000".into()),
workspace_controller_addr: std::env::var("WORKSPACE_CONTROLLER_ADDR")
.unwrap_or_else(|_| "http://localhost:50057".into()),
caller_name: std::env::var("BASCULE_AUTHOR_NAME").unwrap_or_else(|_| {
std::process::Command::new("git")
.args(["config", "user.name"])
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.unwrap_or_else(|| "bascule-user".into())
}),
caller_email: std::env::var("BASCULE_AUTHOR_EMAIL").unwrap_or_else(|_| {
std::process::Command::new("git")
.args(["config", "user.email"])
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.unwrap_or_else(|| "bascule@guildhouse.local".into())
}),
}
}
}
pub async fn run_attach(ctx: AttachContext) -> anyhow::Result<()> {
// Step 1: Resolve workspace DID from did-registry
let did_uri = format!("did:web:{}:workspace:{}", ctx.domain, ctx.workspace_name);
println!("Resolving workspace DID: {did_uri}");
let did_url = format!(
"{}/did/web/{}/workspace/{}",
ctx.registry_url, ctx.domain, ctx.workspace_name,
);
let resp = reqwest::get(&did_url).await.map_err(|e| {
anyhow::anyhow!(
"DID registry unreachable at {}: {}. Is did-registry running?",
ctx.registry_url,
e
)
})?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
anyhow::bail!(
"Workspace '{}' not found in DID registry. Has it been registered?",
ctx.workspace_name
);
}
let doc: serde_json::Value = resp.json().await.map_err(|e| {
anyhow::anyhow!("Failed to parse DID document: {e}")
})?;
// Step 2: Determine read_only from verification methods.
// Phase A: any DID document with verification methods = read-write.
// Missing/empty verification methods = read-only (safe default).
let read_only = determine_read_only(&doc);
// Step 3: Log file targets (enforcement pending Q5 kernel work).
log_file_targets(&doc);
// Step 4: Call workspace-controller gRPC to create SessionWorkspace
let session_id = Uuid::new_v4().to_string();
let channel = tonic::transport::Channel::from_shared(ctx.workspace_controller_addr.clone())?
.connect()
.await
.map_err(|e| {
anyhow::anyhow!(
"Could not connect to workspace-controller at {}: {}. Is it running?",
ctx.workspace_controller_addr,
e
)
})?;
let mut client = WorkspaceControllerClient::new(channel);
let resp = client
.create_workspace(tonic::Request::new(CreateWorkspaceRequest {
session_id: session_id.clone(),
base_branch: "main".to_string(),
author_name: ctx.caller_name.clone(),
author_email: ctx.caller_email.clone(),
read_only,
}))
.await
.map_err(|e| {
anyhow::anyhow!(
"Could not create workspace session: {e}. Is workspace-controller running?"
)
})?
.into_inner();
let workspace_path = resp.workspace_path;
let branch_name = resp.branch_name;
let base_commit = resp.base_commit;
// Step 5: Print summary
println!();
println!("Workspace attached:");
println!(" Path: {workspace_path}");
println!(
" Branch: {}",
if branch_name.is_empty() {
"(read-only)".to_string()
} else {
branch_name
}
);
println!(
" Mode: {}",
if read_only { "read-only" } else { "read-write" }
);
println!(
" Base: {}",
&base_commit[..8.min(base_commit.len())]
);
println!();
// Step 6: Exec $SHELL in worktree (replaces current process)
use std::os::unix::process::CommandExt;
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".into());
let err = std::process::Command::new(&shell)
.current_dir(&workspace_path)
.env("BASCULE_WORKSPACE", &workspace_path)
.env("BASCULE_SESSION_ID", &session_id)
.env("PS1", format!("bascule:{}$ ", ctx.workspace_name))
.exec();
// exec() only returns on error
anyhow::bail!("Failed to exec {}: {}", shell, err)
}
/// Phase A: any DID document with at least one verification method = read-write.
/// No verification methods or missing field = read-only (safe default).
fn determine_read_only(doc: &serde_json::Value) -> bool {
doc.get("verificationMethod")
.and_then(|v| v.as_array())
.map(|vms| vms.is_empty())
.unwrap_or(true)
}
/// Log file: targets from verification methods at debug level.
/// Enforcement is not yet implemented (SPEC-DID-0001 §12 Q5).
fn log_file_targets(doc: &serde_json::Value) {
let vms = doc.get("verificationMethod").and_then(|v| v.as_array());
if let Some(vms) = vms {
for vm in vms {
if let Some(targets) = vm.get("substrate:targets").and_then(|t| t.as_array()) {
let file_targets: Vec<&str> = targets
.iter()
.filter_map(|t| t.as_str())
.filter(|t| t.starts_with("file:"))
.collect();
if !file_targets.is_empty() {
tracing::debug!(
targets = ?file_targets,
"Workspace file targets (enforcement pending Q5)"
);
}
}
}
}
}

155
bascule-shell/src/auth.rs Normal file
View file

@ -0,0 +1,155 @@
use std::io::{Read, Write};
use std::net::TcpListener;
use clap::Args;
use serde::Deserialize;
use crate::config::TokenStore;
#[derive(Args)]
pub struct ConnectArgs {
/// OIDC issuer URL (e.g., http://localhost:8080/realms/demo)
#[arg(long)]
pub issuer: String,
/// OIDC client ID
#[arg(long, default_value = "bascule")]
pub client_id: String,
/// Skip OIDC flow and use this token directly (development only)
#[arg(long)]
pub token: Option<String>,
}
pub async fn connect(args: ConnectArgs, gateway: &str) -> anyhow::Result<()> {
if let Some(token) = args.token {
let store = TokenStore {
gateway_endpoint: gateway.to_string(),
id_token: token.clone(),
access_token: token,
refresh_token: None,
expires_at: None,
session_id: None,
};
store.save()?;
println!("Token saved. Connected to {gateway}");
return Ok(());
}
// OIDC Authorization Code flow
let listener = TcpListener::bind("127.0.0.1:0")?;
let port = listener.local_addr()?.port();
let redirect_uri = format!("http://localhost:{port}/callback");
let auth_url = format!(
"{}/protocol/openid-connect/auth?client_id={}&redirect_uri={}&response_type=code&scope=openid+email+profile",
args.issuer.trim_end_matches('/'),
args.client_id,
encode_uri_component(&redirect_uri),
);
println!("Open this URL in your browser to authenticate:\n");
println!(" {auth_url}\n");
println!("Waiting for callback on port {port}...");
// Accept the OAuth callback
let (mut stream, _) = listener.accept()?;
let mut buf = [0u8; 4096];
let n = stream.read(&mut buf)?;
let request = String::from_utf8_lossy(&buf[..n]);
// Extract the authorization code from GET /callback?code=...&...
let code = extract_code_from_request(&request)
.ok_or_else(|| anyhow::anyhow!("no authorization code in callback"))?;
// Send a success page back to the browser
let html = "<html><body><h1>Authentication successful!</h1><p>You can close this tab.</p></body></html>";
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\n\r\n{}",
html.len(),
html
);
stream.write_all(response.as_bytes())?;
drop(stream);
// Exchange the authorization code for tokens
let token_url = format!(
"{}/protocol/openid-connect/token",
args.issuer.trim_end_matches('/')
);
let client = reqwest::Client::new();
let resp = client
.post(&token_url)
.form(&[
("grant_type", "authorization_code"),
("client_id", args.client_id.as_str()),
("code", code.as_str()),
("redirect_uri", redirect_uri.as_str()),
])
.send()
.await?;
if !resp.status().is_success() {
let body = resp.text().await?;
anyhow::bail!("Token exchange failed: {body}");
}
let token_resp: TokenResponse = resp.json().await?;
let store = TokenStore {
gateway_endpoint: gateway.to_string(),
id_token: token_resp.id_token.unwrap_or_default(),
access_token: token_resp.access_token,
refresh_token: token_resp.refresh_token,
expires_at: token_resp
.expires_in
.map(|secs| chrono::Utc::now() + chrono::Duration::seconds(secs)),
session_id: None,
};
store.save()?;
println!("Authenticated successfully. Connected to {gateway}");
Ok(())
}
#[derive(Deserialize)]
struct TokenResponse {
access_token: String,
id_token: Option<String>,
refresh_token: Option<String>,
expires_in: Option<i64>,
}
/// Extract the `code` query parameter from an HTTP request line.
fn extract_code_from_request(request: &str) -> Option<String> {
let first_line = request.lines().next()?;
let path = first_line.split_whitespace().nth(1)?;
let query_start = path.find('?')? + 1;
let query = &path[query_start..];
for param in query.split('&') {
if let Some(value) = param.strip_prefix("code=") {
return Some(value.to_string());
}
}
None
}
/// Minimal percent-encoding for URI components.
fn encode_uri_component(s: &str) -> String {
let mut encoded = String::with_capacity(s.len() * 2);
for byte in s.bytes() {
match byte {
b'A'..=b'Z'
| b'a'..=b'z'
| b'0'..=b'9'
| b'-'
| b'_'
| b'.'
| b'~' => encoded.push(byte as char),
_ => encoded.push_str(&format!("%{byte:02X}")),
}
}
encoded
}

View file

@ -0,0 +1,96 @@
use clap::Args;
use crate::config::TokenStore;
use crate::output;
#[derive(Args)]
pub struct ExecArgs {
/// Command verb (get, describe, logs, status, etc.)
pub verb: String,
/// Resource type (pods, deployments, services, etc.)
pub resource_type: Option<String>,
/// Resource name
pub resource_name: Option<String>,
/// Namespace
#[arg(short, long)]
pub namespace: Option<String>,
}
pub async fn exec(args: ExecArgs, gateway: &str, output_format: &str) -> anyhow::Result<()> {
let store = TokenStore::load()?;
let session_id = store
.session_id
.as_ref()
.ok_or_else(|| anyhow::anyhow!("No active session. Run `bascule session request` first."))?;
let mut client = crate::session::connect_gateway(gateway).await?;
let request = crate::session::authenticated_request(
bascule_proto::bascule_v1::ExecuteCommandRequest {
session_id: session_id.clone(),
verb: args.verb,
namespace: args.namespace,
resource_type: args.resource_type,
resource_name: args.resource_name,
parameters: None,
output_format: output_format.to_string(),
},
&store.access_token,
);
let response = client.execute_command(request).await?;
output::print_command_response(&response.into_inner(), output_format);
Ok(())
}
/// Parse a REPL input line into command components.
pub struct ParsedCommand {
pub verb: String,
pub namespace: Option<String>,
pub resource_type: Option<String>,
pub resource_name: Option<String>,
}
pub fn parse_line(input: &str) -> Option<ParsedCommand> {
let parts: Vec<&str> = input.split_whitespace().collect();
if parts.is_empty() {
return None;
}
let verb = parts[0].to_string();
let mut namespace = None;
let mut resource_type = None;
let mut resource_name = None;
let mut i = 1;
while i < parts.len() {
match parts[i] {
"-n" | "--namespace" => {
if i + 1 < parts.len() {
namespace = Some(parts[i + 1].to_string());
i += 2;
continue;
}
}
arg => {
if resource_type.is_none() {
resource_type = Some(arg.to_string());
} else if resource_name.is_none() {
resource_name = Some(arg.to_string());
}
}
}
i += 1;
}
Some(ParsedCommand {
verb,
namespace,
resource_type,
resource_name,
})
}

View file

@ -0,0 +1,50 @@
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
/// Stored OIDC tokens and gateway connection info.
#[derive(Debug, Serialize, Deserialize)]
pub struct TokenStore {
pub gateway_endpoint: String,
pub id_token: String,
pub access_token: String,
pub refresh_token: Option<String>,
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
/// Active session ID (set after RequestSession succeeds).
pub session_id: Option<String>,
}
impl TokenStore {
pub fn config_dir() -> PathBuf {
dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("bascule")
}
pub fn token_path() -> PathBuf {
Self::config_dir().join("tokens.json")
}
pub fn save(&self) -> anyhow::Result<()> {
let dir = Self::config_dir();
std::fs::create_dir_all(&dir)?;
let path = Self::token_path();
let json = serde_json::to_string_pretty(self)?;
std::fs::write(&path, &json)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
}
Ok(())
}
pub fn load() -> anyhow::Result<Self> {
let path = Self::token_path();
let json = std::fs::read_to_string(&path)
.map_err(|_| anyhow::anyhow!("Not connected. Run `bascule connect` first."))?;
Ok(serde_json::from_str(&json)?)
}
}

View file

@ -0,0 +1,234 @@
//! Shell command trait and supporting types.
//!
//! Every shell command implements [`ShellCommand`]. Commands are loaded from the
//! container image at startup and dispatched by the command registry.
use std::fmt;
use async_trait::async_trait;
use super::session::GovernedSession;
/// Every shell command implements this trait.
#[async_trait]
pub trait ShellCommand: Send + Sync {
/// Primary command name (e.g., "query", "void", "deploy").
fn name(&self) -> &str;
/// Alternative names (e.g., ["q"] for "query").
fn aliases(&self) -> Vec<&str> {
vec![]
}
/// What access tier this command belongs to.
fn tier(&self) -> CommandTier;
/// What SAT scope is required to execute this command.
fn required_scope(&self) -> RequiredScope;
/// Human-readable description for help output.
fn description(&self) -> &str;
/// Usage string (e.g., "query <registry> <artifact_id>").
fn usage(&self) -> &str;
/// Execute the command with the given arguments and session context.
async fn execute(
&self,
args: &[String],
session: &mut GovernedSession,
) -> Result<CommandOutput, CommandError>;
/// Tab completion hints for arguments.
fn completions(&self, _args: &[String], _session: &GovernedSession) -> Vec<String> {
vec![]
}
}
/// Which image tier this command belongs to.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum CommandTier {
/// Read-only operations — always available if the command is in the image.
Analyst,
/// Application-level mutations — require elevation.
Administrator,
/// Infrastructure-level operations — require elevation + stronger ceremony.
Engineer,
}
impl fmt::Display for CommandTier {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
CommandTier::Analyst => write!(f, "analyst"),
CommandTier::Administrator => write!(f, "administrator"),
CommandTier::Engineer => write!(f, "engineer"),
}
}
}
impl CommandTier {
pub fn from_str(s: &str) -> Option<Self> {
match s {
"analyst" => Some(CommandTier::Analyst),
"administrator" => Some(CommandTier::Administrator),
"engineer" => Some(CommandTier::Engineer),
_ => None,
}
}
}
/// What SAT scope is required to execute a command.
#[derive(Debug, Clone)]
pub enum RequiredScope {
/// Any valid (non-expired) session SAT.
ReadOnly,
/// Requires elevated SAT with specific registry+verb scope.
Elevated { registry: String, verb: String },
}
/// Result of checking scope authorization.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ScopeCheck {
Authorized,
/// Base SAT expired (session should end).
Expired,
/// Elevation required for this operation.
ElevationRequired { registry: String, verb: String },
/// Was elevated but TTL ran out.
ElevationExpired,
}
/// Output from command execution.
#[derive(Debug)]
pub struct CommandOutput {
pub lines: Vec<OutputLine>,
}
impl CommandOutput {
pub fn text(s: impl Into<String>) -> Self {
Self {
lines: vec![OutputLine::Text(s.into())],
}
}
pub fn status(label: impl Into<String>, value: impl Into<String>, color: OutputColor) -> Self {
Self {
lines: vec![OutputLine::Status {
label: label.into(),
value: value.into(),
color,
}],
}
}
pub fn empty() -> Self {
Self { lines: vec![] }
}
}
/// A single line of structured output.
#[derive(Debug)]
pub enum OutputLine {
Text(String),
Table {
headers: Vec<String>,
rows: Vec<Vec<String>>,
},
Status {
label: String,
value: String,
color: OutputColor,
},
Separator,
Blank,
}
/// ANSI color hint for terminal output.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutputColor {
Default,
Green,
Yellow,
Red,
Cyan,
}
/// Errors from command execution.
#[derive(Debug, thiserror::Error)]
pub enum CommandError {
#[error("Unknown command: {0}")]
Unknown(String),
#[error("Insufficient scope: {required} required, current scope is read-only")]
InsufficientScope { required: String },
#[error("Elevation required: {registry}:{verb}")]
ElevationRequired { registry: String, verb: String },
#[error("No tenant selected. Use: use <tenant_id>")]
NoTenantContext,
#[error("Invalid arguments: {0}")]
InvalidArgs(String),
#[error("Service unavailable: {0}")]
ServiceUnavailable(String),
#[error("Governance error: {0}")]
GovernanceError(String),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn command_tier_display() {
assert_eq!(CommandTier::Analyst.to_string(), "analyst");
assert_eq!(CommandTier::Administrator.to_string(), "administrator");
assert_eq!(CommandTier::Engineer.to_string(), "engineer");
}
#[test]
fn command_tier_from_str() {
assert_eq!(CommandTier::from_str("analyst"), Some(CommandTier::Analyst));
assert_eq!(
CommandTier::from_str("administrator"),
Some(CommandTier::Administrator)
);
assert_eq!(
CommandTier::from_str("engineer"),
Some(CommandTier::Engineer)
);
assert_eq!(CommandTier::from_str("unknown"), None);
}
#[test]
fn command_tier_ordering() {
assert!(CommandTier::Analyst < CommandTier::Administrator);
assert!(CommandTier::Administrator < CommandTier::Engineer);
}
#[test]
fn command_output_text() {
let output = CommandOutput::text("hello");
assert_eq!(output.lines.len(), 1);
match &output.lines[0] {
OutputLine::Text(s) => assert_eq!(s, "hello"),
_ => panic!("expected Text"),
}
}
#[test]
fn command_error_display() {
let err = CommandError::Unknown("foo".to_string());
assert_eq!(err.to_string(), "Unknown command: foo");
let err = CommandError::ElevationRequired {
registry: "invoice".to_string(),
verb: "void".to_string(),
};
assert_eq!(err.to_string(), "Elevation required: invoice:void");
}
#[test]
fn scope_check_equality() {
assert_eq!(ScopeCheck::Authorized, ScopeCheck::Authorized);
assert_eq!(ScopeCheck::Expired, ScopeCheck::Expired);
assert_ne!(ScopeCheck::Authorized, ScopeCheck::Expired);
}
}

View file

@ -0,0 +1,76 @@
use async_trait::async_trait;
use crate::governed::command_trait::*;
use crate::governed::session::{GovernedSession, Governed};
pub struct ApproveCommand;
#[async_trait]
impl ShellCommand for ApproveCommand {
fn name(&self) -> &str { "approve" }
fn tier(&self) -> CommandTier { CommandTier::Administrator }
fn required_scope(&self) -> RequiredScope {
// Approval itself is read-only — it's the TARGET operation that
// requires elevation. The approver's identity comes from the session.
RequiredScope::ReadOnly
}
fn description(&self) -> &str { "Approve a pending ceremony" }
fn usage(&self) -> &str { "approve <ceremony_id> [comment]" }
async fn execute(
&self,
args: &[String],
session: &mut GovernedSession,
) -> Result<CommandOutput, CommandError> {
if args.is_empty() {
return Err(CommandError::InvalidArgs(
"Usage: approve <ceremony_id> [comment]".to_string(),
));
}
let ceremony_id = &args[0];
let comment = if args.len() > 1 {
Some(args[1..].join(" "))
} else {
None
};
// Phase 1: stub — reports what would happen.
// Production: calls CeremonyService.ApproveCeremony with session identity.
let identity = session.identity().display_name.clone();
let role = session.identity().roles.first().cloned().unwrap_or_default();
let mut lines = vec![
OutputLine::Status {
label: "Ceremony".to_string(),
value: ceremony_id.clone(),
color: OutputColor::Cyan,
},
OutputLine::Status {
label: "Approver".to_string(),
value: format!("{} ({})", identity, role),
color: OutputColor::Default,
},
OutputLine::Status {
label: "Decision".to_string(),
value: "APPROVED".to_string(),
color: OutputColor::Green,
},
];
if let Some(c) = &comment {
lines.push(OutputLine::Status {
label: "Comment".to_string(),
value: c.clone(),
color: OutputColor::Default,
});
}
lines.push(OutputLine::Blank);
lines.push(OutputLine::Text(
"(stub: CeremonyService.ApproveCeremony not connected)".to_string(),
));
Ok(CommandOutput { lines })
}
}

View file

@ -0,0 +1,43 @@
use async_trait::async_trait;
use crate::governed::command_trait::*;
use crate::governed::session::GovernedSession;
pub struct CeremoniesCommand;
#[async_trait]
impl ShellCommand for CeremoniesCommand {
fn name(&self) -> &str { "ceremonies" }
fn aliases(&self) -> Vec<&str> { vec!["cer"] }
fn tier(&self) -> CommandTier { CommandTier::Analyst }
fn required_scope(&self) -> RequiredScope { RequiredScope::ReadOnly }
fn description(&self) -> &str { "List pending governance ceremonies" }
fn usage(&self) -> &str { "ceremonies [--all]" }
async fn execute(
&self,
_args: &[String],
_session: &mut GovernedSession,
) -> Result<CommandOutput, CommandError> {
// Phase 1: stub — shows placeholder ceremony list.
// Production: calls CeremonyService.ListPendingCeremonies.
let lines = vec![
OutputLine::Table {
headers: vec![
"CEREMONY".to_string(),
"TYPE".to_string(),
"SUBJECT".to_string(),
"APPROVALS".to_string(),
"DEADLINE".to_string(),
],
rows: vec![
vec!["(no ceremonies loaded)".to_string(), "-".to_string(), "-".to_string(), "-".to_string(), "-".to_string()],
],
},
OutputLine::Blank,
OutputLine::Text("Use 'approve <ceremony_id>' or 'deny <ceremony_id>' to act.".to_string()),
];
Ok(CommandOutput { lines })
}
}

View file

@ -0,0 +1,38 @@
use async_trait::async_trait;
use crate::governed::command_trait::*;
use crate::governed::session::{GovernedSession, Governed};
pub struct DeescalateCommand;
#[async_trait]
impl ShellCommand for DeescalateCommand {
fn name(&self) -> &str { "deescalate" }
fn tier(&self) -> CommandTier { CommandTier::Administrator }
fn required_scope(&self) -> RequiredScope { RequiredScope::ReadOnly }
fn description(&self) -> &str { "Drop elevation to read-only scope" }
fn usage(&self) -> &str { "deescalate" }
async fn execute(
&self,
_args: &[String],
session: &mut GovernedSession,
) -> Result<CommandOutput, CommandError> {
if !session.is_elevated() {
return Ok(CommandOutput::text("Not currently elevated."));
}
session.deescalate();
Ok(CommandOutput {
lines: vec![
OutputLine::Status {
label: "Scope".to_string(),
value: "read-only".to_string(),
color: OutputColor::Green,
},
OutputLine::Text("Elevation dropped. Returned to read-only scope.".to_string()),
],
})
}
}

View file

@ -0,0 +1,65 @@
use async_trait::async_trait;
use crate::governed::command_trait::*;
use crate::governed::session::{GovernedSession, Governed};
pub struct DenyCommand;
#[async_trait]
impl ShellCommand for DenyCommand {
fn name(&self) -> &str { "deny" }
fn tier(&self) -> CommandTier { CommandTier::Administrator }
fn required_scope(&self) -> RequiredScope { RequiredScope::ReadOnly }
fn description(&self) -> &str { "Deny a pending ceremony" }
fn usage(&self) -> &str { "deny <ceremony_id> [reason]" }
async fn execute(
&self,
args: &[String],
session: &mut GovernedSession,
) -> Result<CommandOutput, CommandError> {
if args.is_empty() {
return Err(CommandError::InvalidArgs(
"Usage: deny <ceremony_id> [reason]".to_string(),
));
}
let ceremony_id = &args[0];
let reason = if args.len() > 1 {
args[1..].join(" ")
} else {
"No reason given".to_string()
};
let identity = session.identity().display_name.clone();
let lines = vec![
OutputLine::Status {
label: "Ceremony".to_string(),
value: ceremony_id.clone(),
color: OutputColor::Cyan,
},
OutputLine::Status {
label: "Denier".to_string(),
value: identity,
color: OutputColor::Default,
},
OutputLine::Status {
label: "Decision".to_string(),
value: "DENIED".to_string(),
color: OutputColor::Red,
},
OutputLine::Status {
label: "Reason".to_string(),
value: reason,
color: OutputColor::Default,
},
OutputLine::Blank,
OutputLine::Text(
"(stub: CeremonyService.DenyCeremony not connected)".to_string(),
),
];
Ok(CommandOutput { lines })
}
}

View file

@ -0,0 +1,71 @@
use async_trait::async_trait;
use crate::governed::command_trait::*;
use crate::governed::session::{GovernedSession, Governed};
pub struct DeployCommand;
#[async_trait]
impl ShellCommand for DeployCommand {
fn name(&self) -> &str { "deploy" }
fn tier(&self) -> CommandTier { CommandTier::Engineer }
fn required_scope(&self) -> RequiredScope {
RequiredScope::Elevated {
registry: "schematic".to_string(),
verb: "deploy".to_string(),
}
}
fn description(&self) -> &str { "Deploy a schematic version" }
fn usage(&self) -> &str { "deploy <schematic_name> <version> [environment]" }
async fn execute(
&self,
args: &[String],
session: &mut GovernedSession,
) -> Result<CommandOutput, CommandError> {
if session.tenant_context().is_none() {
return Err(CommandError::NoTenantContext);
}
if args.len() < 2 {
return Err(CommandError::InvalidArgs(
"Usage: deploy <schematic_name> <version> [environment]".to_string(),
));
}
let schematic_name = &args[0];
let version = &args[1];
let environment = args.get(2).map(|s| s.as_str()).unwrap_or("default");
let tenant = session.tenant_context().unwrap_or("*").to_string();
let lines = vec![
OutputLine::Text(format!(
"Deploying: {} v{} -> {} (tenant: {})",
schematic_name, version, environment, tenant
)),
OutputLine::Separator,
OutputLine::Status {
label: "Schematic".to_string(),
value: schematic_name.clone(),
color: OutputColor::Cyan,
},
OutputLine::Status {
label: "Version".to_string(),
value: version.clone(),
color: OutputColor::Cyan,
},
OutputLine::Status {
label: "Environment".to_string(),
value: environment.to_string(),
color: OutputColor::Default,
},
OutputLine::Status {
label: "Status".to_string(),
value: "(stub: deployment not connected)".to_string(),
color: OutputColor::Yellow,
},
];
Ok(CommandOutput { lines })
}
}

View file

@ -0,0 +1,73 @@
use async_trait::async_trait;
use crate::governed::command_trait::*;
use crate::governed::session::{GovernedSession, Governed};
pub struct ElevateCommand;
#[async_trait]
impl ShellCommand for ElevateCommand {
fn name(&self) -> &str { "elevate" }
fn tier(&self) -> CommandTier { CommandTier::Administrator }
fn required_scope(&self) -> RequiredScope { RequiredScope::ReadOnly }
fn description(&self) -> &str { "Request scope elevation" }
fn usage(&self) -> &str { "elevate <registry> <verb> [ttl_minutes]" }
async fn execute(
&self,
args: &[String],
session: &mut GovernedSession,
) -> Result<CommandOutput, CommandError> {
if session.tenant_context().is_none() {
return Err(CommandError::NoTenantContext);
}
if args.len() < 2 {
return Err(CommandError::InvalidArgs(
"Usage: elevate <registry> <verb> [ttl_minutes]".to_string(),
));
}
let registry = &args[0];
let verb = &args[1];
let ttl = args.get(2).and_then(|s| s.parse::<u64>().ok()).unwrap_or(15);
let tenant = session.tenant_context().unwrap_or("*").to_string();
// Phase 1: stub — shows what would happen.
// Production: creates ceremony via CeremonyService, polls for approval.
let lines = vec![
OutputLine::Text(format!(
"Requesting elevation: {}:{} on {} ({}m TTL)",
registry, verb, tenant, ttl
)),
OutputLine::Separator,
OutputLine::Status {
label: "Registry".to_string(),
value: registry.clone(),
color: OutputColor::Cyan,
},
OutputLine::Status {
label: "Verb".to_string(),
value: verb.clone(),
color: OutputColor::Cyan,
},
OutputLine::Status {
label: "Tenant".to_string(),
value: tenant,
color: OutputColor::Default,
},
OutputLine::Status {
label: "TTL".to_string(),
value: format!("{} minutes", ttl),
color: OutputColor::Default,
},
OutputLine::Status {
label: "Status".to_string(),
value: "(stub: ceremony creation not connected)".to_string(),
color: OutputColor::Yellow,
},
];
Ok(CommandOutput { lines })
}
}

View file

@ -0,0 +1,26 @@
use async_trait::async_trait;
use crate::governed::command_trait::*;
use crate::governed::session::GovernedSession;
pub struct ExitCommand;
#[async_trait]
impl ShellCommand for ExitCommand {
fn name(&self) -> &str { "exit" }
fn aliases(&self) -> Vec<&str> { vec!["quit"] }
fn tier(&self) -> CommandTier { CommandTier::Analyst }
fn required_scope(&self) -> RequiredScope { RequiredScope::ReadOnly }
fn description(&self) -> &str { "Exit the shell" }
fn usage(&self) -> &str { "exit" }
async fn execute(
&self,
_args: &[String],
_session: &mut GovernedSession,
) -> Result<CommandOutput, CommandError> {
// The executor handles exit by detecting the command name.
// This execute is called if it gets through.
Ok(CommandOutput::text("Goodbye."))
}
}

View file

@ -0,0 +1,58 @@
use async_trait::async_trait;
use crate::governed::command_trait::*;
use crate::governed::session::GovernedSession;
pub struct HelpCommand;
#[async_trait]
impl ShellCommand for HelpCommand {
fn name(&self) -> &str { "help" }
fn aliases(&self) -> Vec<&str> { vec!["?"] }
fn tier(&self) -> CommandTier { CommandTier::Analyst }
fn required_scope(&self) -> RequiredScope { RequiredScope::ReadOnly }
fn description(&self) -> &str { "Show available commands" }
fn usage(&self) -> &str { "help [command]" }
async fn execute(
&self,
args: &[String],
_session: &mut GovernedSession,
) -> Result<CommandOutput, CommandError> {
if let Some(cmd_name) = args.first() {
// Detailed help for a specific command is handled by the executor
// since it needs access to the registry. Return a hint.
Ok(CommandOutput::text(format!("Use 'help' to see all commands. Requested help for: {}", cmd_name)))
} else {
let lines = vec![
OutputLine::Text("Available commands:".to_string()),
OutputLine::Blank,
OutputLine::Text("Analyst (read-only):".to_string()),
OutputLine::Text(" help Show available commands".to_string()),
OutputLine::Text(" status Session and connection status".to_string()),
OutputLine::Text(" whoami Show identity and SAT info".to_string()),
OutputLine::Text(" tenants List accessible tenants".to_string()),
OutputLine::Text(" use Select tenant context".to_string()),
OutputLine::Text(" registries List registries for current tenant".to_string()),
OutputLine::Text(" query Query a registry artifact".to_string()),
OutputLine::Text(" history Show mutation history".to_string()),
OutputLine::Text(" verify Verify merkle proof for an artifact".to_string()),
OutputLine::Text(" ceremonies List pending governance ceremonies".to_string()),
OutputLine::Text(" exit Exit the shell".to_string()),
OutputLine::Blank,
OutputLine::Text("Administrator (require elevation):".to_string()),
OutputLine::Text(" approve Approve a pending ceremony".to_string()),
OutputLine::Text(" deny Deny a pending ceremony".to_string()),
OutputLine::Text(" elevate Request scope elevation".to_string()),
OutputLine::Text(" deescalate Drop elevation to read-only".to_string()),
OutputLine::Text(" void Void a registry artifact".to_string()),
OutputLine::Blank,
OutputLine::Text("Engineer (require elevation):".to_string()),
OutputLine::Text(" deploy Deploy a schematic version".to_string()),
OutputLine::Text(" pipeline Pipeline management".to_string()),
OutputLine::Text(" schematic Schematic management".to_string()),
];
Ok(CommandOutput { lines })
}
}
}

View file

@ -0,0 +1,58 @@
use async_trait::async_trait;
use crate::governed::command_trait::*;
use crate::governed::session::{GovernedSession, Governed};
pub struct HistoryCommand;
#[async_trait]
impl ShellCommand for HistoryCommand {
fn name(&self) -> &str { "history" }
fn tier(&self) -> CommandTier { CommandTier::Analyst }
fn required_scope(&self) -> RequiredScope { RequiredScope::ReadOnly }
fn description(&self) -> &str { "Show mutation history for a registry" }
fn usage(&self) -> &str { "history <registry> [artifact_id]" }
async fn execute(
&self,
args: &[String],
session: &mut GovernedSession,
) -> Result<CommandOutput, CommandError> {
if session.tenant_context().is_none() {
return Err(CommandError::NoTenantContext);
}
if args.is_empty() {
return Err(CommandError::InvalidArgs(
"Usage: history <registry> [artifact_id]".to_string(),
));
}
let registry = &args[0];
let artifact_id = args.get(1);
// Phase 1: stub — shows placeholder history.
let scope = if let Some(id) = artifact_id {
format!("{}:{}", registry, id)
} else {
registry.clone()
};
let lines = vec![
OutputLine::Text(format!("Mutation history for: {}", scope)),
OutputLine::Table {
headers: vec![
"TIMESTAMP".to_string(),
"VERB".to_string(),
"ACTOR".to_string(),
"CEREMONY".to_string(),
],
rows: vec![
vec!["(no history loaded)".to_string(), "-".to_string(), "-".to_string(), "-".to_string()],
],
},
];
Ok(CommandOutput { lines })
}
}

View file

@ -0,0 +1,58 @@
//! Built-in shell commands.
pub mod approve;
pub mod ceremonies;
pub mod deescalate;
pub mod deny;
pub mod deploy;
pub mod elevate;
pub mod exit;
pub mod help;
pub mod history;
pub mod pipeline;
pub mod query;
pub mod registries;
pub mod schematic;
pub mod status;
pub mod tenants;
pub mod use_tenant;
pub mod verify;
pub mod void;
pub mod whoami;
use std::sync::Arc;
use super::command_trait::ShellCommand;
use super::registry::CommandRegistry;
/// Register all built-in commands into the registry.
pub fn register_all_builtins(registry: &mut CommandRegistry) {
let builtins: Vec<Arc<dyn ShellCommand>> = vec![
// Analyst tier
Arc::new(help::HelpCommand),
Arc::new(status::StatusCommand),
Arc::new(tenants::TenantsCommand),
Arc::new(use_tenant::UseTenantCommand),
Arc::new(registries::RegistriesCommand),
Arc::new(query::QueryCommand),
Arc::new(history::HistoryCommand),
Arc::new(verify::VerifyCommand),
Arc::new(ceremonies::CeremoniesCommand),
Arc::new(whoami::WhoamiCommand),
Arc::new(exit::ExitCommand),
// Administrator tier
Arc::new(approve::ApproveCommand),
Arc::new(deny::DenyCommand),
Arc::new(elevate::ElevateCommand),
Arc::new(deescalate::DeescalateCommand),
Arc::new(void::VoidCommand),
// Engineer tier
Arc::new(deploy::DeployCommand),
Arc::new(pipeline::PipelineCommand),
Arc::new(schematic::SchematicCommand),
];
for cmd in builtins {
registry.register(cmd);
}
}

View file

@ -0,0 +1,69 @@
use async_trait::async_trait;
use crate::governed::command_trait::*;
use crate::governed::session::{GovernedSession, Governed};
pub struct PipelineCommand;
#[async_trait]
impl ShellCommand for PipelineCommand {
fn name(&self) -> &str { "pipeline" }
fn aliases(&self) -> Vec<&str> { vec!["pl"] }
fn tier(&self) -> CommandTier { CommandTier::Engineer }
fn required_scope(&self) -> RequiredScope {
// Base command is read-only; subcommands like "trigger" require elevation.
RequiredScope::ReadOnly
}
fn description(&self) -> &str { "Pipeline management (list, show, trigger)" }
fn usage(&self) -> &str { "pipeline <list|show|trigger> [args...]" }
async fn execute(
&self,
args: &[String],
session: &mut GovernedSession,
) -> Result<CommandOutput, CommandError> {
let subcommand = args.first().map(|s| s.as_str()).unwrap_or("list");
match subcommand {
"list" | "ls" => {
let lines = vec![
OutputLine::Table {
headers: vec![
"RUN ID".to_string(),
"PIPELINE".to_string(),
"STATUS".to_string(),
"BRANCH".to_string(),
],
rows: vec![
vec!["(no runs loaded)".to_string(), "-".to_string(), "-".to_string(), "-".to_string()],
],
},
];
Ok(CommandOutput { lines })
}
"show" => {
let run_id = args.get(1).ok_or_else(|| {
CommandError::InvalidArgs("Usage: pipeline show <run_id>".to_string())
})?;
Ok(CommandOutput::text(format!(
"Pipeline run: {} (stub: not connected to runner-controller)",
run_id
)))
}
"trigger" => {
if session.tenant_context().is_none() {
return Err(CommandError::NoTenantContext);
}
// Trigger requires elevation.
Err(CommandError::ElevationRequired {
registry: "pipeline".to_string(),
verb: "trigger".to_string(),
})
}
_ => Err(CommandError::InvalidArgs(format!(
"Unknown subcommand: {}. Use: list, show, trigger",
subcommand
))),
}
}
}

View file

@ -0,0 +1,65 @@
use async_trait::async_trait;
use crate::governed::command_trait::*;
use crate::governed::session::{GovernedSession, Governed};
pub struct QueryCommand;
#[async_trait]
impl ShellCommand for QueryCommand {
fn name(&self) -> &str { "query" }
fn aliases(&self) -> Vec<&str> { vec!["q"] }
fn tier(&self) -> CommandTier { CommandTier::Analyst }
fn required_scope(&self) -> RequiredScope { RequiredScope::ReadOnly }
fn description(&self) -> &str { "Query a registry artifact" }
fn usage(&self) -> &str { "query <registry> <artifact_id>" }
async fn execute(
&self,
args: &[String],
session: &mut GovernedSession,
) -> Result<CommandOutput, CommandError> {
if session.tenant_context().is_none() {
return Err(CommandError::NoTenantContext);
}
if args.len() < 2 {
return Err(CommandError::InvalidArgs(
"Usage: query <registry> <artifact_id>".to_string(),
));
}
let registry = &args[0];
let artifact_id = &args[1];
// Phase 1: stub — shows placeholder artifact info.
// Production: calls the appropriate registry gRPC service.
let tenant = session.tenant_context().unwrap_or("*");
let lines = vec![
OutputLine::Text(format!("Artifact: {}/{}/{}", tenant, registry, artifact_id)),
OutputLine::Separator,
OutputLine::Status {
label: "Registry".to_string(),
value: registry.clone(),
color: OutputColor::Cyan,
},
OutputLine::Status {
label: "Artifact ID".to_string(),
value: artifact_id.clone(),
color: OutputColor::Cyan,
},
OutputLine::Status {
label: "Tenant".to_string(),
value: tenant.to_string(),
color: OutputColor::Default,
},
OutputLine::Status {
label: "Status".to_string(),
value: "(not connected to live service)".to_string(),
color: OutputColor::Yellow,
},
];
Ok(CommandOutput { lines })
}
}

View file

@ -0,0 +1,49 @@
use async_trait::async_trait;
use crate::governed::command_trait::*;
use crate::governed::session::{GovernedSession, Governed};
pub struct RegistriesCommand;
#[async_trait]
impl ShellCommand for RegistriesCommand {
fn name(&self) -> &str { "registries" }
fn aliases(&self) -> Vec<&str> { vec!["regs"] }
fn tier(&self) -> CommandTier { CommandTier::Analyst }
fn required_scope(&self) -> RequiredScope { RequiredScope::ReadOnly }
fn description(&self) -> &str { "List registries for current tenant" }
fn usage(&self) -> &str { "registries" }
async fn execute(
&self,
_args: &[String],
session: &mut GovernedSession,
) -> Result<CommandOutput, CommandError> {
if session.tenant_context().is_none() {
return Err(CommandError::NoTenantContext);
}
// Phase 1: returns the known registry types.
// Production: queries actual registry services for artifact counts.
let lines = vec![
OutputLine::Table {
headers: vec![
"REGISTRY".to_string(),
"TYPE".to_string(),
"DESCRIPTION".to_string(),
],
rows: vec![
vec!["credential".to_string(), "governed".to_string(), "Service credentials".to_string()],
vec!["invoice".to_string(), "governed".to_string(), "Financial invoices".to_string()],
vec!["pipeline-result".to_string(), "governed".to_string(), "Pipeline attestations".to_string()],
vec!["capability-profile".to_string(), "governed".to_string(), "Workload capabilities".to_string()],
vec!["sync-event".to_string(), "governed".to_string(), "Git sync events".to_string()],
vec!["schematic".to_string(), "governed".to_string(), "Infrastructure schematics".to_string()],
vec!["governance-ceremony".to_string(), "governed".to_string(), "Ceremony resolutions".to_string()],
],
},
];
Ok(CommandOutput { lines })
}
}

View file

@ -0,0 +1,71 @@
use async_trait::async_trait;
use crate::governed::command_trait::*;
use crate::governed::session::{GovernedSession, Governed};
pub struct SchematicCommand;
#[async_trait]
impl ShellCommand for SchematicCommand {
fn name(&self) -> &str { "schematic" }
fn aliases(&self) -> Vec<&str> { vec!["sch"] }
fn tier(&self) -> CommandTier { CommandTier::Engineer }
fn required_scope(&self) -> RequiredScope {
// Base command is read-only; mutation subcommands require elevation.
RequiredScope::ReadOnly
}
fn description(&self) -> &str { "Schematic management (list, show, validate, approve)" }
fn usage(&self) -> &str { "schematic <list|show|validate|approve> [args...]" }
async fn execute(
&self,
args: &[String],
session: &mut GovernedSession,
) -> Result<CommandOutput, CommandError> {
let subcommand = args.first().map(|s| s.as_str()).unwrap_or("list");
match subcommand {
"list" | "ls" => {
if session.tenant_context().is_none() {
return Err(CommandError::NoTenantContext);
}
let lines = vec![
OutputLine::Table {
headers: vec![
"NAME".to_string(),
"VERSION".to_string(),
"STATUS".to_string(),
],
rows: vec![
vec!["(no schematics loaded)".to_string(), "-".to_string(), "-".to_string()],
],
},
];
Ok(CommandOutput { lines })
}
"show" => {
let name = args.get(1).ok_or_else(|| {
CommandError::InvalidArgs("Usage: schematic show <name> [version]".to_string())
})?;
Ok(CommandOutput::text(format!(
"Schematic: {} (stub: not connected)",
name
)))
}
"validate" | "approve" => {
if session.tenant_context().is_none() {
return Err(CommandError::NoTenantContext);
}
Err(CommandError::ElevationRequired {
registry: "schematic".to_string(),
verb: subcommand.to_string(),
})
}
_ => Err(CommandError::InvalidArgs(format!(
"Unknown subcommand: {}. Use: list, show, validate, approve",
subcommand
))),
}
}
}

View file

@ -0,0 +1,90 @@
use async_trait::async_trait;
use crate::governed::command_trait::*;
use crate::governed::session::{GovernedSession, Governed};
pub struct StatusCommand;
#[async_trait]
impl ShellCommand for StatusCommand {
fn name(&self) -> &str { "status" }
fn tier(&self) -> CommandTier { CommandTier::Analyst }
fn required_scope(&self) -> RequiredScope { RequiredScope::ReadOnly }
fn description(&self) -> &str { "Show session and connection status" }
fn usage(&self) -> &str { "status" }
async fn execute(
&self,
_args: &[String],
session: &mut GovernedSession,
) -> Result<CommandOutput, CommandError> {
let tenant = session.tenant_context().unwrap_or("* (none selected)");
let scope = if session.is_elevated() { "elevated" } else { "read-only" };
let elevation = if let Some(e) = &session.elevated_sat {
let remaining = (e.expires_at - chrono::Utc::now()).num_seconds().max(0);
let mins = remaining / 60;
let secs = remaining % 60;
format!("{}m {}s remaining", mins, secs)
} else {
"none".to_string()
};
let connected = session.connected_at.format("%Y-%m-%d %H:%M:%S UTC").to_string();
let session_remaining = {
let remaining = (session.base_sat.expires_at - chrono::Utc::now()).num_seconds().max(0);
let hours = remaining / 3600;
let mins = (remaining % 3600) / 60;
format!("{}h {}m", hours, mins)
};
let lines = vec![
OutputLine::Status {
label: "Session".to_string(),
value: session.session_id().to_string(),
color: OutputColor::Cyan,
},
OutputLine::Status {
label: "User".to_string(),
value: session.identity().display_name.clone(),
color: OutputColor::Default,
},
OutputLine::Status {
label: "Roles".to_string(),
value: session.identity().roles.join(", "),
color: OutputColor::Default,
},
OutputLine::Status {
label: "Scope".to_string(),
value: scope.to_string(),
color: if session.is_elevated() { OutputColor::Yellow } else { OutputColor::Green },
},
OutputLine::Status {
label: "Tenant".to_string(),
value: tenant.to_string(),
color: OutputColor::Default,
},
OutputLine::Status {
label: "Connected".to_string(),
value: connected,
color: OutputColor::Default,
},
OutputLine::Status {
label: "Session expires".to_string(),
value: session_remaining,
color: OutputColor::Default,
},
OutputLine::Status {
label: "Elevation".to_string(),
value: elevation,
color: if session.is_elevated() { OutputColor::Yellow } else { OutputColor::Default },
},
OutputLine::Status {
label: "Commands executed".to_string(),
value: session.command_count.to_string(),
color: OutputColor::Default,
},
];
Ok(CommandOutput { lines })
}
}

View file

@ -0,0 +1,41 @@
use async_trait::async_trait;
use crate::governed::command_trait::*;
use crate::governed::session::GovernedSession;
pub struct TenantsCommand;
#[async_trait]
impl ShellCommand for TenantsCommand {
fn name(&self) -> &str { "tenants" }
fn aliases(&self) -> Vec<&str> { vec!["ls-tenants"] }
fn tier(&self) -> CommandTier { CommandTier::Analyst }
fn required_scope(&self) -> RequiredScope { RequiredScope::ReadOnly }
fn description(&self) -> &str { "List accessible tenants" }
fn usage(&self) -> &str { "tenants" }
async fn execute(
&self,
_args: &[String],
_session: &mut GovernedSession,
) -> Result<CommandOutput, CommandError> {
// Phase 1: stub — returns placeholder tenant list.
// Production: calls tenant listing endpoint via gRPC.
let lines = vec![
OutputLine::Table {
headers: vec![
"TENANT".to_string(),
"STATUS".to_string(),
"REGISTRIES".to_string(),
],
rows: vec![
vec!["(no tenants loaded)".to_string(), "-".to_string(), "-".to_string()],
],
},
OutputLine::Blank,
OutputLine::Text("Use 'use <tenant_id>' to select a tenant.".to_string()),
];
Ok(CommandOutput { lines })
}
}

View file

@ -0,0 +1,45 @@
use async_trait::async_trait;
use crate::governed::command_trait::*;
use crate::governed::session::{GovernedSession, Governed};
pub struct UseTenantCommand;
#[async_trait]
impl ShellCommand for UseTenantCommand {
fn name(&self) -> &str { "use" }
fn tier(&self) -> CommandTier { CommandTier::Analyst }
fn required_scope(&self) -> RequiredScope { RequiredScope::ReadOnly }
fn description(&self) -> &str { "Select tenant context" }
fn usage(&self) -> &str { "use <tenant_id>" }
async fn execute(
&self,
args: &[String],
session: &mut GovernedSession,
) -> Result<CommandOutput, CommandError> {
let tenant_id = args.first().ok_or_else(|| {
CommandError::InvalidArgs("Usage: use <tenant_id>".to_string())
})?;
// Clear context with "use *" or "use none".
if tenant_id == "*" || tenant_id == "none" {
session.set_tenant_context(None);
return Ok(CommandOutput::text("Tenant context cleared."));
}
// Phase 1: accept any tenant_id (production: validate via gRPC).
session.set_tenant_context(Some(tenant_id.clone()));
Ok(CommandOutput {
lines: vec![
OutputLine::Status {
label: "Tenant".to_string(),
value: tenant_id.clone(),
color: OutputColor::Green,
},
OutputLine::Text(format!("Context set to '{}'. Commands will target this tenant.", tenant_id)),
],
})
}
}

View file

@ -0,0 +1,53 @@
use async_trait::async_trait;
use crate::governed::command_trait::*;
use crate::governed::session::{GovernedSession, Governed};
pub struct VerifyCommand;
#[async_trait]
impl ShellCommand for VerifyCommand {
fn name(&self) -> &str { "verify" }
fn tier(&self) -> CommandTier { CommandTier::Analyst }
fn required_scope(&self) -> RequiredScope { RequiredScope::ReadOnly }
fn description(&self) -> &str { "Verify merkle proof for an artifact" }
fn usage(&self) -> &str { "verify <registry> <artifact_id>" }
async fn execute(
&self,
args: &[String],
session: &mut GovernedSession,
) -> Result<CommandOutput, CommandError> {
if session.tenant_context().is_none() {
return Err(CommandError::NoTenantContext);
}
if args.len() < 2 {
return Err(CommandError::InvalidArgs(
"Usage: verify <registry> <artifact_id>".to_string(),
));
}
let registry = &args[0];
let artifact_id = &args[1];
// Phase 1: stub — shows placeholder verification.
// Production: calls NotaryService.VerifyInclusion.
let lines = vec![
OutputLine::Text(format!("Verifying: {}/{}", registry, artifact_id)),
OutputLine::Separator,
OutputLine::Status {
label: "Anchor".to_string(),
value: "(not connected to notary)".to_string(),
color: OutputColor::Yellow,
},
OutputLine::Status {
label: "Verification".to_string(),
value: "unavailable (notary not connected)".to_string(),
color: OutputColor::Yellow,
},
];
Ok(CommandOutput { lines })
}
}

View file

@ -0,0 +1,69 @@
use async_trait::async_trait;
use crate::governed::command_trait::*;
use crate::governed::session::{GovernedSession, Governed};
pub struct VoidCommand;
#[async_trait]
impl ShellCommand for VoidCommand {
fn name(&self) -> &str { "void" }
fn tier(&self) -> CommandTier { CommandTier::Administrator }
fn required_scope(&self) -> RequiredScope {
RequiredScope::Elevated {
registry: "invoice".to_string(),
verb: "void".to_string(),
}
}
fn description(&self) -> &str { "Void a registry artifact" }
fn usage(&self) -> &str { "void <registry> <artifact_id>" }
async fn execute(
&self,
args: &[String],
session: &mut GovernedSession,
) -> Result<CommandOutput, CommandError> {
if session.tenant_context().is_none() {
return Err(CommandError::NoTenantContext);
}
if args.len() < 2 {
return Err(CommandError::InvalidArgs(
"Usage: void <registry> <artifact_id>".to_string(),
));
}
let registry = &args[0];
let artifact_id = &args[1];
let tenant = session.tenant_context().unwrap_or("*").to_string();
// Phase 1: stub — shows what would happen.
// Production: creates MutationIntent with elevated SAT, executes void.
let lines = vec![
OutputLine::Text(format!("Voiding: {}/{}/{}", tenant, registry, artifact_id)),
OutputLine::Separator,
OutputLine::Status {
label: "Registry".to_string(),
value: registry.clone(),
color: OutputColor::Cyan,
},
OutputLine::Status {
label: "Artifact".to_string(),
value: artifact_id.clone(),
color: OutputColor::Cyan,
},
OutputLine::Status {
label: "Tenant".to_string(),
value: tenant,
color: OutputColor::Default,
},
OutputLine::Status {
label: "Status".to_string(),
value: "(stub: void operation not connected)".to_string(),
color: OutputColor::Yellow,
},
];
Ok(CommandOutput { lines })
}
}

View file

@ -0,0 +1,64 @@
use async_trait::async_trait;
use crate::governed::command_trait::*;
use crate::governed::session::{AuthMethod, GovernedSession, Governed};
pub struct WhoamiCommand;
#[async_trait]
impl ShellCommand for WhoamiCommand {
fn name(&self) -> &str { "whoami" }
fn tier(&self) -> CommandTier { CommandTier::Analyst }
fn required_scope(&self) -> RequiredScope { RequiredScope::ReadOnly }
fn description(&self) -> &str { "Show identity and SAT info" }
fn usage(&self) -> &str { "whoami" }
async fn execute(
&self,
_args: &[String],
session: &mut GovernedSession,
) -> Result<CommandOutput, CommandError> {
let auth = match &session.identity().auth_method {
AuthMethod::PublicKey { fingerprint } => format!("public-key ({})", fingerprint),
AuthMethod::Token { issuer } => format!("token ({})", issuer),
};
let scope = if session.is_elevated() { "elevated" } else { "read-only" };
let tenant_scope = session.tenant_context().unwrap_or("*");
let remaining = (session.base_sat.expires_at - chrono::Utc::now()).num_seconds().max(0);
let hours = remaining / 3600;
let mins = (remaining % 3600) / 60;
let expires = format!("{}h {}m", hours, mins);
let lines = vec![
OutputLine::Status {
label: "Identity".to_string(),
value: session.identity().display_name.clone(),
color: OutputColor::Default,
},
OutputLine::Status {
label: "Subject".to_string(),
value: session.identity().subject.clone(),
color: OutputColor::Cyan,
},
OutputLine::Status {
label: "Roles".to_string(),
value: format!("[{}]", session.identity().roles.join(", ")),
color: OutputColor::Default,
},
OutputLine::Status {
label: "Auth".to_string(),
value: auth,
color: OutputColor::Default,
},
OutputLine::Status {
label: "SAT".to_string(),
value: format!("{} | Tenant scope: {} | Expires: {}", scope, tenant_scope, expires),
color: if session.is_elevated() { OutputColor::Yellow } else { OutputColor::Green },
},
];
Ok(CommandOutput { lines })
}
}

View file

@ -0,0 +1,319 @@
//! Command executor — the governed REPL loop.
//!
//! Reads input, routes commands, checks scopes, handles elevation,
//! and renders output.
use super::command_trait::{CommandError, ScopeCheck};
use super::registry::CommandRegistry;
use super::render::OutputRenderer;
use super::session::{GovernedSession, Governed};
/// Executes commands within a governed shell session.
pub struct CommandExecutor {
pub registry: CommandRegistry,
pub renderer: OutputRenderer,
}
/// Result of processing a single command line.
#[derive(Debug)]
pub enum ExecResult {
/// Command produced output.
Output(String),
/// Session should end.
Exit,
/// Elevation is required for this command.
ElevationNeeded { registry: String, verb: String },
/// Error message.
Error(String),
/// Session expired.
SessionExpired,
/// Elevation just expired (informational).
ElevationExpired,
/// Empty input.
Empty,
}
impl CommandExecutor {
pub fn new(registry: CommandRegistry) -> Self {
Self {
registry,
renderer: OutputRenderer::default(),
}
}
/// Process a single input line and return the result.
pub async fn process_line(
&self,
line: &str,
session: &mut GovernedSession,
) -> ExecResult {
// Check elevation expiry before processing.
if session.check_elevation_expiry() {
return ExecResult::ElevationExpired;
}
// Check base SAT expiry.
if session.base_sat_expired() {
return ExecResult::SessionExpired;
}
let line = line.trim();
if line.is_empty() {
return ExecResult::Empty;
}
// Parse command + args.
let parts: Vec<String> = split_args(line);
let (cmd_name, args) = match parts.split_first() {
Some((name, args)) => (name.as_str(), args),
None => return ExecResult::Empty,
};
// Look up command.
let command = match self.registry.get(cmd_name) {
Some(cmd) => cmd.clone(),
None => {
return ExecResult::Error(format!("Unknown command: {}. Type 'help' for available commands.", cmd_name));
}
};
// Check scope BEFORE execution.
let scope_check = session.can_execute(&command.required_scope());
match scope_check {
ScopeCheck::Authorized => {}
ScopeCheck::Expired => {
return ExecResult::SessionExpired;
}
ScopeCheck::ElevationRequired { registry, verb } => {
return ExecResult::ElevationNeeded { registry, verb };
}
ScopeCheck::ElevationExpired => {
return ExecResult::ElevationExpired;
}
}
// Execute command.
match command.execute(args, session).await {
Ok(output) => {
session.increment_command_count();
let rendered = self.renderer.render(&output);
if rendered.is_empty() {
ExecResult::Output(String::new())
} else {
ExecResult::Output(rendered)
}
}
Err(CommandError::ElevationRequired { registry, verb }) => {
ExecResult::ElevationNeeded { registry, verb }
}
Err(e) => ExecResult::Error(e.to_string()),
}
}
/// Generate the welcome banner.
pub fn welcome_banner(&self, session: &GovernedSession) -> String {
let mut banner = String::new();
banner.push_str("Guildhouse Governed Shell\n");
banner.push_str(&format!(
"Session: {} | User: {} | Role: {}\n",
session.session_id,
session.identity.display_name,
session.identity.roles.join(", ")
));
banner.push_str(&format!(
"Commands: {} available | Type 'help' for usage\n",
self.registry.len()
));
banner.push('\n');
banner
}
}
/// Split an input line into arguments, respecting quoted strings.
fn split_args(input: &str) -> Vec<String> {
let mut args = Vec::new();
let mut current = String::new();
let mut in_quote = false;
let mut quote_char = ' ';
for ch in input.chars() {
if in_quote {
if ch == quote_char {
in_quote = false;
} else {
current.push(ch);
}
} else if ch == '"' || ch == '\'' {
in_quote = true;
quote_char = ch;
} else if ch.is_whitespace() {
if !current.is_empty() {
args.push(std::mem::take(&mut current));
}
} else {
current.push(ch);
}
}
if !current.is_empty() {
args.push(current);
}
args
}
#[cfg(test)]
mod tests {
use super::*;
use crate::governed::command_trait::*;
use crate::governed::session::{AuthMethod, OperatorIdentity};
use async_trait::async_trait;
use registry_protocol::sat::SatRef;
use std::sync::Arc;
struct ExitCommand;
#[async_trait]
impl ShellCommand for ExitCommand {
fn name(&self) -> &str { "exit" }
fn tier(&self) -> CommandTier { CommandTier::Analyst }
fn required_scope(&self) -> RequiredScope { RequiredScope::ReadOnly }
fn description(&self) -> &str { "Exit the shell" }
fn usage(&self) -> &str { "exit" }
async fn execute(
&self,
_args: &[String],
_session: &mut GovernedSession,
) -> Result<CommandOutput, CommandError> {
Ok(CommandOutput::text("Goodbye!"))
}
}
struct ElevatedCommand;
#[async_trait]
impl ShellCommand for ElevatedCommand {
fn name(&self) -> &str { "void" }
fn tier(&self) -> CommandTier { CommandTier::Administrator }
fn required_scope(&self) -> RequiredScope {
RequiredScope::Elevated {
registry: "invoice".to_string(),
verb: "void".to_string(),
}
}
fn description(&self) -> &str { "Void an artifact" }
fn usage(&self) -> &str { "void <registry> <id>" }
async fn execute(
&self,
_args: &[String],
_session: &mut GovernedSession,
) -> Result<CommandOutput, CommandError> {
Ok(CommandOutput::text("voided"))
}
}
fn test_session() -> GovernedSession {
let now = chrono::Utc::now();
GovernedSession::new(
"sess-test".to_string(),
OperatorIdentity {
subject: "tyler".to_string(),
display_name: "Tyler King".to_string(),
roles: vec!["engineer".to_string()],
auth_method: AuthMethod::PublicKey {
fingerprint: "SHA256:test".to_string(),
},
},
"127.0.0.1:12345".parse().unwrap(),
SatRef {
sat_hash: [0u8; 32],
bearer_svid: "spiffe://test".to_string(),
scopes: vec![],
issued_at: now,
expires_at: now + chrono::Duration::hours(12),
},
)
}
fn test_executor() -> CommandExecutor {
let mut registry = CommandRegistry::new();
registry.register(Arc::new(ExitCommand));
registry.register(Arc::new(ElevatedCommand));
CommandExecutor {
registry,
renderer: OutputRenderer { use_color: false, ..Default::default() },
}
}
#[tokio::test]
async fn executor_routes_known_command() {
let executor = test_executor();
let mut session = test_session();
match executor.process_line("exit", &mut session).await {
ExecResult::Output(s) => assert!(s.contains("Goodbye")),
other => panic!("expected Output, got {:?}", other),
}
}
#[tokio::test]
async fn executor_rejects_unknown_command() {
let executor = test_executor();
let mut session = test_session();
match executor.process_line("nonexistent", &mut session).await {
ExecResult::Error(s) => assert!(s.contains("Unknown command")),
other => panic!("expected Error, got {:?}", other),
}
}
#[tokio::test]
async fn executor_blocks_elevated_command() {
let executor = test_executor();
let mut session = test_session();
match executor.process_line("void invoice INV-001", &mut session).await {
ExecResult::ElevationNeeded { registry, verb } => {
assert_eq!(registry, "invoice");
assert_eq!(verb, "void");
}
other => panic!("expected ElevationNeeded, got {:?}", other),
}
}
#[tokio::test]
async fn executor_empty_line() {
let executor = test_executor();
let mut session = test_session();
match executor.process_line("", &mut session).await {
ExecResult::Empty => {}
other => panic!("expected Empty, got {:?}", other),
}
}
#[test]
fn split_args_simple() {
assert_eq!(split_args("hello world"), vec!["hello", "world"]);
}
#[test]
fn split_args_quoted() {
assert_eq!(
split_args(r#"void invoice "my reason""#),
vec!["void", "invoice", "my reason"]
);
}
#[test]
fn split_args_empty() {
assert!(split_args("").is_empty());
assert!(split_args(" ").is_empty());
}
#[test]
fn welcome_banner_includes_session_info() {
let session = test_session();
let executor = test_executor();
let banner = executor.welcome_banner(&session);
assert!(banner.contains("Tyler King"));
assert!(banner.contains("engineer"));
assert!(banner.contains("sess-test"));
}
}

View file

@ -0,0 +1,166 @@
//! Command manifest — declares which commands are available in this image.
use std::path::Path;
use serde::Deserialize;
/// Parsed from `/opt/bascule/commands/manifest.yaml`.
#[derive(Debug, Clone, Deserialize)]
pub struct CommandManifest {
pub image_tier: String,
pub org_name: Option<String>,
pub commands: Vec<CommandEntry>,
}
/// A single command entry in the manifest.
#[derive(Debug, Clone, Deserialize)]
pub struct CommandEntry {
pub name: String,
pub tier: String,
pub builtin: bool,
pub description: Option<String>,
}
impl CommandManifest {
/// Load manifest from a YAML file.
pub fn from_file(path: &Path) -> anyhow::Result<Self> {
let contents = std::fs::read_to_string(path)?;
let manifest: Self = serde_yaml::from_str(&contents)?;
Ok(manifest)
}
/// Load manifest from a YAML string.
pub fn from_str(yaml: &str) -> anyhow::Result<Self> {
let manifest: Self = serde_yaml::from_str(yaml)?;
Ok(manifest)
}
/// Default analyst manifest with all built-in read commands.
pub fn default_analyst() -> Self {
Self {
image_tier: "analyst".to_string(),
org_name: None,
commands: vec![
cmd("help", "analyst"),
cmd("status", "analyst"),
cmd("tenants", "analyst"),
cmd("use", "analyst"),
cmd("registries", "analyst"),
cmd("query", "analyst"),
cmd("history", "analyst"),
cmd("verify", "analyst"),
cmd("ceremonies", "analyst"),
cmd("whoami", "analyst"),
cmd("exit", "analyst"),
],
}
}
/// Default administrator manifest — all analyst + admin commands.
pub fn default_administrator() -> Self {
let mut m = Self::default_analyst();
m.image_tier = "administrator".to_string();
m.commands.extend(vec![
cmd("approve", "administrator"),
cmd("deny", "administrator"),
cmd("elevate", "administrator"),
cmd("deescalate", "administrator"),
cmd("void", "administrator"),
]);
m
}
/// Default engineer manifest — all admin + engineer commands.
pub fn default_engineer() -> Self {
let mut m = Self::default_administrator();
m.image_tier = "engineer".to_string();
m.commands.extend(vec![
cmd("deploy", "engineer"),
cmd("provision", "engineer"),
cmd("pipeline", "engineer"),
cmd("schematic", "engineer"),
]);
m
}
}
fn cmd(name: &str, tier: &str) -> CommandEntry {
CommandEntry {
name: name.to_string(),
tier: tier.to_string(),
builtin: true,
description: None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_manifest_yaml() {
let yaml = r#"
image_tier: analyst
org_name: acme-msp
commands:
- name: status
tier: analyst
builtin: true
- name: query
tier: analyst
builtin: true
description: "Query registry artifacts"
"#;
let manifest = CommandManifest::from_str(yaml).unwrap();
assert_eq!(manifest.image_tier, "analyst");
assert_eq!(manifest.org_name.as_deref(), Some("acme-msp"));
assert_eq!(manifest.commands.len(), 2);
assert_eq!(manifest.commands[0].name, "status");
assert!(manifest.commands[0].builtin);
assert_eq!(
manifest.commands[1].description.as_deref(),
Some("Query registry artifacts")
);
}
#[test]
fn default_analyst_has_read_commands() {
let manifest = CommandManifest::default_analyst();
assert_eq!(manifest.image_tier, "analyst");
let names: Vec<&str> = manifest.commands.iter().map(|c| c.name.as_str()).collect();
assert!(names.contains(&"status"));
assert!(names.contains(&"query"));
assert!(names.contains(&"help"));
assert!(names.contains(&"exit"));
assert!(!names.contains(&"approve"));
assert!(!names.contains(&"deploy"));
}
#[test]
fn default_administrator_includes_analyst_and_admin() {
let manifest = CommandManifest::default_administrator();
assert_eq!(manifest.image_tier, "administrator");
let names: Vec<&str> = manifest.commands.iter().map(|c| c.name.as_str()).collect();
assert!(names.contains(&"status")); // analyst
assert!(names.contains(&"approve")); // admin
assert!(names.contains(&"elevate")); // admin
assert!(!names.contains(&"deploy")); // engineer only
}
#[test]
fn default_engineer_includes_all() {
let manifest = CommandManifest::default_engineer();
assert_eq!(manifest.image_tier, "engineer");
let names: Vec<&str> = manifest.commands.iter().map(|c| c.name.as_str()).collect();
assert!(names.contains(&"status")); // analyst
assert!(names.contains(&"approve")); // admin
assert!(names.contains(&"deploy")); // engineer
assert!(names.contains(&"pipeline")); // engineer
}
#[test]
fn manifest_from_file_missing_returns_error() {
let result = CommandManifest::from_file(Path::new("/nonexistent/manifest.yaml"));
assert!(result.is_err());
}
}

View file

@ -0,0 +1,13 @@
//! Governed SSH shell runtime.
//!
//! Implements the tiered command model with SAT-based authorization,
//! ceremony-gated elevation, and full session audit.
pub mod command_trait;
pub mod commands;
pub mod executor;
pub mod manifest;
pub mod registry;
pub mod render;
pub mod session;
pub mod ssh;

Some files were not shown because too many files have changed in this diff Show more