Compare commits
3 commits
7c84854222
...
a97e9569d6
| Author | SHA256 | Date | |
|---|---|---|---|
| a97e9569d6 | |||
| 872a53a3c7 | |||
| d0b674f6cd |
17 changed files with 1344 additions and 919 deletions
812
Cargo.lock
generated
812
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -11,7 +11,6 @@ repository = "https://git.guildhouse.dev/guildhouse/gsh"
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
reqwest = { version = "0.12", features = ["blocking", "json"] }
|
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
|
@ -25,4 +24,6 @@ colored = "2"
|
||||||
atty = "0.2"
|
atty = "0.2"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
substrate-ipc = { path = "../substrate/crates/substrate-ipc" }
|
substrate-ipc = { path = "../substrate/crates/substrate-ipc" }
|
||||||
|
substrate-identity-store = { path = "../substrate/crates/substrate-identity-store" }
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
libc = "0.2"
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,15 @@
|
||||||
**Spec:** GCAP-SPEC-SHELLBOUND-BROKER-0001 (Layer 3)
|
**Spec:** GCAP-SPEC-SHELLBOUND-BROKER-0001 (Layer 3)
|
||||||
**Language:** Rust
|
**Language:** Rust
|
||||||
**License:** Apache 2.0
|
**License:** Apache 2.0
|
||||||
|
**References:** DESIGN-TYPED-SHELL-HIERARCHY-0001 (T0-T6), DESIGN-GOVERNANCE-LAYERING-MODEL-0001 (five-layer model)
|
||||||
|
|
||||||
|
> **Shell Tier Context (2026-05-28):** gsh is the **T2 Operator Runtime** binary
|
||||||
|
> per DESIGN-TYPED-SHELL-HIERARCHY-0001. It compiles with `--features operator`
|
||||||
|
> and is one of seven tier-specific binaries (T0-T6). `NO_NEW_PRIVS` is set at
|
||||||
|
> session start. The Free/Observed/Governed command categorization maps to
|
||||||
|
> governance layers: Free = L1 Permitted (within PosixBinding ceiling),
|
||||||
|
> Observed = L2 Authorized (AC-scoped), Governed = L3 Approved (ceremony-gated).
|
||||||
|
> See DESIGN-GOVERNANCE-LAYERING-MODEL-0001 for the full five-layer model.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,3 +21,7 @@ colored = { workspace = true }
|
||||||
atty = { workspace = true }
|
atty = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
substrate-ipc = { workspace = true }
|
substrate-ipc = { workspace = true }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
lmdb = ["libgsh/lmdb"]
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,14 @@
|
||||||
//! gsh human mode — interactive governed shell with reedline.
|
//! gsh human mode — interactive governed shell with reedline.
|
||||||
|
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use colored::Colorize;
|
use colored::Colorize;
|
||||||
use reedline::{DefaultPrompt, DefaultPromptSegment, Reedline, Signal};
|
use reedline::{DefaultPrompt, DefaultPromptSegment, Reedline, Signal};
|
||||||
|
|
||||||
use libgsh::classifier::{classify_command, CommandClass};
|
use libgsh::classifier::{classify_command, CommandClass};
|
||||||
use libgsh::cr::{build_client, post_cr};
|
use libgsh::cr::post_cr;
|
||||||
use libgsh::session::SessionState;
|
use libgsh::session::SessionState;
|
||||||
|
|
||||||
|
|
||||||
/// Corpus base directory. Configurable via GSH_CORPUS_DIR env.
|
/// Corpus base directory. Configurable via GSH_CORPUS_DIR env.
|
||||||
fn corpus_base() -> PathBuf {
|
fn corpus_base() -> PathBuf {
|
||||||
std::env::var("GSH_CORPUS_DIR")
|
std::env::var("GSH_CORPUS_DIR")
|
||||||
|
|
@ -21,22 +19,16 @@ fn corpus_base() -> PathBuf {
|
||||||
/// Run the interactive governed shell.
|
/// Run the interactive governed shell.
|
||||||
pub fn run_human_mode(
|
pub fn run_human_mode(
|
||||||
session: &mut SessionState,
|
session: &mut SessionState,
|
||||||
broker_url: &Option<String>,
|
fabric_socket: &str,
|
||||||
token: &Option<String>,
|
rt: &tokio::runtime::Runtime,
|
||||||
) -> i32 {
|
) -> i32 {
|
||||||
// Print banner:
|
|
||||||
print_banner(session);
|
print_banner(session);
|
||||||
|
|
||||||
// Post SESSION_STARTED CR if broker available:
|
// Post SESSION_STARTED CR via fabric:
|
||||||
if let Some(ref base) = broker_url {
|
let _ = rt.block_on(post_cr(Some(fabric_socket), &session.ac_id, "completed"));
|
||||||
if let Ok(client) = build_client(token) {
|
|
||||||
let _ = post_cr(&client, base, &session.ac_id, "completed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let corpus_dir = corpus_base();
|
let corpus_dir = corpus_base();
|
||||||
|
|
||||||
// Reedline REPL:
|
|
||||||
let mut editor = Reedline::create();
|
let mut editor = Reedline::create();
|
||||||
let prompt = build_prompt(session);
|
let prompt = build_prompt(session);
|
||||||
|
|
||||||
|
|
@ -51,7 +43,6 @@ pub fn run_human_mode(
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Classify command:
|
|
||||||
match classify_command(line, &session.corpus_cid, &corpus_dir) {
|
match classify_command(line, &session.corpus_cid, &corpus_dir) {
|
||||||
CommandClass::Free => {
|
CommandClass::Free => {
|
||||||
execute_passthrough(line, session);
|
execute_passthrough(line, session);
|
||||||
|
|
@ -62,17 +53,12 @@ pub fn run_human_mode(
|
||||||
execute_governed(line, &corpus_binary, session);
|
execute_governed(line, &corpus_binary, session);
|
||||||
session.governed_count += 1;
|
session.governed_count += 1;
|
||||||
|
|
||||||
// Post lightweight command CR:
|
let outcome = if exit_code == 0 { "completed" } else { "failed" };
|
||||||
if let Some(ref base) = broker_url {
|
let _ = rt.block_on(post_cr(
|
||||||
if let Ok(client) = build_client(token) {
|
Some(fabric_socket),
|
||||||
let outcome = if exit_code == 0 {
|
&session.ac_id,
|
||||||
"completed"
|
outcome,
|
||||||
} else {
|
));
|
||||||
"failed"
|
|
||||||
};
|
|
||||||
let _ = post_cr(&client, base, &session.ac_id, outcome);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
CommandClass::Ungoverned => {
|
CommandClass::Ungoverned => {
|
||||||
let cmd_name = line.split_whitespace().next().unwrap_or("?");
|
let cmd_name = line.split_whitespace().next().unwrap_or("?");
|
||||||
|
|
@ -93,7 +79,6 @@ pub fn run_human_mode(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// AC expiry warning:
|
|
||||||
let mins = session.minutes_remaining();
|
let mins = session.minutes_remaining();
|
||||||
if mins < 10 && mins > 0 {
|
if mins < 10 && mins > 0 {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
|
|
@ -115,15 +100,10 @@ pub fn run_human_mode(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Session teardown:
|
|
||||||
print_summary(session);
|
print_summary(session);
|
||||||
|
|
||||||
// Post SESSION_ENDED CR:
|
// Post SESSION_ENDED CR via fabric:
|
||||||
if let Some(ref base) = broker_url {
|
let _ = rt.block_on(post_cr(Some(fabric_socket), &session.ac_id, "session_end"));
|
||||||
if let Ok(client) = build_client(token) {
|
|
||||||
let _ = post_cr(&client, base, &session.ac_id, "session_end");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
0
|
0
|
||||||
}
|
}
|
||||||
|
|
@ -163,7 +143,15 @@ fn print_banner(session: &SessionState) {
|
||||||
},
|
},
|
||||||
"║".bright_blue());
|
"║".bright_blue());
|
||||||
|
|
||||||
// DEFCON line — only shown when not peacetime
|
if let Some(ref class) = session.identity_class {
|
||||||
|
println!("{} Class: {:<44}{}", "║".bright_blue(), class, "║".bright_blue());
|
||||||
|
}
|
||||||
|
if !session.earned_credentials.is_empty() {
|
||||||
|
for cred in &session.earned_credentials {
|
||||||
|
println!("{} Credential: {:<44}{}", "║".bright_blue(), cred, "║".bright_blue());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if session.defcon_level < 5 {
|
if session.defcon_level < 5 {
|
||||||
let defcon_label = match session.defcon_level {
|
let defcon_label = match session.defcon_level {
|
||||||
1 => "LOCKDOWN".red().to_string(),
|
1 => "LOCKDOWN".red().to_string(),
|
||||||
|
|
@ -212,7 +200,6 @@ fn print_summary(session: &SessionState) {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_prompt(session: &SessionState) -> DefaultPrompt {
|
fn build_prompt(session: &SessionState) -> DefaultPrompt {
|
||||||
// DEFCON overrides the risk indicator when elevated
|
|
||||||
let risk_indicator = if session.defcon_level <= 2 {
|
let risk_indicator = if session.defcon_level <= 2 {
|
||||||
"[DEFCON]"
|
"[DEFCON]"
|
||||||
} else if session.defcon_level == 3 {
|
} else if session.defcon_level == 3 {
|
||||||
|
|
@ -227,9 +214,10 @@ fn build_prompt(session: &SessionState) -> DefaultPrompt {
|
||||||
};
|
};
|
||||||
|
|
||||||
let short_name = session.display_name.split('@').next().unwrap_or(&session.display_name);
|
let short_name = session.display_name.split('@').next().unwrap_or(&session.display_name);
|
||||||
|
let tier_tag = format!("T{}", session.shell_tier.numeric_level());
|
||||||
|
|
||||||
DefaultPrompt::new(
|
DefaultPrompt::new(
|
||||||
DefaultPromptSegment::Basic(format!("{} {}@gsh", risk_indicator, short_name)),
|
DefaultPromptSegment::Basic(format!("{} {}:{}@gsh", risk_indicator, tier_tag, short_name)),
|
||||||
DefaultPromptSegment::Empty,
|
DefaultPromptSegment::Empty,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ use serde::Serialize;
|
||||||
use std::process;
|
use std::process;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use libgsh::cr::{build_client, post_cr, request_ac_inline};
|
use libgsh::cr::{post_cr, request_ac_inline};
|
||||||
use libgsh::registry::ConsumedRegistry;
|
use libgsh::registry::ConsumedRegistry;
|
||||||
use libgsh::session::SessionState;
|
use libgsh::session::SessionState;
|
||||||
use libgsh::{corpus_check, sha256_hash};
|
use libgsh::{corpus_check, sha256_hash};
|
||||||
|
|
@ -39,8 +39,8 @@ struct Args {
|
||||||
#[arg(long, global = true)]
|
#[arg(long, global = true)]
|
||||||
ungoverned: bool,
|
ungoverned: bool,
|
||||||
|
|
||||||
#[arg(long, global = true)]
|
#[arg(long, global = true, default_value = "/run/substrate/fabric.sock")]
|
||||||
broker_url: Option<String>,
|
fabric_socket: String,
|
||||||
|
|
||||||
#[arg(long, global = true)]
|
#[arg(long, global = true)]
|
||||||
agent_did: Option<String>,
|
agent_did: Option<String>,
|
||||||
|
|
@ -92,6 +92,10 @@ struct GshOutput {
|
||||||
corpus_cid: String,
|
corpus_cid: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn fabric_sock(args: &Args) -> String {
|
||||||
|
std::env::var("GSH_FABRIC_SOCKET").unwrap_or_else(|_| args.fabric_socket.clone())
|
||||||
|
}
|
||||||
|
|
||||||
// ── Main ─────────────────────────────────────────────────────
|
// ── Main ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
|
|
@ -114,7 +118,8 @@ fn main() {
|
||||||
|
|
||||||
fn run(args: Args) -> Result<i32> {
|
fn run(args: Args) -> Result<i32> {
|
||||||
let corpus = std::env::var("GSAP_CORPUS_CID").unwrap_or_else(|_| "sha256:ungoverned".into());
|
let corpus = std::env::var("GSAP_CORPUS_CID").unwrap_or_else(|_| "sha256:ungoverned".into());
|
||||||
let token = std::env::var("GSAP_TOKEN").ok();
|
let rt = tokio::runtime::Runtime::new().context("creating tokio runtime")?;
|
||||||
|
let sock = fabric_sock(&args);
|
||||||
|
|
||||||
// ── Ungoverned machine mode ────────────────────────────────
|
// ── Ungoverned machine mode ────────────────────────────────
|
||||||
if args.ungoverned && args.exec.is_some() {
|
if args.ungoverned && args.exec.is_some() {
|
||||||
|
|
@ -140,7 +145,6 @@ fn run(args: Args) -> Result<i32> {
|
||||||
|
|
||||||
// ── Subcommands ─────────────────────────────────────────
|
// ── Subcommands ─────────────────────────────────────────
|
||||||
if let Some(cmd) = &args.command {
|
if let Some(cmd) = &args.command {
|
||||||
// Register is handled separately — it doesn't need a broker.
|
|
||||||
if let Cmd::Register {
|
if let Cmd::Register {
|
||||||
service_name,
|
service_name,
|
||||||
fabric_socket,
|
fabric_socket,
|
||||||
|
|
@ -150,15 +154,11 @@ fn run(args: Args) -> Result<i32> {
|
||||||
return run_register(service_name, fabric_socket, env_dir.as_deref());
|
return run_register(service_name, fabric_socket, env_dir.as_deref());
|
||||||
}
|
}
|
||||||
|
|
||||||
let base = args.broker_url.clone()
|
|
||||||
.or_else(|| std::env::var("GSAP_BROKER_URL").ok())
|
|
||||||
.context("GSAP_BROKER_URL not set")?;
|
|
||||||
let client = build_client(&token).map_err(|e| anyhow::anyhow!(e))?;
|
|
||||||
return match cmd {
|
return match cmd {
|
||||||
Cmd::SessionStart { scope } => {
|
Cmd::SessionStart { scope } => {
|
||||||
let hash = sha256_hash(format!("session:{}", scope).as_bytes());
|
let hash = sha256_hash(format!("session:{}", scope).as_bytes());
|
||||||
eprintln!("gsh: starting session (scope: {})", scope);
|
eprintln!("gsh: starting session (scope: {})", scope);
|
||||||
let ac_id = request_ac_inline(&client, &base, scope, &hash, &corpus)
|
let ac_id = rt.block_on(request_ac_inline(Some(&sock), scope, &hash, &corpus))
|
||||||
.map_err(|e| anyhow::anyhow!(e))?;
|
.map_err(|e| anyhow::anyhow!(e))?;
|
||||||
eprintln!("gsh: session AC — {}", &ac_id[..8.min(ac_id.len())]);
|
eprintln!("gsh: session AC — {}", &ac_id[..8.min(ac_id.len())]);
|
||||||
println!("export GSAP_SESSION_AC=\"{}\";", ac_id);
|
println!("export GSAP_SESSION_AC=\"{}\";", ac_id);
|
||||||
|
|
@ -169,7 +169,7 @@ fn run(args: Args) -> Result<i32> {
|
||||||
Cmd::SessionEnd => {
|
Cmd::SessionEnd => {
|
||||||
let ac_id = std::env::var("GSAP_SESSION_AC")
|
let ac_id = std::env::var("GSAP_SESSION_AC")
|
||||||
.context("No session active (GSAP_SESSION_AC not set)")?;
|
.context("No session active (GSAP_SESSION_AC not set)")?;
|
||||||
let cr = post_cr(&client, &base, &ac_id, "completed");
|
let cr = rt.block_on(post_cr(Some(&sock), &ac_id, "completed"));
|
||||||
eprintln!("gsh: session closed");
|
eprintln!("gsh: session closed");
|
||||||
if !cr.chronicle_cid.is_empty() {
|
if !cr.chronicle_cid.is_empty() {
|
||||||
eprintln!("gsh: session CID {}", &cr.chronicle_cid[..40.min(cr.chronicle_cid.len())]);
|
eprintln!("gsh: session CID {}", &cr.chronicle_cid[..40.min(cr.chronicle_cid.len())]);
|
||||||
|
|
@ -194,16 +194,12 @@ fn run(args: Args) -> Result<i32> {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Mode detection ─────────────────────────────────────────
|
// ── Mode detection ─────────────────────────────────────────
|
||||||
// No --exec: human mode (if TTY) or error (if piped)
|
|
||||||
if args.exec.is_none() {
|
if args.exec.is_none() {
|
||||||
let is_tty = atty::is(atty::Stream::Stdin);
|
let is_tty = atty::is(atty::Stream::Stdin);
|
||||||
if !is_tty {
|
if !is_tty {
|
||||||
anyhow::bail!("No --exec and no TTY. Use --exec for non-interactive mode.");
|
anyhow::bail!("No --exec and no TTY. Use --exec for non-interactive mode.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Human mode: interactive governed shell
|
|
||||||
let token = std::env::var("GSAP_TOKEN").ok();
|
|
||||||
let broker = args.broker_url.clone().or_else(|| std::env::var("GSAP_BROKER_URL").ok());
|
|
||||||
let pre_issued = args.ac.clone().or_else(|| std::env::var("GSAP_AC").ok());
|
let pre_issued = args.ac.clone().or_else(|| std::env::var("GSAP_AC").ok());
|
||||||
|
|
||||||
let mut session = if args.ungoverned {
|
let mut session = if args.ungoverned {
|
||||||
|
|
@ -213,36 +209,31 @@ fn run(args: Args) -> Result<i32> {
|
||||||
let ac = libgsh::ac::validate_ac(&ac_json, &corpus, &mut registry)
|
let ac = libgsh::ac::validate_ac(&ac_json, &corpus, &mut registry)
|
||||||
.map_err(|e| anyhow::anyhow!("AC validation failed (exit {}): {}", e.exit_code(), e))?;
|
.map_err(|e| anyhow::anyhow!("AC validation failed (exit {}): {}", e.exit_code(), e))?;
|
||||||
SessionState::from_ac(&ac, &corpus)
|
SessionState::from_ac(&ac, &corpus)
|
||||||
} else if let Some(ref base) = broker {
|
} else {
|
||||||
// Request a session AC from broker:
|
|
||||||
let client = build_client(&token).map_err(|e| anyhow::anyhow!(e))?;
|
|
||||||
let hash = sha256_hash(b"session:human-mode");
|
let hash = sha256_hash(b"session:human-mode");
|
||||||
let ac_id = request_ac_inline(&client, base, "shell:session", &hash, &corpus)
|
match rt.block_on(request_ac_inline(Some(&sock), "shell:session", &hash, &corpus)) {
|
||||||
.map_err(|e| anyhow::anyhow!(e))?;
|
Ok(ac_id) => {
|
||||||
let mut s = SessionState::ungoverned(&corpus);
|
let mut s = SessionState::ungoverned(&corpus);
|
||||||
s.ac_id = ac_id;
|
s.ac_id = ac_id;
|
||||||
s.risk_level = "standard".to_string();
|
s.risk_level = "standard".to_string();
|
||||||
s
|
s
|
||||||
} else {
|
}
|
||||||
// No AC and no broker — run ungoverned with warning
|
Err(_) => {
|
||||||
eprintln!("gsh: no GSAP_AC or GSAP_BROKER_URL — running ungoverned");
|
eprintln!("gsh: fabric unreachable — running ungoverned");
|
||||||
SessionState::ungoverned(&corpus)
|
SessionState::ungoverned(&corpus)
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return Ok(human::run_human_mode(&mut session, &broker, &token));
|
return Ok(human::run_human_mode(&mut session, &sock, &rt));
|
||||||
}
|
}
|
||||||
|
|
||||||
let exec = args.exec.as_ref().unwrap(); // safe: checked above
|
let exec = args.exec.as_ref().unwrap();
|
||||||
let run_id = Uuid::new_v4().to_string();
|
let run_id = Uuid::new_v4().to_string();
|
||||||
let command_hash = sha256_hash(exec.as_bytes());
|
let command_hash = sha256_hash(exec.as_bytes());
|
||||||
|
|
||||||
// Determine AC mode:
|
|
||||||
let pre_issued = args.ac.clone().or_else(|| std::env::var("GSAP_AC").ok());
|
let pre_issued = args.ac.clone().or_else(|| std::env::var("GSAP_AC").ok());
|
||||||
|
|
||||||
// Retain the full AC struct for pre-issued mode so the governance-env
|
|
||||||
// contract (`GSH_DID`/`GSH_ACCORD_HASH`/...) can be threaded into the
|
|
||||||
// child process at the exec site below. Session and inline modes only
|
|
||||||
// surface an ID; their governance fields stay un-exported.
|
|
||||||
let (ac_id, ac_mode, ac_struct) = if let Some(ac_json) = pre_issued {
|
let (ac_id, ac_mode, ac_struct) = if let Some(ac_json) = pre_issued {
|
||||||
let mut registry = ConsumedRegistry::default_location();
|
let mut registry = ConsumedRegistry::default_location();
|
||||||
let ac = libgsh::ac::validate_ac(&ac_json, &corpus, &mut registry)
|
let ac = libgsh::ac::validate_ac(&ac_json, &corpus, &mut registry)
|
||||||
|
|
@ -255,12 +246,8 @@ fn run(args: Args) -> Result<i32> {
|
||||||
} else if let Ok(session_ac) = std::env::var("GSAP_SESSION_AC") {
|
} else if let Ok(session_ac) = std::env::var("GSAP_SESSION_AC") {
|
||||||
(session_ac, "session".to_string(), None)
|
(session_ac, "session".to_string(), None)
|
||||||
} else {
|
} else {
|
||||||
let base = args.broker_url.clone()
|
|
||||||
.or_else(|| std::env::var("GSAP_BROKER_URL").ok())
|
|
||||||
.context("No AC provided. Set GSAP_AC, GSAP_SESSION_AC, or GSAP_BROKER_URL")?;
|
|
||||||
let client = build_client(&token).map_err(|e| anyhow::anyhow!(e))?;
|
|
||||||
eprintln!("gsh: requesting AC for '{}'", exec);
|
eprintln!("gsh: requesting AC for '{}'", exec);
|
||||||
let id = request_ac_inline(&client, &base, &args.operation, &command_hash, &corpus)
|
let id = rt.block_on(request_ac_inline(Some(&sock), &args.operation, &command_hash, &corpus))
|
||||||
.map_err(|e| anyhow::anyhow!(e))?;
|
.map_err(|e| anyhow::anyhow!(e))?;
|
||||||
eprintln!("gsh: AC issued — {}", &id[..8.min(id.len())]);
|
eprintln!("gsh: AC issued — {}", &id[..8.min(id.len())]);
|
||||||
(id, "inline".to_string(), None)
|
(id, "inline".to_string(), None)
|
||||||
|
|
@ -280,10 +267,7 @@ fn run(args: Args) -> Result<i32> {
|
||||||
} => {
|
} => {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"gsh: command '{}' content does not match CID {} (found {}, path {}): execution denied (tamper signal)",
|
"gsh: command '{}' content does not match CID {} (found {}, path {}): execution denied (tamper signal)",
|
||||||
command,
|
command, corpus_cid, actual_cid, path.display()
|
||||||
corpus_cid,
|
|
||||||
actual_cid,
|
|
||||||
path.display()
|
|
||||||
);
|
);
|
||||||
return Ok(3);
|
return Ok(3);
|
||||||
}
|
}
|
||||||
|
|
@ -295,10 +279,7 @@ fn run(args: Args) -> Result<i32> {
|
||||||
} => {
|
} => {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"gsh: command '{}' in corpus {} could not be read for hash verification ({}); execution denied fail-closed (path {})",
|
"gsh: command '{}' in corpus {} could not be read for hash verification ({}); execution denied fail-closed (path {})",
|
||||||
command,
|
command, corpus_cid, detail, path.display()
|
||||||
corpus_cid,
|
|
||||||
detail,
|
|
||||||
path.display()
|
|
||||||
);
|
);
|
||||||
return Ok(3);
|
return Ok(3);
|
||||||
}
|
}
|
||||||
|
|
@ -320,6 +301,17 @@ fn run(args: Args) -> Result<i32> {
|
||||||
return Ok(0);
|
return Ok(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply Linux capability bounding set if declared in PosixBinding
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
if let Ok(cap_hex) = std::env::var("GSH_CAP_BOUNDING") {
|
||||||
|
let bounding = libgsh::capabilities::parse_hex(&cap_hex)
|
||||||
|
.map_err(|e| anyhow::anyhow!("cap bounding parse: {e}"))?;
|
||||||
|
if let Err(e) = libgsh::capabilities::apply_cap_bounding(bounding) {
|
||||||
|
eprintln!("gsh: capability application failed: {e} — denying shell startup");
|
||||||
|
return Ok(125);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Execute:
|
// Execute:
|
||||||
let mut command = process::Command::new("sh");
|
let mut command = process::Command::new("sh");
|
||||||
command.arg("-c").arg(exec);
|
command.arg("-c").arg(exec);
|
||||||
|
|
@ -333,31 +325,23 @@ fn run(args: Args) -> Result<i32> {
|
||||||
|
|
||||||
// Post CR:
|
// Post CR:
|
||||||
let outcome = if exit_code == 0 { "completed" } else { "failed" };
|
let outcome = if exit_code == 0 { "completed" } else { "failed" };
|
||||||
let base = args.broker_url.or_else(|| std::env::var("GSAP_BROKER_URL").ok());
|
let cr = rt.block_on(post_cr(Some(&sock), &ac_id, outcome));
|
||||||
|
|
||||||
let (cr_id, chronicle_cid) = if let Some(base) = base {
|
|
||||||
let client = build_client(&token).map_err(|e| anyhow::anyhow!(e))?;
|
|
||||||
let cr = post_cr(&client, &base, &ac_id, outcome);
|
|
||||||
if ac_mode == "session" && cr.receipt_id.is_empty() && cr.chronicle_cid.is_empty() {
|
if ac_mode == "session" && cr.receipt_id.is_empty() && cr.chronicle_cid.is_empty() {
|
||||||
eprintln!("gsh: session CR not recorded (broker session support pending)");
|
eprintln!("gsh: session CR not recorded (fabric session support pending)");
|
||||||
}
|
}
|
||||||
(cr.receipt_id, cr.chronicle_cid)
|
|
||||||
} else {
|
|
||||||
eprintln!("gsh: no GSAP_BROKER_URL — CR not posted");
|
|
||||||
(String::new(), String::new())
|
|
||||||
};
|
|
||||||
|
|
||||||
// Output:
|
// Output:
|
||||||
if args.json {
|
if args.json {
|
||||||
println!("{}", serde_json::to_string_pretty(&GshOutput {
|
println!("{}", serde_json::to_string_pretty(&GshOutput {
|
||||||
exit_code, stdout: stdout_str, stderr: stderr_str,
|
exit_code, stdout: stdout_str, stderr: stderr_str,
|
||||||
ac_id, ac_mode, cr_id, chronicle_cid, command_hash, run_id, corpus_cid: corpus,
|
ac_id, ac_mode, cr_id: cr.receipt_id, chronicle_cid: cr.chronicle_cid.clone(),
|
||||||
|
command_hash, run_id, corpus_cid: corpus,
|
||||||
})?);
|
})?);
|
||||||
} else {
|
} else {
|
||||||
print!("{}", stdout_str);
|
print!("{}", stdout_str);
|
||||||
eprint!("{}", stderr_str);
|
eprint!("{}", stderr_str);
|
||||||
if !chronicle_cid.is_empty() {
|
if !cr.chronicle_cid.is_empty() {
|
||||||
eprintln!("gsh: CID {}", &chronicle_cid[..40.min(chronicle_cid.len())]);
|
eprintln!("gsh: CID {}", &cr.chronicle_cid[..40.min(cr.chronicle_cid.len())]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ description = "Governed shell library — AC validation, CR building, corpus gat
|
||||||
guildhouse-did = { path = "../../guildhouse-did" }
|
guildhouse-did = { path = "../../guildhouse-did" }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
reqwest = { workspace = true }
|
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
sha2 = { workspace = true }
|
sha2 = { workspace = true }
|
||||||
hex = { workspace = true }
|
hex = { workspace = true }
|
||||||
|
|
@ -16,8 +15,14 @@ chrono = { workspace = true }
|
||||||
dirs = { workspace = true }
|
dirs = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
substrate-ipc = { workspace = true }
|
substrate-ipc = { workspace = true }
|
||||||
|
substrate-identity-store = { workspace = true, optional = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
|
libc = { workspace = true }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
lmdb = ["substrate-identity-store"]
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
substrate-identity-store = { workspace = true }
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,10 @@ pub struct AuthorizationContext {
|
||||||
/// `"Application"` | `"System"` (or future variants); threaded into `GSH_SHELL_CLASS`.
|
/// `"Application"` | `"System"` (or future variants); threaded into `GSH_SHELL_CLASS`.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub shell_class: Option<String>,
|
pub shell_class: Option<String>,
|
||||||
|
/// Shell tier (0–6); threaded into `GSH_SHELL_TIER`. When absent,
|
||||||
|
/// derived from `shell_class` (Application→T2, System→T1).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub shell_tier: Option<u8>,
|
||||||
/// Capability bitmask; threaded into `GSH_CAPABILITY_SET` as `0x{:08x}`.
|
/// Capability bitmask; threaded into `GSH_CAPABILITY_SET` as `0x{:08x}`.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub capability_set: Option<u32>,
|
pub capability_set: Option<u32>,
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,7 @@ pub async fn register_agent_shell(
|
||||||
}
|
}
|
||||||
FabricResponse::Denied { reason } => Err(AgentError::Denied { reason }),
|
FabricResponse::Denied { reason } => Err(AgentError::Denied { reason }),
|
||||||
FabricResponse::Error(msg) => Err(AgentError::FabricError(msg)),
|
FabricResponse::Error(msg) => Err(AgentError::FabricError(msg)),
|
||||||
|
_ => Err(AgentError::FabricError("unexpected response".into())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
441
libgsh/src/capabilities.rs
Normal file
441
libgsh/src/capabilities.rs
Normal file
|
|
@ -0,0 +1,441 @@
|
||||||
|
//! Linux capability intersection and materialization for PosixBinding.
|
||||||
|
//!
|
||||||
|
//! Provides:
|
||||||
|
//! - Mapping between Linux capability names and bit positions (u64 bitmask)
|
||||||
|
//! - Intersection logic: `posix_bounding ∩ accord_allowed ∩ delegation_remaining`
|
||||||
|
//! - Hex formatting/parsing for `GSH_CAP_BOUNDING` env var
|
||||||
|
//! - Linux-specific bounding set application via prctl (gated on `target_os`)
|
||||||
|
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum CapError {
|
||||||
|
#[error("invalid capability hex string: {0}")]
|
||||||
|
InvalidHex(String),
|
||||||
|
#[error("unknown capability name: {0}")]
|
||||||
|
UnknownCap(String),
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[error("prctl({0}) failed: {1}")]
|
||||||
|
PrctlFailed(&'static str, std::io::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const CAP_CHOWN: u8 = 0;
|
||||||
|
pub const CAP_DAC_OVERRIDE: u8 = 1;
|
||||||
|
pub const CAP_DAC_READ_SEARCH: u8 = 2;
|
||||||
|
pub const CAP_FOWNER: u8 = 3;
|
||||||
|
pub const CAP_FSETID: u8 = 4;
|
||||||
|
pub const CAP_KILL: u8 = 5;
|
||||||
|
pub const CAP_SETGID: u8 = 6;
|
||||||
|
pub const CAP_SETUID: u8 = 7;
|
||||||
|
pub const CAP_SETPCAP: u8 = 8;
|
||||||
|
pub const CAP_LINUX_IMMUTABLE: u8 = 9;
|
||||||
|
pub const CAP_NET_BIND_SERVICE: u8 = 10;
|
||||||
|
pub const CAP_NET_BROADCAST: u8 = 11;
|
||||||
|
pub const CAP_NET_ADMIN: u8 = 12;
|
||||||
|
pub const CAP_NET_RAW: u8 = 13;
|
||||||
|
pub const CAP_IPC_LOCK: u8 = 14;
|
||||||
|
pub const CAP_IPC_OWNER: u8 = 15;
|
||||||
|
pub const CAP_SYS_MODULE: u8 = 16;
|
||||||
|
pub const CAP_SYS_RAWIO: u8 = 17;
|
||||||
|
pub const CAP_SYS_CHROOT: u8 = 18;
|
||||||
|
pub const CAP_SYS_PTRACE: u8 = 19;
|
||||||
|
pub const CAP_SYS_PACCT: u8 = 20;
|
||||||
|
pub const CAP_SYS_ADMIN: u8 = 21;
|
||||||
|
pub const CAP_SYS_BOOT: u8 = 22;
|
||||||
|
pub const CAP_SYS_NICE: u8 = 23;
|
||||||
|
pub const CAP_SYS_RESOURCE: u8 = 24;
|
||||||
|
pub const CAP_SYS_TIME: u8 = 25;
|
||||||
|
pub const CAP_SYS_TTY_CONFIG: u8 = 26;
|
||||||
|
pub const CAP_MKNOD: u8 = 27;
|
||||||
|
pub const CAP_LEASE: u8 = 28;
|
||||||
|
pub const CAP_AUDIT_WRITE: u8 = 29;
|
||||||
|
pub const CAP_AUDIT_CONTROL: u8 = 30;
|
||||||
|
pub const CAP_SETFCAP: u8 = 31;
|
||||||
|
pub const CAP_MAC_OVERRIDE: u8 = 32;
|
||||||
|
pub const CAP_MAC_ADMIN: u8 = 33;
|
||||||
|
pub const CAP_SYSLOG: u8 = 34;
|
||||||
|
pub const CAP_WAKE_ALARM: u8 = 35;
|
||||||
|
pub const CAP_BLOCK_SUSPEND: u8 = 36;
|
||||||
|
pub const CAP_AUDIT_READ: u8 = 37;
|
||||||
|
pub const CAP_PERFMON: u8 = 38;
|
||||||
|
pub const CAP_BPF: u8 = 39;
|
||||||
|
pub const CAP_CHECKPOINT_RESTORE: u8 = 40;
|
||||||
|
|
||||||
|
const CAP_LAST_CAP: u8 = 40;
|
||||||
|
|
||||||
|
const CAP_TABLE: &[(&str, u8)] = &[
|
||||||
|
("CAP_CHOWN", CAP_CHOWN),
|
||||||
|
("CAP_DAC_OVERRIDE", CAP_DAC_OVERRIDE),
|
||||||
|
("CAP_DAC_READ_SEARCH", CAP_DAC_READ_SEARCH),
|
||||||
|
("CAP_FOWNER", CAP_FOWNER),
|
||||||
|
("CAP_FSETID", CAP_FSETID),
|
||||||
|
("CAP_KILL", CAP_KILL),
|
||||||
|
("CAP_SETGID", CAP_SETGID),
|
||||||
|
("CAP_SETUID", CAP_SETUID),
|
||||||
|
("CAP_SETPCAP", CAP_SETPCAP),
|
||||||
|
("CAP_LINUX_IMMUTABLE", CAP_LINUX_IMMUTABLE),
|
||||||
|
("CAP_NET_BIND_SERVICE", CAP_NET_BIND_SERVICE),
|
||||||
|
("CAP_NET_BROADCAST", CAP_NET_BROADCAST),
|
||||||
|
("CAP_NET_ADMIN", CAP_NET_ADMIN),
|
||||||
|
("CAP_NET_RAW", CAP_NET_RAW),
|
||||||
|
("CAP_IPC_LOCK", CAP_IPC_LOCK),
|
||||||
|
("CAP_IPC_OWNER", CAP_IPC_OWNER),
|
||||||
|
("CAP_SYS_MODULE", CAP_SYS_MODULE),
|
||||||
|
("CAP_SYS_RAWIO", CAP_SYS_RAWIO),
|
||||||
|
("CAP_SYS_CHROOT", CAP_SYS_CHROOT),
|
||||||
|
("CAP_SYS_PTRACE", CAP_SYS_PTRACE),
|
||||||
|
("CAP_SYS_PACCT", CAP_SYS_PACCT),
|
||||||
|
("CAP_SYS_ADMIN", CAP_SYS_ADMIN),
|
||||||
|
("CAP_SYS_BOOT", CAP_SYS_BOOT),
|
||||||
|
("CAP_SYS_NICE", CAP_SYS_NICE),
|
||||||
|
("CAP_SYS_RESOURCE", CAP_SYS_RESOURCE),
|
||||||
|
("CAP_SYS_TIME", CAP_SYS_TIME),
|
||||||
|
("CAP_SYS_TTY_CONFIG", CAP_SYS_TTY_CONFIG),
|
||||||
|
("CAP_MKNOD", CAP_MKNOD),
|
||||||
|
("CAP_LEASE", CAP_LEASE),
|
||||||
|
("CAP_AUDIT_WRITE", CAP_AUDIT_WRITE),
|
||||||
|
("CAP_AUDIT_CONTROL", CAP_AUDIT_CONTROL),
|
||||||
|
("CAP_SETFCAP", CAP_SETFCAP),
|
||||||
|
("CAP_MAC_OVERRIDE", CAP_MAC_OVERRIDE),
|
||||||
|
("CAP_MAC_ADMIN", CAP_MAC_ADMIN),
|
||||||
|
("CAP_SYSLOG", CAP_SYSLOG),
|
||||||
|
("CAP_WAKE_ALARM", CAP_WAKE_ALARM),
|
||||||
|
("CAP_BLOCK_SUSPEND", CAP_BLOCK_SUSPEND),
|
||||||
|
("CAP_AUDIT_READ", CAP_AUDIT_READ),
|
||||||
|
("CAP_PERFMON", CAP_PERFMON),
|
||||||
|
("CAP_BPF", CAP_BPF),
|
||||||
|
("CAP_CHECKPOINT_RESTORE", CAP_CHECKPOINT_RESTORE),
|
||||||
|
];
|
||||||
|
|
||||||
|
pub fn cap_name_to_bit(name: &str) -> Option<u8> {
|
||||||
|
CAP_TABLE.iter().find(|(n, _)| *n == name).map(|(_, b)| *b)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cap_bit_to_name(bit: u8) -> Option<&'static str> {
|
||||||
|
CAP_TABLE.iter().find(|(_, b)| *b == bit).map(|(n, _)| *n)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a list of capability name strings to a bitmask.
|
||||||
|
pub fn parse_cap_names(names: &[String]) -> Result<u64, CapError> {
|
||||||
|
let mut mask = 0u64;
|
||||||
|
for name in names {
|
||||||
|
let bit = cap_name_to_bit(name).ok_or_else(|| CapError::UnknownCap(name.clone()))?;
|
||||||
|
mask |= 1u64 << bit;
|
||||||
|
}
|
||||||
|
Ok(mask)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute the effective capability bitmask as the intersection of all
|
||||||
|
/// governance dimensions. Capabilities can only be narrowed, never widened.
|
||||||
|
pub fn intersect(posix_bounding: u64, accord_allowed: u64, delegation_remaining: u64) -> u64 {
|
||||||
|
posix_bounding & accord_allowed & delegation_remaining
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Input for capability intersection sourced from DID document bindings.
|
||||||
|
///
|
||||||
|
/// Constructed from a `GovernanceDeclaration` — the DID document carries
|
||||||
|
/// all three dimensions of the intersection natively:
|
||||||
|
/// - PosixBinding.kernel.capabilities.bounding → posix bitmask
|
||||||
|
/// - AccordBinding.attenuation.denied_capabilities → accord allowed (complement)
|
||||||
|
/// - Delegation chain (external, passed as `delegation_remaining`)
|
||||||
|
pub struct IntersectionInput {
|
||||||
|
pub posix_bounding: u64,
|
||||||
|
pub accord_allowed: u64,
|
||||||
|
pub delegation_remaining: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntersectionInput {
|
||||||
|
/// Construct from a governance declaration and delegation chain bitmask.
|
||||||
|
///
|
||||||
|
/// Extracts the PosixBinding bounding set (first binding found) and
|
||||||
|
/// computes the accord allowed mask from the effective attenuation.
|
||||||
|
/// Unknown capability names in the attenuation are silently ignored.
|
||||||
|
pub fn from_governance(
|
||||||
|
gov: &guildhouse_did::GovernanceDeclaration,
|
||||||
|
delegation_remaining: u64,
|
||||||
|
) -> Self {
|
||||||
|
let posix_bounding = gov
|
||||||
|
.posix_bindings
|
||||||
|
.first()
|
||||||
|
.and_then(|p| p.kernel.capabilities.as_ref())
|
||||||
|
.map(|caps| {
|
||||||
|
caps.bounding
|
||||||
|
.iter()
|
||||||
|
.filter_map(|name| cap_name_to_bit(name))
|
||||||
|
.fold(0u64, |mask, bit| mask | (1u64 << bit))
|
||||||
|
})
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
let attenuation = gov.effective_attenuation();
|
||||||
|
let accord_allowed = denied_to_allowed_mask(&attenuation.denied_capabilities);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
posix_bounding,
|
||||||
|
accord_allowed,
|
||||||
|
delegation_remaining,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute the effective capability bitmask.
|
||||||
|
pub fn effective(&self) -> u64 {
|
||||||
|
intersect(self.posix_bounding, self.accord_allowed, self.delegation_remaining)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a list of denied capability names to an allowed bitmask.
|
||||||
|
/// All bits start high; each denied capability clears its bit.
|
||||||
|
pub fn denied_to_allowed_mask(denied: &[String]) -> u64 {
|
||||||
|
let mut mask = u64::MAX;
|
||||||
|
for name in denied {
|
||||||
|
if let Some(bit) = cap_name_to_bit(name) {
|
||||||
|
mask &= !(1u64 << bit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mask
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format a capability bitmask as the `GSH_CAP_BOUNDING` hex string.
|
||||||
|
pub fn format_hex(mask: u64) -> String {
|
||||||
|
format!("0x{mask:016x}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a hex bitmask string (with optional `0x` prefix).
|
||||||
|
pub fn parse_hex(s: &str) -> Result<u64, CapError> {
|
||||||
|
let hex = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")).unwrap_or(s);
|
||||||
|
u64::from_str_radix(hex, 16).map_err(|_| CapError::InvalidHex(s.to_owned()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply Linux capability bounding set restrictions to the current process.
|
||||||
|
///
|
||||||
|
/// Drops all capabilities NOT present in the given bitmask from the bounding
|
||||||
|
/// set, and sets securebits to prevent setuid from granting new capabilities.
|
||||||
|
/// Fails closed — returns an error if any prctl call fails (except EINVAL for
|
||||||
|
/// capabilities not supported by the running kernel).
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub fn apply_cap_bounding(bounding: u64) -> Result<(), CapError> {
|
||||||
|
const PR_SET_SECUREBITS: libc::c_int = 28;
|
||||||
|
const SECBIT_NOROOT: libc::c_ulong = 0x01;
|
||||||
|
const SECBIT_NO_SETUID_FIXUP: libc::c_ulong = 0x04;
|
||||||
|
const PR_CAPBSET_DROP: libc::c_int = 24;
|
||||||
|
|
||||||
|
let ret = unsafe {
|
||||||
|
libc::prctl(
|
||||||
|
PR_SET_SECUREBITS,
|
||||||
|
SECBIT_NOROOT | SECBIT_NO_SETUID_FIXUP,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if ret != 0 {
|
||||||
|
let err = std::io::Error::last_os_error();
|
||||||
|
if err.raw_os_error() != Some(libc::EPERM) {
|
||||||
|
return Err(CapError::PrctlFailed("PR_SET_SECUREBITS", err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for cap in 0..=CAP_LAST_CAP {
|
||||||
|
if (bounding >> cap) & 1 == 0 {
|
||||||
|
let ret = unsafe { libc::prctl(PR_CAPBSET_DROP, cap as libc::c_ulong, 0, 0, 0) };
|
||||||
|
if ret != 0 {
|
||||||
|
let err = std::io::Error::last_os_error();
|
||||||
|
if err.raw_os_error() != Some(libc::EINVAL) {
|
||||||
|
return Err(CapError::PrctlFailed("PR_CAPBSET_DROP", err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_known_caps() {
|
||||||
|
let names = vec![
|
||||||
|
"CAP_NET_BIND_SERVICE".into(),
|
||||||
|
"CAP_DAC_READ_SEARCH".into(),
|
||||||
|
];
|
||||||
|
let mask = parse_cap_names(&names).unwrap();
|
||||||
|
assert_eq!(mask, (1u64 << 10) | (1u64 << 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_unknown_cap_errors() {
|
||||||
|
let names = vec!["CAP_DOES_NOT_EXIST".into()];
|
||||||
|
assert!(parse_cap_names(&names).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn intersection_narrows() {
|
||||||
|
let posix = (1u64 << CAP_NET_BIND_SERVICE)
|
||||||
|
| (1u64 << CAP_DAC_READ_SEARCH)
|
||||||
|
| (1u64 << CAP_SYS_ADMIN);
|
||||||
|
let accord = (1u64 << CAP_NET_BIND_SERVICE) | (1u64 << CAP_DAC_READ_SEARCH);
|
||||||
|
let delegation = u64::MAX; // no attenuation
|
||||||
|
let effective = intersect(posix, accord, delegation);
|
||||||
|
assert_eq!(
|
||||||
|
effective,
|
||||||
|
(1u64 << CAP_NET_BIND_SERVICE) | (1u64 << CAP_DAC_READ_SEARCH)
|
||||||
|
);
|
||||||
|
assert_eq!(effective & (1u64 << CAP_SYS_ADMIN), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn intersection_all_zeros() {
|
||||||
|
assert_eq!(intersect(0xFF, 0x00, 0xFF), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delegation_attenuates() {
|
||||||
|
let posix = (1u64 << CAP_NET_BIND_SERVICE) | (1u64 << CAP_SYS_ADMIN);
|
||||||
|
let accord = u64::MAX;
|
||||||
|
let delegation = 1u64 << CAP_NET_BIND_SERVICE; // only net_bind
|
||||||
|
let effective = intersect(posix, accord, delegation);
|
||||||
|
assert_eq!(effective, 1u64 << CAP_NET_BIND_SERVICE);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hex_format_round_trip() {
|
||||||
|
let mask = (1u64 << CAP_NET_BIND_SERVICE) | (1u64 << CAP_DAC_READ_SEARCH);
|
||||||
|
let hex = format_hex(mask);
|
||||||
|
assert_eq!(hex, "0x0000000000000404");
|
||||||
|
let parsed = parse_hex(&hex).unwrap();
|
||||||
|
assert_eq!(parsed, mask);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_hex_no_prefix() {
|
||||||
|
let parsed = parse_hex("0000000000200400").unwrap();
|
||||||
|
assert_eq!(parsed, (1u64 << 10) | (1u64 << 21));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_hex_invalid() {
|
||||||
|
assert!(parse_hex("0xZZZZ").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cap_name_round_trip() {
|
||||||
|
for (name, bit) in CAP_TABLE {
|
||||||
|
assert_eq!(cap_name_to_bit(name), Some(*bit));
|
||||||
|
assert_eq!(cap_bit_to_name(*bit), Some(*name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn all_41_caps_present() {
|
||||||
|
assert_eq!(CAP_TABLE.len(), 41);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn denied_to_allowed_clears_bits() {
|
||||||
|
let denied = vec!["CAP_SYS_ADMIN".into(), "CAP_NET_ADMIN".into()];
|
||||||
|
let mask = denied_to_allowed_mask(&denied);
|
||||||
|
assert_eq!(mask & (1u64 << CAP_SYS_ADMIN), 0);
|
||||||
|
assert_eq!(mask & (1u64 << CAP_NET_ADMIN), 0);
|
||||||
|
assert_ne!(mask & (1u64 << CAP_NET_BIND_SERVICE), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn denied_to_allowed_empty_is_all_ones() {
|
||||||
|
let mask = denied_to_allowed_mask(&[]);
|
||||||
|
assert_eq!(mask, u64::MAX);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn denied_to_allowed_unknown_caps_ignored() {
|
||||||
|
let denied = vec!["CAP_DOES_NOT_EXIST".into(), "CAP_SYS_ADMIN".into()];
|
||||||
|
let mask = denied_to_allowed_mask(&denied);
|
||||||
|
assert_eq!(mask & (1u64 << CAP_SYS_ADMIN), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn intersection_input_from_governance() {
|
||||||
|
use chrono::{TimeZone, Utc};
|
||||||
|
use guildhouse_did::document::*;
|
||||||
|
use guildhouse_did::{Did, GovernanceDeclaration};
|
||||||
|
|
||||||
|
let did = Did::parse("did:web:test.dev:user:a").unwrap();
|
||||||
|
let mut doc = DidDocument::minimal(did.clone());
|
||||||
|
|
||||||
|
doc.add_binding(
|
||||||
|
ServiceEndpoint::typed(
|
||||||
|
format!("{}#posix", did.as_str()),
|
||||||
|
BINDING_TYPE_POSIX,
|
||||||
|
&PosixBinding {
|
||||||
|
binding_version: "0.1.0".into(),
|
||||||
|
identity: PosixIdentity {
|
||||||
|
uid: 60001,
|
||||||
|
gid: 60001,
|
||||||
|
username: "test".into(),
|
||||||
|
home_directory: None,
|
||||||
|
login_shell: None,
|
||||||
|
gecos: None,
|
||||||
|
supplementary_groups: vec![],
|
||||||
|
},
|
||||||
|
kernel: PosixKernel {
|
||||||
|
capabilities: Some(PosixCapabilities {
|
||||||
|
bounding: vec![
|
||||||
|
"CAP_NET_BIND_SERVICE".into(),
|
||||||
|
"CAP_DAC_READ_SEARCH".into(),
|
||||||
|
"CAP_SYS_ADMIN".into(),
|
||||||
|
],
|
||||||
|
ambient: vec![],
|
||||||
|
inheritable: vec![],
|
||||||
|
}),
|
||||||
|
namespaces: None,
|
||||||
|
},
|
||||||
|
filesystem: None,
|
||||||
|
network: None,
|
||||||
|
node_selector: None,
|
||||||
|
host_did: None,
|
||||||
|
allocation_method: None,
|
||||||
|
bound_at: None,
|
||||||
|
ceremony_id: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
doc.add_binding(
|
||||||
|
ServiceEndpoint::typed(
|
||||||
|
format!("{}#accord", did.as_str()),
|
||||||
|
BINDING_TYPE_ACCORD,
|
||||||
|
&AccordBinding {
|
||||||
|
accord_id: "acc-001".into(),
|
||||||
|
accord_hash: "hash".into(),
|
||||||
|
trust_domain: "test.dev".into(),
|
||||||
|
scope: "test".into(),
|
||||||
|
state: AccordBindingState::Active,
|
||||||
|
attenuation: AccordAttenuation {
|
||||||
|
denied_capabilities: vec!["CAP_SYS_ADMIN".into()],
|
||||||
|
max_forge_source_count: None,
|
||||||
|
denied_forge_sources: vec![],
|
||||||
|
denied_network_services: vec![],
|
||||||
|
max_connections: None,
|
||||||
|
},
|
||||||
|
limits: None,
|
||||||
|
sla: None,
|
||||||
|
bound_at: Utc.timestamp_opt(1_700_000_000, 0).unwrap(),
|
||||||
|
expires_at: None,
|
||||||
|
ceremony_id: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let gov = GovernanceDeclaration::from_document(&doc);
|
||||||
|
let input = IntersectionInput::from_governance(&gov, u64::MAX);
|
||||||
|
|
||||||
|
let effective = input.effective();
|
||||||
|
assert_ne!(effective & (1u64 << CAP_NET_BIND_SERVICE), 0);
|
||||||
|
assert_ne!(effective & (1u64 << CAP_DAC_READ_SEARCH), 0);
|
||||||
|
assert_eq!(effective & (1u64 << CAP_SYS_ADMIN), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
331
libgsh/src/cr.rs
331
libgsh/src/cr.rs
|
|
@ -1,203 +1,192 @@
|
||||||
//! Completion Receipt construction and posting.
|
//! Completion Receipt and Authorization Context via fabric IPC.
|
||||||
|
|
||||||
use reqwest::blocking::Client;
|
use substrate_ipc::fabric_api::{FabricRequest, FabricResponse};
|
||||||
use serde::{Deserialize, Serialize};
|
use substrate_ipc::wire;
|
||||||
use std::time::Duration;
|
use tokio::net::UnixStream;
|
||||||
|
|
||||||
#[derive(Serialize)]
|
const DEFAULT_FABRIC_SOCKET: &str = "/run/substrate/fabric.sock";
|
||||||
pub struct CrRequest {
|
|
||||||
pub context_id: String,
|
|
||||||
pub outcome: String,
|
|
||||||
pub completed_at: String,
|
|
||||||
pub chronicle_evidence: CrEvidence,
|
|
||||||
pub behavioral_attestation: CrAttestation,
|
|
||||||
pub ffc: serde_json::Value,
|
|
||||||
pub signature: serde_json::Value,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
pub struct CrEvidence {
|
|
||||||
pub session_id: Option<String>,
|
|
||||||
pub events: Vec<serde_json::Value>,
|
|
||||||
pub merkle_root: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
pub struct CrAttestation {
|
|
||||||
pub status: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct CrResponse {
|
|
||||||
pub receipt_id: Option<String>,
|
|
||||||
pub chronicle_event_cid: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Result of posting a CR.
|
|
||||||
pub struct CrResult {
|
pub struct CrResult {
|
||||||
pub receipt_id: String,
|
pub receipt_id: String,
|
||||||
pub chronicle_cid: String,
|
pub chronicle_cid: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build an HTTP client with optional bearer token.
|
/// Post a Completion Receipt through the fabric socket.
|
||||||
pub fn build_client(token: &Option<String>) -> Result<Client, String> {
|
pub async fn post_cr(
|
||||||
let mut headers = reqwest::header::HeaderMap::new();
|
fabric_socket: Option<&str>,
|
||||||
if let Some(tok) = token {
|
ac_id: &str,
|
||||||
headers.insert(
|
outcome: &str,
|
||||||
"Authorization",
|
) -> CrResult {
|
||||||
format!("Bearer {}", tok)
|
let sock = fabric_socket.unwrap_or(DEFAULT_FABRIC_SOCKET);
|
||||||
.parse()
|
let req = FabricRequest::SubmitCompletionRecord {
|
||||||
.map_err(|_| "Invalid token".to_string())?,
|
ac_id: ac_id.into(),
|
||||||
);
|
|
||||||
}
|
|
||||||
Client::builder()
|
|
||||||
.timeout(Duration::from_secs(30))
|
|
||||||
.default_headers(headers)
|
|
||||||
.build()
|
|
||||||
.map_err(|e| format!("HTTP client failed: {}", e))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Format a broker URL path.
|
|
||||||
pub fn broker_url(base: &str, path: &str) -> String {
|
|
||||||
format!(
|
|
||||||
"{}/{}",
|
|
||||||
base.trim_end_matches('/'),
|
|
||||||
path.trim_start_matches('/')
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Post a Completion Receipt to the broker.
|
|
||||||
pub fn post_cr(client: &Client, base: &str, ac_id: &str, outcome: &str) -> CrResult {
|
|
||||||
let now = chrono::Utc::now().to_rfc3339();
|
|
||||||
let session_id = std::env::var("CHRONICLE_SESSION_ID").unwrap_or_default();
|
|
||||||
// Phase 0 D1: FFC DID sourced from env (FFC_DID), not hardcoded.
|
|
||||||
// W3C colon form. Empty on error → null in payload.
|
|
||||||
let ffc_did = std::env::var("FFC_DID")
|
|
||||||
.ok()
|
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.and_then(|s| guildhouse_did::Did::parse(&s).ok())
|
|
||||||
.map(|d| d.as_str().to_owned());
|
|
||||||
|
|
||||||
match client
|
|
||||||
.post(broker_url(base, "governance/complete/"))
|
|
||||||
.json(&CrRequest {
|
|
||||||
context_id: ac_id.into(),
|
|
||||||
outcome: outcome.into(),
|
outcome: outcome.into(),
|
||||||
completed_at: now,
|
};
|
||||||
chronicle_evidence: CrEvidence {
|
|
||||||
session_id: if session_id.is_empty() { None } else { Some(session_id.clone()) },
|
match send_fabric_request(sock, &req).await {
|
||||||
events: vec![],
|
Ok(FabricResponse::CompletionRecordAccepted { cr_id, chronicle_cid }) => {
|
||||||
merkle_root: None,
|
CrResult { receipt_id: cr_id, chronicle_cid }
|
||||||
},
|
|
||||||
behavioral_attestation: CrAttestation {
|
|
||||||
status: "unavailable".into(),
|
|
||||||
},
|
|
||||||
ffc: serde_json::json!({"did": ffc_did, "chronicle_session_id": session_id}),
|
|
||||||
signature: serde_json::json!({"value": "gsh"}),
|
|
||||||
})
|
|
||||||
.send()
|
|
||||||
{
|
|
||||||
Ok(r) if r.status().is_success() => {
|
|
||||||
let cr: CrResponse = r.json().unwrap_or(CrResponse {
|
|
||||||
receipt_id: None,
|
|
||||||
chronicle_event_cid: None,
|
|
||||||
});
|
|
||||||
CrResult {
|
|
||||||
receipt_id: cr.receipt_id.unwrap_or_default(),
|
|
||||||
chronicle_cid: cr.chronicle_event_cid.unwrap_or_default(),
|
|
||||||
}
|
}
|
||||||
|
Ok(FabricResponse::Error(msg)) => {
|
||||||
|
eprintln!("gsh: CR failed: {}", msg);
|
||||||
|
CrResult { receipt_id: String::new(), chronicle_cid: String::new() }
|
||||||
}
|
}
|
||||||
Ok(r) => {
|
Ok(_) => {
|
||||||
eprintln!("gsh: CR failed: {}", r.status());
|
eprintln!("gsh: CR unexpected response");
|
||||||
CrResult {
|
CrResult { receipt_id: String::new(), chronicle_cid: String::new() }
|
||||||
receipt_id: String::new(),
|
|
||||||
chronicle_cid: String::new(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("gsh: CR error: {}", e);
|
eprintln!("gsh: CR error: {}", e);
|
||||||
CrResult {
|
CrResult { receipt_id: String::new(), chronicle_cid: String::new() }
|
||||||
receipt_id: String::new(),
|
|
||||||
chronicle_cid: String::new(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Request an inline AC from the broker (fallback mode).
|
/// Request an inline Authorization Context through the fabric socket.
|
||||||
pub fn request_ac_inline(
|
pub async fn request_ac_inline(
|
||||||
client: &Client,
|
fabric_socket: Option<&str>,
|
||||||
base: &str,
|
|
||||||
operation: &str,
|
operation: &str,
|
||||||
command_hash: &str,
|
command_hash: &str,
|
||||||
corpus_cid: &str,
|
corpus_cid: &str,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
#[derive(Serialize)]
|
let sock = fabric_socket.unwrap_or(DEFAULT_FABRIC_SOCKET);
|
||||||
struct AcRequest {
|
let req = FabricRequest::RequestAuthorization {
|
||||||
driver_id: String,
|
operation: operation.into(),
|
||||||
playbook: String,
|
command_hash: command_hash.into(),
|
||||||
corpus_entry_cid: String,
|
corpus_cid: corpus_cid.into(),
|
||||||
parameters_cid: String,
|
};
|
||||||
accord_template: String,
|
|
||||||
|
match send_fabric_request(sock, &req).await {
|
||||||
|
Ok(FabricResponse::AuthorizationGranted { ac_id }) => Ok(ac_id),
|
||||||
|
Ok(FabricResponse::Denied { reason }) => Err(format!("AC denied: {}", reason)),
|
||||||
|
Ok(FabricResponse::Error(msg)) => Err(format!("fabric error: {}", msg)),
|
||||||
|
Ok(_) => Err("unexpected fabric response".into()),
|
||||||
|
Err(e) => Err(format!("fabric IPC error: {}", e)),
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
async fn send_fabric_request(
|
||||||
struct AcResponse {
|
sock_path: &str,
|
||||||
authorization_context: Option<AcCtx>,
|
req: &FabricRequest,
|
||||||
}
|
) -> Result<FabricResponse, String> {
|
||||||
|
let stream = UnixStream::connect(sock_path)
|
||||||
#[derive(Deserialize)]
|
.await
|
||||||
struct AcCtx {
|
.map_err(|e| format!("connecting to {}: {}", sock_path, e))?;
|
||||||
context_id: String,
|
let (mut rd, mut wr) = tokio::io::split(stream);
|
||||||
}
|
wire::send_msg(&mut wr, req)
|
||||||
|
.await
|
||||||
let resp = client
|
.map_err(|e| format!("sending request: {}", e))?;
|
||||||
.post(broker_url(base, "governance/authorize/"))
|
wire::recv_msg(&mut rd)
|
||||||
.json(&AcRequest {
|
.await
|
||||||
driver_id: "keycloak".into(),
|
.map_err(|e| format!("receiving response: {}", e))
|
||||||
playbook: format!(
|
|
||||||
"{}:{}",
|
|
||||||
operation,
|
|
||||||
&command_hash[..20.min(command_hash.len())]
|
|
||||||
),
|
|
||||||
corpus_entry_cid: corpus_cid.into(),
|
|
||||||
parameters_cid: command_hash.into(),
|
|
||||||
accord_template: "shell-exec".into(),
|
|
||||||
})
|
|
||||||
.send()
|
|
||||||
.map_err(|e| format!("Failed to reach broker: {}", e))?;
|
|
||||||
|
|
||||||
if !resp.status().is_success() {
|
|
||||||
return Err(format!(
|
|
||||||
"AC denied: {} — {}",
|
|
||||||
resp.status(),
|
|
||||||
resp.text().unwrap_or_default()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let ac: AcResponse = resp
|
|
||||||
.json()
|
|
||||||
.map_err(|e| format!("Invalid AC response: {}", e))?;
|
|
||||||
|
|
||||||
ac.authorization_context
|
|
||||||
.map(|c| c.context_id)
|
|
||||||
.filter(|id| !id.is_empty())
|
|
||||||
.ok_or_else(|| "No AC ID returned".to_string())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
fn test_broker_url() {
|
async fn post_cr_round_trip_with_mock_fabric() {
|
||||||
assert_eq!(
|
let dir = tempfile::tempdir().unwrap();
|
||||||
broker_url("http://localhost:8000", "governance/complete/"),
|
let sock_path = dir.path().join("fabric.sock");
|
||||||
"http://localhost:8000/governance/complete/"
|
let sock_str = sock_path.to_str().unwrap().to_string();
|
||||||
);
|
|
||||||
assert_eq!(
|
let listener = tokio::net::UnixListener::bind(&sock_path).unwrap();
|
||||||
broker_url("http://localhost:8000/", "/governance/complete/"),
|
|
||||||
"http://localhost:8000/governance/complete/"
|
let sock_clone = sock_str.clone();
|
||||||
);
|
let client = tokio::spawn(async move {
|
||||||
|
post_cr(Some(&sock_clone), "ac-123", "completed").await
|
||||||
|
});
|
||||||
|
|
||||||
|
let (stream, _) = listener.accept().await.unwrap();
|
||||||
|
let (mut rd, mut wr) = tokio::io::split(stream);
|
||||||
|
|
||||||
|
let req: FabricRequest = wire::recv_msg(&mut rd).await.unwrap();
|
||||||
|
match req {
|
||||||
|
FabricRequest::SubmitCompletionRecord { ac_id, outcome } => {
|
||||||
|
assert_eq!(ac_id, "ac-123");
|
||||||
|
assert_eq!(outcome, "completed");
|
||||||
|
}
|
||||||
|
_ => panic!("wrong request variant"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let resp = FabricResponse::CompletionRecordAccepted {
|
||||||
|
cr_id: "cr-456".into(),
|
||||||
|
chronicle_cid: "bafy...".into(),
|
||||||
|
};
|
||||||
|
wire::send_msg(&mut wr, &resp).await.unwrap();
|
||||||
|
|
||||||
|
let result = client.await.unwrap();
|
||||||
|
assert_eq!(result.receipt_id, "cr-456");
|
||||||
|
assert_eq!(result.chronicle_cid, "bafy...");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn request_ac_round_trip_with_mock_fabric() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let sock_path = dir.path().join("fabric.sock");
|
||||||
|
let sock_str = sock_path.to_str().unwrap().to_string();
|
||||||
|
|
||||||
|
let listener = tokio::net::UnixListener::bind(&sock_path).unwrap();
|
||||||
|
|
||||||
|
let sock_clone = sock_str.clone();
|
||||||
|
let client = tokio::spawn(async move {
|
||||||
|
request_ac_inline(Some(&sock_clone), "shell:exec", "abc123", "sha256:corpus").await
|
||||||
|
});
|
||||||
|
|
||||||
|
let (stream, _) = listener.accept().await.unwrap();
|
||||||
|
let (mut rd, mut wr) = tokio::io::split(stream);
|
||||||
|
|
||||||
|
let req: FabricRequest = wire::recv_msg(&mut rd).await.unwrap();
|
||||||
|
match req {
|
||||||
|
FabricRequest::RequestAuthorization { operation, command_hash, corpus_cid } => {
|
||||||
|
assert_eq!(operation, "shell:exec");
|
||||||
|
assert_eq!(command_hash, "abc123");
|
||||||
|
assert_eq!(corpus_cid, "sha256:corpus");
|
||||||
|
}
|
||||||
|
_ => panic!("wrong request variant"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let resp = FabricResponse::AuthorizationGranted {
|
||||||
|
ac_id: "ac-789".into(),
|
||||||
|
};
|
||||||
|
wire::send_msg(&mut wr, &resp).await.unwrap();
|
||||||
|
|
||||||
|
let result = client.await.unwrap().unwrap();
|
||||||
|
assert_eq!(result, "ac-789");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn request_ac_denied() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let sock_path = dir.path().join("fabric.sock");
|
||||||
|
let sock_str = sock_path.to_str().unwrap().to_string();
|
||||||
|
|
||||||
|
let listener = tokio::net::UnixListener::bind(&sock_path).unwrap();
|
||||||
|
|
||||||
|
let sock_clone = sock_str.clone();
|
||||||
|
let client = tokio::spawn(async move {
|
||||||
|
request_ac_inline(Some(&sock_clone), "shell:exec", "abc", "sha256:c").await
|
||||||
|
});
|
||||||
|
|
||||||
|
let (stream, _) = listener.accept().await.unwrap();
|
||||||
|
let (mut rd, mut wr) = tokio::io::split(stream);
|
||||||
|
|
||||||
|
let _req: FabricRequest = wire::recv_msg(&mut rd).await.unwrap();
|
||||||
|
let resp = FabricResponse::Denied {
|
||||||
|
reason: "posture too low".into(),
|
||||||
|
};
|
||||||
|
wire::send_msg(&mut wr, &resp).await.unwrap();
|
||||||
|
|
||||||
|
let result = client.await.unwrap();
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result.unwrap_err().contains("denied"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn post_cr_socket_missing() {
|
||||||
|
let result = post_cr(Some("/tmp/nonexistent-fabric-cr.sock"), "ac", "ok").await;
|
||||||
|
assert!(result.receipt_id.is_empty());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
//! | `GSH_DID` | `principal.did` (canonical string) |
|
//! | `GSH_DID` | `principal.did` (canonical string) |
|
||||||
//! | `GSH_ACCORD_HASH` | `accord_hash` |
|
//! | `GSH_ACCORD_HASH` | `accord_hash` |
|
||||||
//! | `GSH_SHELL_CLASS` | `shell_class` |
|
//! | `GSH_SHELL_CLASS` | `shell_class` |
|
||||||
|
//! | `GSH_SHELL_TIER` | `shell_tier` numeric (0–6) |
|
||||||
//! | `GSH_POSTURE_LEVEL` | `posture_level` (decimal) |
|
//! | `GSH_POSTURE_LEVEL` | `posture_level` (decimal) |
|
||||||
//! | `GSH_CAPABILITY_SET` | `capability_set` formatted `0x{:08x}` |
|
//! | `GSH_CAPABILITY_SET` | `capability_set` formatted `0x{:08x}` |
|
||||||
//!
|
//!
|
||||||
|
|
@ -18,6 +19,7 @@
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
use crate::ac::AuthorizationContext;
|
use crate::ac::AuthorizationContext;
|
||||||
|
use crate::shell_tier::ShellTier;
|
||||||
|
|
||||||
/// Apply the `GSH_*` env-var contract to a child `Command`.
|
/// Apply the `GSH_*` env-var contract to a child `Command`.
|
||||||
///
|
///
|
||||||
|
|
@ -30,8 +32,10 @@ pub fn apply(
|
||||||
did: Option<&str>,
|
did: Option<&str>,
|
||||||
accord_hash: Option<&str>,
|
accord_hash: Option<&str>,
|
||||||
shell_class: Option<&str>,
|
shell_class: Option<&str>,
|
||||||
|
shell_tier: Option<ShellTier>,
|
||||||
posture_level: Option<u8>,
|
posture_level: Option<u8>,
|
||||||
capability_set: Option<u32>,
|
capability_set: Option<u32>,
|
||||||
|
cap_bounding: Option<u64>,
|
||||||
) {
|
) {
|
||||||
if let Some(d) = did {
|
if let Some(d) = did {
|
||||||
cmd.env("GSH_DID", d);
|
cmd.env("GSH_DID", d);
|
||||||
|
|
@ -42,12 +46,18 @@ pub fn apply(
|
||||||
if let Some(c) = shell_class {
|
if let Some(c) = shell_class {
|
||||||
cmd.env("GSH_SHELL_CLASS", c);
|
cmd.env("GSH_SHELL_CLASS", c);
|
||||||
}
|
}
|
||||||
|
if let Some(tier) = shell_tier {
|
||||||
|
cmd.env("GSH_SHELL_TIER", tier.numeric_level().to_string());
|
||||||
|
}
|
||||||
if let Some(p) = posture_level {
|
if let Some(p) = posture_level {
|
||||||
cmd.env("GSH_POSTURE_LEVEL", p.to_string());
|
cmd.env("GSH_POSTURE_LEVEL", p.to_string());
|
||||||
}
|
}
|
||||||
if let Some(c) = capability_set {
|
if let Some(c) = capability_set {
|
||||||
cmd.env("GSH_CAPABILITY_SET", format!("0x{:08x}", c));
|
cmd.env("GSH_CAPABILITY_SET", format!("0x{:08x}", c));
|
||||||
}
|
}
|
||||||
|
if let Some(b) = cap_bounding {
|
||||||
|
cmd.env("GSH_CAP_BOUNDING", format!("0x{b:016x}"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply the `GSH_*` env-var contract from a parsed AC.
|
/// Apply the `GSH_*` env-var contract from a parsed AC.
|
||||||
|
|
@ -57,13 +67,21 @@ pub fn apply_from_ac(cmd: &mut Command, ac: &AuthorizationContext) {
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|p| p.did.as_ref())
|
.and_then(|p| p.did.as_ref())
|
||||||
.map(|d| d.as_str().to_owned());
|
.map(|d| d.as_str().to_owned());
|
||||||
|
|
||||||
|
let tier = ac
|
||||||
|
.shell_tier
|
||||||
|
.and_then(ShellTier::from_numeric)
|
||||||
|
.or_else(|| ac.shell_class.as_deref().map(ShellTier::from_shell_class));
|
||||||
|
|
||||||
apply(
|
apply(
|
||||||
cmd,
|
cmd,
|
||||||
did.as_deref(),
|
did.as_deref(),
|
||||||
ac.accord_hash.as_deref(),
|
ac.accord_hash.as_deref(),
|
||||||
ac.shell_class.as_deref(),
|
ac.shell_class.as_deref(),
|
||||||
|
tier,
|
||||||
ac.posture_level,
|
ac.posture_level,
|
||||||
ac.capability_set,
|
ac.capability_set,
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -90,8 +108,10 @@ mod tests {
|
||||||
Some("did:web:guildhouse.dev:user:tking"),
|
Some("did:web:guildhouse.dev:user:tking"),
|
||||||
Some("sha256:abcd"),
|
Some("sha256:abcd"),
|
||||||
Some("Application"),
|
Some("Application"),
|
||||||
|
Some(ShellTier::T2Operator),
|
||||||
Some(3),
|
Some(3),
|
||||||
Some(0xCAFEBABE),
|
Some(0xCAFEBABE),
|
||||||
|
Some(0x0000000000200404),
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
cmd_env(&cmd, "GSH_DID").as_deref(),
|
cmd_env(&cmd, "GSH_DID").as_deref(),
|
||||||
|
|
@ -99,24 +119,38 @@ mod tests {
|
||||||
);
|
);
|
||||||
assert_eq!(cmd_env(&cmd, "GSH_ACCORD_HASH").as_deref(), Some("sha256:abcd"));
|
assert_eq!(cmd_env(&cmd, "GSH_ACCORD_HASH").as_deref(), Some("sha256:abcd"));
|
||||||
assert_eq!(cmd_env(&cmd, "GSH_SHELL_CLASS").as_deref(), Some("Application"));
|
assert_eq!(cmd_env(&cmd, "GSH_SHELL_CLASS").as_deref(), Some("Application"));
|
||||||
|
assert_eq!(cmd_env(&cmd, "GSH_SHELL_TIER").as_deref(), Some("2"));
|
||||||
assert_eq!(cmd_env(&cmd, "GSH_POSTURE_LEVEL").as_deref(), Some("3"));
|
assert_eq!(cmd_env(&cmd, "GSH_POSTURE_LEVEL").as_deref(), Some("3"));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
cmd_env(&cmd, "GSH_CAPABILITY_SET").as_deref(),
|
cmd_env(&cmd, "GSH_CAPABILITY_SET").as_deref(),
|
||||||
Some("0xcafebabe")
|
Some("0xcafebabe")
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cmd_env(&cmd, "GSH_CAP_BOUNDING").as_deref(),
|
||||||
|
Some("0x0000000000200404")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn apply_partial_only_did() {
|
fn apply_partial_only_did() {
|
||||||
let mut cmd = Command::new("true");
|
let mut cmd = Command::new("true");
|
||||||
apply(&mut cmd, Some("did:web:foo:bar"), None, None, None, None);
|
apply(&mut cmd, Some("did:web:foo:bar"), None, None, None, None, None, None);
|
||||||
assert_eq!(cmd_env(&cmd, "GSH_DID").as_deref(), Some("did:web:foo:bar"));
|
assert_eq!(cmd_env(&cmd, "GSH_DID").as_deref(), Some("did:web:foo:bar"));
|
||||||
assert!(cmd_env(&cmd, "GSH_ACCORD_HASH").is_none());
|
assert!(cmd_env(&cmd, "GSH_ACCORD_HASH").is_none());
|
||||||
assert!(cmd_env(&cmd, "GSH_SHELL_CLASS").is_none());
|
assert!(cmd_env(&cmd, "GSH_SHELL_CLASS").is_none());
|
||||||
|
assert!(cmd_env(&cmd, "GSH_SHELL_TIER").is_none());
|
||||||
assert!(cmd_env(&cmd, "GSH_POSTURE_LEVEL").is_none());
|
assert!(cmd_env(&cmd, "GSH_POSTURE_LEVEL").is_none());
|
||||||
assert!(cmd_env(&cmd, "GSH_CAPABILITY_SET").is_none());
|
assert!(cmd_env(&cmd, "GSH_CAPABILITY_SET").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_shell_tier_without_class() {
|
||||||
|
let mut cmd = Command::new("true");
|
||||||
|
apply(&mut cmd, None, None, None, Some(ShellTier::T3Agent), None, None, None);
|
||||||
|
assert!(cmd_env(&cmd, "GSH_SHELL_CLASS").is_none());
|
||||||
|
assert_eq!(cmd_env(&cmd, "GSH_SHELL_TIER").as_deref(), Some("3"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn apply_from_ac_full() {
|
fn apply_from_ac_full() {
|
||||||
let ac_json = r#"{
|
let ac_json = r#"{
|
||||||
|
|
@ -136,6 +170,7 @@ mod tests {
|
||||||
);
|
);
|
||||||
assert_eq!(cmd_env(&cmd, "GSH_ACCORD_HASH").as_deref(), Some("sha256:zz"));
|
assert_eq!(cmd_env(&cmd, "GSH_ACCORD_HASH").as_deref(), Some("sha256:zz"));
|
||||||
assert_eq!(cmd_env(&cmd, "GSH_SHELL_CLASS").as_deref(), Some("System"));
|
assert_eq!(cmd_env(&cmd, "GSH_SHELL_CLASS").as_deref(), Some("System"));
|
||||||
|
assert_eq!(cmd_env(&cmd, "GSH_SHELL_TIER").as_deref(), Some("1"));
|
||||||
assert_eq!(cmd_env(&cmd, "GSH_POSTURE_LEVEL").as_deref(), Some("5"));
|
assert_eq!(cmd_env(&cmd, "GSH_POSTURE_LEVEL").as_deref(), Some("5"));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
cmd_env(&cmd, "GSH_CAPABILITY_SET").as_deref(),
|
cmd_env(&cmd, "GSH_CAPABILITY_SET").as_deref(),
|
||||||
|
|
@ -143,6 +178,21 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_from_ac_with_explicit_tier() {
|
||||||
|
let ac_json = r#"{
|
||||||
|
"context_id":"x",
|
||||||
|
"principal":{"did":"did:web:example.com:user:bob"},
|
||||||
|
"shell_class":"Application",
|
||||||
|
"shell_tier":3
|
||||||
|
}"#;
|
||||||
|
let ac: AuthorizationContext = serde_json::from_str(ac_json).unwrap();
|
||||||
|
let mut cmd = Command::new("true");
|
||||||
|
apply_from_ac(&mut cmd, &ac);
|
||||||
|
assert_eq!(cmd_env(&cmd, "GSH_SHELL_CLASS").as_deref(), Some("Application"));
|
||||||
|
assert_eq!(cmd_env(&cmd, "GSH_SHELL_TIER").as_deref(), Some("3"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn apply_from_legacy_ac_no_governance_fields() {
|
fn apply_from_legacy_ac_no_governance_fields() {
|
||||||
let ac_json = r#"{"context_id":"legacy","principal":{"did":"did:web:foo:bar"}}"#;
|
let ac_json = r#"{"context_id":"legacy","principal":{"did":"did:web:foo:bar"}}"#;
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,18 @@
|
||||||
pub mod ac;
|
pub mod ac;
|
||||||
pub mod agent_api;
|
pub mod agent_api;
|
||||||
|
pub mod capabilities;
|
||||||
pub mod chronicle_events;
|
pub mod chronicle_events;
|
||||||
pub mod classifier;
|
pub mod classifier;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod corpus;
|
pub mod corpus;
|
||||||
pub mod cr;
|
pub mod cr;
|
||||||
pub mod governance_env;
|
pub mod governance_env;
|
||||||
|
#[cfg(feature = "lmdb")]
|
||||||
|
pub mod lmdb_enrichment;
|
||||||
pub mod register;
|
pub mod register;
|
||||||
pub mod registry;
|
pub mod registry;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
|
pub mod shell_tier;
|
||||||
|
|
||||||
pub use ac::{AcValidationError, AuthorizationContext};
|
pub use ac::{AcValidationError, AuthorizationContext};
|
||||||
pub use classifier::{classify_command, CommandClass, FREE_COMMANDS};
|
pub use classifier::{classify_command, CommandClass, FREE_COMMANDS};
|
||||||
|
|
@ -17,6 +21,7 @@ pub use corpus::{corpus_check, corpus_check_with_base, CorpusCheckResult, DEFAUL
|
||||||
pub use cr::{post_cr, CrResult};
|
pub use cr::{post_cr, CrResult};
|
||||||
pub use registry::ConsumedRegistry;
|
pub use registry::ConsumedRegistry;
|
||||||
pub use session::SessionState;
|
pub use session::SessionState;
|
||||||
|
pub use shell_tier::ShellTier;
|
||||||
|
|
||||||
/// Compute SHA-256 hash with "sha256:" prefix.
|
/// Compute SHA-256 hash with "sha256:" prefix.
|
||||||
pub fn sha256_hash(data: &[u8]) -> String {
|
pub fn sha256_hash(data: &[u8]) -> String {
|
||||||
|
|
|
||||||
95
libgsh/src/lmdb_enrichment.rs
Normal file
95
libgsh/src/lmdb_enrichment.rs
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
//! Optional LMDB session enrichment (behind `lmdb` feature flag).
|
||||||
|
//!
|
||||||
|
//! Reads earned credentials, identity class, shell tier, and capability
|
||||||
|
//! bounding set from the substrate identity store for the session's DID.
|
||||||
|
|
||||||
|
use substrate_identity_store::{CredentialRef, IdentityStore};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// Enrichment data retrieved from the LMDB identity store.
|
||||||
|
pub struct LmdbEnrichment {
|
||||||
|
pub credentials: Vec<CredentialRef>,
|
||||||
|
pub identity_class: String,
|
||||||
|
pub shell_tier: u8,
|
||||||
|
pub cap_bounding: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read identity data from LMDB for the given DID.
|
||||||
|
/// Returns `None` if LMDB is unavailable or the DID is not found.
|
||||||
|
pub fn enrich_from_lmdb(did: &str) -> Option<LmdbEnrichment> {
|
||||||
|
let path = std::env::var("SUBSTRATE_IDENTITY_PATH")
|
||||||
|
.unwrap_or_else(|_| substrate_identity_store::DEFAULT_IDENTITY_PATH.to_string());
|
||||||
|
let store = IdentityStore::open(Path::new(&path)).ok()?;
|
||||||
|
let entry = store.get_by_did(did).ok()??;
|
||||||
|
Some(LmdbEnrichment {
|
||||||
|
credentials: entry.credentials,
|
||||||
|
identity_class: entry.identity_class,
|
||||||
|
shell_tier: entry.shell_tier,
|
||||||
|
cap_bounding: entry.cap_bounding,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enrich_returns_none_when_lmdb_unavailable() {
|
||||||
|
std::env::set_var("SUBSTRATE_IDENTITY_PATH", "/nonexistent/lmdb");
|
||||||
|
let result = enrich_from_lmdb("did:web:example.com:user:test");
|
||||||
|
assert!(result.is_none());
|
||||||
|
std::env::remove_var("SUBSTRATE_IDENTITY_PATH");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enrich_returns_none_for_unknown_did() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let _store = IdentityStore::open(dir.path()).unwrap();
|
||||||
|
std::env::set_var("SUBSTRATE_IDENTITY_PATH", dir.path().to_str().unwrap());
|
||||||
|
let result = enrich_from_lmdb("did:web:example.com:user:nobody");
|
||||||
|
assert!(result.is_none());
|
||||||
|
std::env::remove_var("SUBSTRATE_IDENTITY_PATH");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enrich_returns_data_for_known_did() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let store = IdentityStore::open(dir.path()).unwrap();
|
||||||
|
let entry = substrate_identity_store::IdentityEntry {
|
||||||
|
did: "did:web:example.com:user:tking".into(),
|
||||||
|
uid: 60001,
|
||||||
|
gid: 60001,
|
||||||
|
login_name: "tking".into(),
|
||||||
|
home_directory: "/home/tking".into(),
|
||||||
|
login_shell: "/usr/bin/gsh".into(),
|
||||||
|
gecos: String::new(),
|
||||||
|
supplementary_groups: vec![],
|
||||||
|
shell_tier: 2,
|
||||||
|
cap_bounding: 0x0404,
|
||||||
|
cap_ambient: 0,
|
||||||
|
namespace_flags: 0,
|
||||||
|
identity_class: "Authority".into(),
|
||||||
|
credentials: vec![substrate_identity_store::CredentialRef {
|
||||||
|
vc_hash: "sha256:abc".into(),
|
||||||
|
capability: "CAP_DEPLOY".into(),
|
||||||
|
issuer_did: "did:web:example.com:authority:root".into(),
|
||||||
|
earned_at: "2026-05-28".into(),
|
||||||
|
ceremony_id: "cer-001".into(),
|
||||||
|
corpus_evidence_root: "sha256:def".into(),
|
||||||
|
}],
|
||||||
|
forge_sources: vec![],
|
||||||
|
generation: 1,
|
||||||
|
last_updated_ns: 0,
|
||||||
|
source_resource_version: "rv-1".into(),
|
||||||
|
};
|
||||||
|
store.put_identity(&entry).unwrap();
|
||||||
|
|
||||||
|
std::env::set_var("SUBSTRATE_IDENTITY_PATH", dir.path().to_str().unwrap());
|
||||||
|
let result = enrich_from_lmdb("did:web:example.com:user:tking").unwrap();
|
||||||
|
assert_eq!(result.identity_class, "Authority");
|
||||||
|
assert_eq!(result.credentials.len(), 1);
|
||||||
|
assert_eq!(result.credentials[0].capability, "CAP_DEPLOY");
|
||||||
|
assert_eq!(result.shell_tier, 2);
|
||||||
|
std::env::remove_var("SUBSTRATE_IDENTITY_PATH");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -79,6 +79,7 @@ pub async fn register_app_shell(
|
||||||
}
|
}
|
||||||
FabricResponse::Denied { reason } => Err(RegisterError::Denied { reason }),
|
FabricResponse::Denied { reason } => Err(RegisterError::Denied { reason }),
|
||||||
FabricResponse::Error(msg) => Err(RegisterError::FabricError(msg)),
|
FabricResponse::Error(msg) => Err(RegisterError::FabricError(msg)),
|
||||||
|
_ => Err(RegisterError::FabricError("unexpected response".into())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
//! Session state tracking for human mode.
|
//! Session state tracking for human mode.
|
||||||
|
|
||||||
use crate::ac::AuthorizationContext;
|
use crate::ac::AuthorizationContext;
|
||||||
|
use crate::shell_tier::ShellTier;
|
||||||
|
|
||||||
/// Tracks session state across the REPL loop.
|
/// Tracks session state across the REPL loop.
|
||||||
pub struct SessionState {
|
pub struct SessionState {
|
||||||
|
|
@ -22,8 +23,15 @@ pub struct SessionState {
|
||||||
/// `GSH_*` env vars via [`SessionState::apply_governance_env`].
|
/// `GSH_*` env vars via [`SessionState::apply_governance_env`].
|
||||||
pub accord_hash: Option<String>,
|
pub accord_hash: Option<String>,
|
||||||
pub shell_class: Option<String>,
|
pub shell_class: Option<String>,
|
||||||
|
pub shell_tier: ShellTier,
|
||||||
pub capability_set: Option<u32>,
|
pub capability_set: Option<u32>,
|
||||||
pub posture_level: Option<u8>,
|
pub posture_level: Option<u8>,
|
||||||
|
/// Earned credentials from LMDB identity store, formatted as
|
||||||
|
/// "capability (issuer_did)". Populated only when the `lmdb`
|
||||||
|
/// feature is enabled and the DID is found in the store.
|
||||||
|
pub earned_credentials: Vec<String>,
|
||||||
|
/// Identity class from LMDB (e.g. "Authority", "Operator").
|
||||||
|
pub identity_class: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SessionState {
|
impl SessionState {
|
||||||
|
|
@ -57,7 +65,7 @@ impl SessionState {
|
||||||
.ok().and_then(|v| v.parse().ok()).unwrap_or(5);
|
.ok().and_then(|v| v.parse().ok()).unwrap_or(5);
|
||||||
let defcon_reason = std::env::var("BASCULE_DEFCON_REASON").ok();
|
let defcon_reason = std::env::var("BASCULE_DEFCON_REASON").ok();
|
||||||
|
|
||||||
Self {
|
let mut session = Self {
|
||||||
ac_id: ac.context_id.clone(),
|
ac_id: ac.context_id.clone(),
|
||||||
corpus_cid: corpus_cid.to_string(),
|
corpus_cid: corpus_cid.to_string(),
|
||||||
principal,
|
principal,
|
||||||
|
|
@ -73,11 +81,26 @@ impl SessionState {
|
||||||
denied_count: 0,
|
denied_count: 0,
|
||||||
accord_hash: ac.accord_hash.clone(),
|
accord_hash: ac.accord_hash.clone(),
|
||||||
shell_class: ac.shell_class.clone(),
|
shell_class: ac.shell_class.clone(),
|
||||||
|
shell_tier: resolve_shell_tier(ac.shell_tier, ac.shell_class.as_deref()),
|
||||||
capability_set: ac.capability_set,
|
capability_set: ac.capability_set,
|
||||||
posture_level: ac.posture_level,
|
posture_level: ac.posture_level,
|
||||||
|
earned_credentials: Vec::new(),
|
||||||
|
identity_class: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(feature = "lmdb")]
|
||||||
|
{
|
||||||
|
if let Some(enrichment) = crate::lmdb_enrichment::enrich_from_lmdb(&session.principal) {
|
||||||
|
session.earned_credentials = enrichment.credentials.iter()
|
||||||
|
.map(|c| format!("{} ({})", c.capability, c.issuer_did))
|
||||||
|
.collect();
|
||||||
|
session.identity_class = Some(enrichment.identity_class);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
session
|
||||||
|
}
|
||||||
|
|
||||||
/// Create a minimal session for ungoverned mode.
|
/// Create a minimal session for ungoverned mode.
|
||||||
pub fn ungoverned(corpus_cid: &str) -> Self {
|
pub fn ungoverned(corpus_cid: &str) -> Self {
|
||||||
// Principal resolution: GSH_DID → BASCULE_USER_DID → whoami()
|
// Principal resolution: GSH_DID → BASCULE_USER_DID → whoami()
|
||||||
|
|
@ -93,7 +116,7 @@ impl SessionState {
|
||||||
.ok().and_then(|v| v.parse().ok()).unwrap_or(5);
|
.ok().and_then(|v| v.parse().ok()).unwrap_or(5);
|
||||||
let defcon_reason = std::env::var("BASCULE_DEFCON_REASON").ok();
|
let defcon_reason = std::env::var("BASCULE_DEFCON_REASON").ok();
|
||||||
|
|
||||||
Self {
|
let mut session = Self {
|
||||||
ac_id: "ungoverned".to_string(),
|
ac_id: "ungoverned".to_string(),
|
||||||
corpus_cid: corpus_cid.to_string(),
|
corpus_cid: corpus_cid.to_string(),
|
||||||
principal,
|
principal,
|
||||||
|
|
@ -109,11 +132,26 @@ impl SessionState {
|
||||||
denied_count: 0,
|
denied_count: 0,
|
||||||
accord_hash: None,
|
accord_hash: None,
|
||||||
shell_class: None,
|
shell_class: None,
|
||||||
|
shell_tier: resolve_shell_tier(None, None),
|
||||||
capability_set: None,
|
capability_set: None,
|
||||||
posture_level: None,
|
posture_level: None,
|
||||||
|
earned_credentials: Vec::new(),
|
||||||
|
identity_class: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(feature = "lmdb")]
|
||||||
|
{
|
||||||
|
if let Some(enrichment) = crate::lmdb_enrichment::enrich_from_lmdb(&session.principal) {
|
||||||
|
session.earned_credentials = enrichment.credentials.iter()
|
||||||
|
.map(|c| format!("{} ({})", c.capability, c.issuer_did))
|
||||||
|
.collect();
|
||||||
|
session.identity_class = Some(enrichment.identity_class);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
session
|
||||||
|
}
|
||||||
|
|
||||||
/// Apply the `GSH_*` env-var contract to a child `Command`.
|
/// Apply the `GSH_*` env-var contract to a child `Command`.
|
||||||
///
|
///
|
||||||
/// Always exports `GSH_DID` (the resolved principal). Governance
|
/// Always exports `GSH_DID` (the resolved principal). Governance
|
||||||
|
|
@ -127,8 +165,10 @@ impl SessionState {
|
||||||
Some(&self.principal),
|
Some(&self.principal),
|
||||||
self.accord_hash.as_deref(),
|
self.accord_hash.as_deref(),
|
||||||
self.shell_class.as_deref(),
|
self.shell_class.as_deref(),
|
||||||
|
Some(self.shell_tier),
|
||||||
self.posture_level,
|
self.posture_level,
|
||||||
self.capability_set,
|
self.capability_set,
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -144,6 +184,23 @@ impl SessionState {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn resolve_shell_tier(explicit: Option<u8>, shell_class: Option<&str>) -> ShellTier {
|
||||||
|
if let Some(n) = explicit {
|
||||||
|
if let Some(tier) = ShellTier::from_numeric(n) {
|
||||||
|
return tier;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(env_tier) = std::env::var("GSH_SHELL_TIER").ok() {
|
||||||
|
if let Ok(tier) = env_tier.parse::<ShellTier>() {
|
||||||
|
return tier;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(class) = shell_class {
|
||||||
|
return ShellTier::from_shell_class(class);
|
||||||
|
}
|
||||||
|
ShellTier::default()
|
||||||
|
}
|
||||||
|
|
||||||
fn whoami() -> String {
|
fn whoami() -> String {
|
||||||
std::env::var("USER")
|
std::env::var("USER")
|
||||||
.or_else(|_| std::env::var("USERNAME"))
|
.or_else(|_| std::env::var("USERNAME"))
|
||||||
|
|
|
||||||
263
libgsh/src/shell_tier.rs
Normal file
263
libgsh/src/shell_tier.rs
Normal file
|
|
@ -0,0 +1,263 @@
|
||||||
|
// Copyright 2026 Guildhouse Dev
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
//! Typed shell hierarchy (T0–T6).
|
||||||
|
//!
|
||||||
|
//! Local definition matching org-ops-core's `ShellTier`. Kept here to
|
||||||
|
//! avoid pulling the full org-ops-core dependency tree into gsh. If a
|
||||||
|
//! shared governance-types crate is created, consolidate there.
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! T0 Genesis → T1 Infrastructure → ┬─ T2 Operator → ┬─ T3 Agent
|
||||||
|
//! │ └─ T4 Task
|
||||||
|
//! ├─ T5 Forensic
|
||||||
|
//! └─ T6 Recovery
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use std::fmt;
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
pub enum ShellTier {
|
||||||
|
T0Genesis,
|
||||||
|
T1Infrastructure,
|
||||||
|
T2Operator,
|
||||||
|
T3Agent,
|
||||||
|
T4Task,
|
||||||
|
T5Forensic,
|
||||||
|
T6Recovery,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ShellTier {
|
||||||
|
pub fn numeric_level(&self) -> u8 {
|
||||||
|
match self {
|
||||||
|
Self::T0Genesis => 0,
|
||||||
|
Self::T1Infrastructure => 1,
|
||||||
|
Self::T2Operator => 2,
|
||||||
|
Self::T3Agent => 3,
|
||||||
|
Self::T4Task => 4,
|
||||||
|
Self::T5Forensic => 5,
|
||||||
|
Self::T6Recovery => 6,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_numeric(n: u8) -> Option<Self> {
|
||||||
|
match n {
|
||||||
|
0 => Some(Self::T0Genesis),
|
||||||
|
1 => Some(Self::T1Infrastructure),
|
||||||
|
2 => Some(Self::T2Operator),
|
||||||
|
3 => Some(Self::T3Agent),
|
||||||
|
4 => Some(Self::T4Task),
|
||||||
|
5 => Some(Self::T5Forensic),
|
||||||
|
6 => Some(Self::T6Recovery),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn label(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::T0Genesis => "Genesis",
|
||||||
|
Self::T1Infrastructure => "Infrastructure",
|
||||||
|
Self::T2Operator => "Operator",
|
||||||
|
Self::T3Agent => "Agent",
|
||||||
|
Self::T4Task => "Task",
|
||||||
|
Self::T5Forensic => "Forensic",
|
||||||
|
Self::T6Recovery => "Recovery",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parent(&self) -> Option<ShellTier> {
|
||||||
|
match self {
|
||||||
|
Self::T0Genesis => None,
|
||||||
|
Self::T1Infrastructure => Some(Self::T0Genesis),
|
||||||
|
Self::T2Operator => Some(Self::T1Infrastructure),
|
||||||
|
Self::T3Agent => Some(Self::T2Operator),
|
||||||
|
Self::T4Task => Some(Self::T2Operator),
|
||||||
|
Self::T5Forensic => Some(Self::T1Infrastructure),
|
||||||
|
Self::T6Recovery => Some(Self::T1Infrastructure),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn satisfies(&self, required: ShellTier) -> bool {
|
||||||
|
if *self == required {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let mut current = required;
|
||||||
|
while let Some(p) = current.parent() {
|
||||||
|
if p == *self {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
current = p;
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map a legacy `GSH_SHELL_CLASS` string to a tier.
|
||||||
|
pub fn from_shell_class(class: &str) -> Self {
|
||||||
|
match class {
|
||||||
|
"System" => Self::T1Infrastructure,
|
||||||
|
"Application" => Self::T2Operator,
|
||||||
|
_ => Self::T2Operator,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the legacy shell class string for backward compatibility.
|
||||||
|
pub fn to_shell_class(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::T0Genesis | Self::T1Infrastructure => "System",
|
||||||
|
_ => "Application",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ShellTier {
|
||||||
|
fn default() -> Self {
|
||||||
|
ShellTier::T2Operator
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for ShellTier {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "T{} ({})", self.numeric_level(), self.label())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for ShellTier {
|
||||||
|
type Err = String;
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
if let Ok(n) = s.parse::<u8>() {
|
||||||
|
return Self::from_numeric(n).ok_or_else(|| format!("unknown shell tier: {s}"));
|
||||||
|
}
|
||||||
|
match s.to_lowercase().trim() {
|
||||||
|
"t0" | "genesis" | "t0genesis" => Ok(Self::T0Genesis),
|
||||||
|
"t1" | "infrastructure" | "t1infrastructure" => Ok(Self::T1Infrastructure),
|
||||||
|
"t2" | "operator" | "t2operator" => Ok(Self::T2Operator),
|
||||||
|
"t3" | "agent" | "t3agent" => Ok(Self::T3Agent),
|
||||||
|
"t4" | "task" | "t4task" => Ok(Self::T4Task),
|
||||||
|
"t5" | "forensic" | "t5forensic" => Ok(Self::T5Forensic),
|
||||||
|
"t6" | "recovery" | "t6recovery" => Ok(Self::T6Recovery),
|
||||||
|
other => Err(format!("unknown shell tier: {other}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_is_t2() {
|
||||||
|
assert_eq!(ShellTier::default(), ShellTier::T2Operator);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn display_format() {
|
||||||
|
assert_eq!(format!("{}", ShellTier::T0Genesis), "T0 (Genesis)");
|
||||||
|
assert_eq!(format!("{}", ShellTier::T2Operator), "T2 (Operator)");
|
||||||
|
assert_eq!(format!("{}", ShellTier::T5Forensic), "T5 (Forensic)");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_canonical_short() {
|
||||||
|
assert_eq!("t0".parse::<ShellTier>().unwrap(), ShellTier::T0Genesis);
|
||||||
|
assert_eq!("T2".parse::<ShellTier>().unwrap(), ShellTier::T2Operator);
|
||||||
|
assert_eq!("T6".parse::<ShellTier>().unwrap(), ShellTier::T6Recovery);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_numeric_string() {
|
||||||
|
assert_eq!("0".parse::<ShellTier>().unwrap(), ShellTier::T0Genesis);
|
||||||
|
assert_eq!("3".parse::<ShellTier>().unwrap(), ShellTier::T3Agent);
|
||||||
|
assert_eq!("6".parse::<ShellTier>().unwrap(), ShellTier::T6Recovery);
|
||||||
|
assert!("7".parse::<ShellTier>().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_label() {
|
||||||
|
assert_eq!("genesis".parse::<ShellTier>().unwrap(), ShellTier::T0Genesis);
|
||||||
|
assert_eq!("operator".parse::<ShellTier>().unwrap(), ShellTier::T2Operator);
|
||||||
|
assert_eq!("forensic".parse::<ShellTier>().unwrap(), ShellTier::T5Forensic);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_invalid() {
|
||||||
|
assert!("t7".parse::<ShellTier>().is_err());
|
||||||
|
assert!("bogus".parse::<ShellTier>().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parent_tree() {
|
||||||
|
assert_eq!(ShellTier::T0Genesis.parent(), None);
|
||||||
|
assert_eq!(ShellTier::T1Infrastructure.parent(), Some(ShellTier::T0Genesis));
|
||||||
|
assert_eq!(ShellTier::T2Operator.parent(), Some(ShellTier::T1Infrastructure));
|
||||||
|
assert_eq!(ShellTier::T3Agent.parent(), Some(ShellTier::T2Operator));
|
||||||
|
assert_eq!(ShellTier::T4Task.parent(), Some(ShellTier::T2Operator));
|
||||||
|
assert_eq!(ShellTier::T5Forensic.parent(), Some(ShellTier::T1Infrastructure));
|
||||||
|
assert_eq!(ShellTier::T6Recovery.parent(), Some(ShellTier::T1Infrastructure));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn satisfies_self() {
|
||||||
|
for tier in [
|
||||||
|
ShellTier::T0Genesis, ShellTier::T1Infrastructure, ShellTier::T2Operator,
|
||||||
|
ShellTier::T3Agent, ShellTier::T4Task, ShellTier::T5Forensic, ShellTier::T6Recovery,
|
||||||
|
] {
|
||||||
|
assert!(tier.satisfies(tier));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn t0_satisfies_everything() {
|
||||||
|
assert!(ShellTier::T0Genesis.satisfies(ShellTier::T4Task));
|
||||||
|
assert!(ShellTier::T0Genesis.satisfies(ShellTier::T6Recovery));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn t2_satisfies_t3_t4_not_t5_t6() {
|
||||||
|
assert!(ShellTier::T2Operator.satisfies(ShellTier::T3Agent));
|
||||||
|
assert!(ShellTier::T2Operator.satisfies(ShellTier::T4Task));
|
||||||
|
assert!(!ShellTier::T2Operator.satisfies(ShellTier::T5Forensic));
|
||||||
|
assert!(!ShellTier::T2Operator.satisfies(ShellTier::T6Recovery));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn leaf_does_not_satisfy_sibling() {
|
||||||
|
assert!(!ShellTier::T3Agent.satisfies(ShellTier::T4Task));
|
||||||
|
assert!(!ShellTier::T5Forensic.satisfies(ShellTier::T6Recovery));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_shell_class_mapping() {
|
||||||
|
assert_eq!(ShellTier::from_shell_class("Application"), ShellTier::T2Operator);
|
||||||
|
assert_eq!(ShellTier::from_shell_class("System"), ShellTier::T1Infrastructure);
|
||||||
|
assert_eq!(ShellTier::from_shell_class("Unknown"), ShellTier::T2Operator);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn to_shell_class_backward_compat() {
|
||||||
|
assert_eq!(ShellTier::T0Genesis.to_shell_class(), "System");
|
||||||
|
assert_eq!(ShellTier::T1Infrastructure.to_shell_class(), "System");
|
||||||
|
assert_eq!(ShellTier::T2Operator.to_shell_class(), "Application");
|
||||||
|
assert_eq!(ShellTier::T3Agent.to_shell_class(), "Application");
|
||||||
|
assert_eq!(ShellTier::T5Forensic.to_shell_class(), "Application");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn numeric_levels() {
|
||||||
|
assert_eq!(ShellTier::T0Genesis.numeric_level(), 0);
|
||||||
|
assert_eq!(ShellTier::T3Agent.numeric_level(), 3);
|
||||||
|
assert_eq!(ShellTier::T6Recovery.numeric_level(), 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_numeric_round_trip() {
|
||||||
|
for n in 0..=6u8 {
|
||||||
|
let tier = ShellTier::from_numeric(n).unwrap();
|
||||||
|
assert_eq!(tier.numeric_level(), n);
|
||||||
|
}
|
||||||
|
assert!(ShellTier::from_numeric(7).is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue