Compare commits
No commits in common. "a97e9569d6218bac15bf48cbdd6c15126aeb10911cc91af9c183ca0dd8488d22" and "7c84854222d9b214365ac9b8447b0cd5a8f45fe198b27f7792dcf7177ab61e77" have entirely different histories.
a97e9569d6
...
7c84854222
17 changed files with 919 additions and 1344 deletions
812
Cargo.lock
generated
812
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -11,6 +11,7 @@ repository = "https://git.guildhouse.dev/guildhouse/gsh"
|
|||
[workspace.dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
reqwest = { version = "0.12", features = ["blocking", "json"] }
|
||||
thiserror = "2"
|
||||
anyhow = "1"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
|
|
@ -24,6 +25,4 @@ colored = "2"
|
|||
atty = "0.2"
|
||||
tracing = "0.1"
|
||||
substrate-ipc = { path = "../substrate/crates/substrate-ipc" }
|
||||
substrate-identity-store = { path = "../substrate/crates/substrate-identity-store" }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
libc = "0.2"
|
||||
|
|
|
|||
|
|
@ -6,15 +6,6 @@
|
|||
**Spec:** GCAP-SPEC-SHELLBOUND-BROKER-0001 (Layer 3)
|
||||
**Language:** Rust
|
||||
**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,7 +21,3 @@ colored = { workspace = true }
|
|||
atty = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
substrate-ipc = { workspace = true }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
lmdb = ["libgsh/lmdb"]
|
||||
|
|
|
|||
|
|
@ -1,14 +1,16 @@
|
|||
//! gsh human mode — interactive governed shell with reedline.
|
||||
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use colored::Colorize;
|
||||
use reedline::{DefaultPrompt, DefaultPromptSegment, Reedline, Signal};
|
||||
|
||||
use libgsh::classifier::{classify_command, CommandClass};
|
||||
use libgsh::cr::post_cr;
|
||||
use libgsh::cr::{build_client, post_cr};
|
||||
use libgsh::session::SessionState;
|
||||
|
||||
|
||||
/// Corpus base directory. Configurable via GSH_CORPUS_DIR env.
|
||||
fn corpus_base() -> PathBuf {
|
||||
std::env::var("GSH_CORPUS_DIR")
|
||||
|
|
@ -19,16 +21,22 @@ fn corpus_base() -> PathBuf {
|
|||
/// Run the interactive governed shell.
|
||||
pub fn run_human_mode(
|
||||
session: &mut SessionState,
|
||||
fabric_socket: &str,
|
||||
rt: &tokio::runtime::Runtime,
|
||||
broker_url: &Option<String>,
|
||||
token: &Option<String>,
|
||||
) -> i32 {
|
||||
// Print banner:
|
||||
print_banner(session);
|
||||
|
||||
// Post SESSION_STARTED CR via fabric:
|
||||
let _ = rt.block_on(post_cr(Some(fabric_socket), &session.ac_id, "completed"));
|
||||
// Post SESSION_STARTED CR if broker available:
|
||||
if let Some(ref base) = broker_url {
|
||||
if let Ok(client) = build_client(token) {
|
||||
let _ = post_cr(&client, base, &session.ac_id, "completed");
|
||||
}
|
||||
}
|
||||
|
||||
let corpus_dir = corpus_base();
|
||||
|
||||
// Reedline REPL:
|
||||
let mut editor = Reedline::create();
|
||||
let prompt = build_prompt(session);
|
||||
|
||||
|
|
@ -43,6 +51,7 @@ pub fn run_human_mode(
|
|||
break;
|
||||
}
|
||||
|
||||
// Classify command:
|
||||
match classify_command(line, &session.corpus_cid, &corpus_dir) {
|
||||
CommandClass::Free => {
|
||||
execute_passthrough(line, session);
|
||||
|
|
@ -53,12 +62,17 @@ pub fn run_human_mode(
|
|||
execute_governed(line, &corpus_binary, session);
|
||||
session.governed_count += 1;
|
||||
|
||||
let outcome = if exit_code == 0 { "completed" } else { "failed" };
|
||||
let _ = rt.block_on(post_cr(
|
||||
Some(fabric_socket),
|
||||
&session.ac_id,
|
||||
outcome,
|
||||
));
|
||||
// Post lightweight command CR:
|
||||
if let Some(ref base) = broker_url {
|
||||
if let Ok(client) = build_client(token) {
|
||||
let outcome = if exit_code == 0 {
|
||||
"completed"
|
||||
} else {
|
||||
"failed"
|
||||
};
|
||||
let _ = post_cr(&client, base, &session.ac_id, outcome);
|
||||
}
|
||||
}
|
||||
}
|
||||
CommandClass::Ungoverned => {
|
||||
let cmd_name = line.split_whitespace().next().unwrap_or("?");
|
||||
|
|
@ -79,6 +93,7 @@ pub fn run_human_mode(
|
|||
}
|
||||
}
|
||||
|
||||
// AC expiry warning:
|
||||
let mins = session.minutes_remaining();
|
||||
if mins < 10 && mins > 0 {
|
||||
eprintln!(
|
||||
|
|
@ -100,10 +115,15 @@ pub fn run_human_mode(
|
|||
}
|
||||
}
|
||||
|
||||
// Session teardown:
|
||||
print_summary(session);
|
||||
|
||||
// Post SESSION_ENDED CR via fabric:
|
||||
let _ = rt.block_on(post_cr(Some(fabric_socket), &session.ac_id, "session_end"));
|
||||
// Post SESSION_ENDED CR:
|
||||
if let Some(ref base) = broker_url {
|
||||
if let Ok(client) = build_client(token) {
|
||||
let _ = post_cr(&client, base, &session.ac_id, "session_end");
|
||||
}
|
||||
}
|
||||
|
||||
0
|
||||
}
|
||||
|
|
@ -143,15 +163,7 @@ fn print_banner(session: &SessionState) {
|
|||
},
|
||||
"║".bright_blue());
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
// DEFCON line — only shown when not peacetime
|
||||
if session.defcon_level < 5 {
|
||||
let defcon_label = match session.defcon_level {
|
||||
1 => "LOCKDOWN".red().to_string(),
|
||||
|
|
@ -200,6 +212,7 @@ fn print_summary(session: &SessionState) {
|
|||
}
|
||||
|
||||
fn build_prompt(session: &SessionState) -> DefaultPrompt {
|
||||
// DEFCON overrides the risk indicator when elevated
|
||||
let risk_indicator = if session.defcon_level <= 2 {
|
||||
"[DEFCON]"
|
||||
} else if session.defcon_level == 3 {
|
||||
|
|
@ -214,10 +227,9 @@ fn build_prompt(session: &SessionState) -> DefaultPrompt {
|
|||
};
|
||||
|
||||
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(
|
||||
DefaultPromptSegment::Basic(format!("{} {}:{}@gsh", risk_indicator, tier_tag, short_name)),
|
||||
DefaultPromptSegment::Basic(format!("{} {}@gsh", risk_indicator, short_name)),
|
||||
DefaultPromptSegment::Empty,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ use serde::Serialize;
|
|||
use std::process;
|
||||
use uuid::Uuid;
|
||||
|
||||
use libgsh::cr::{post_cr, request_ac_inline};
|
||||
use libgsh::cr::{build_client, post_cr, request_ac_inline};
|
||||
use libgsh::registry::ConsumedRegistry;
|
||||
use libgsh::session::SessionState;
|
||||
use libgsh::{corpus_check, sha256_hash};
|
||||
|
|
@ -39,8 +39,8 @@ struct Args {
|
|||
#[arg(long, global = true)]
|
||||
ungoverned: bool,
|
||||
|
||||
#[arg(long, global = true, default_value = "/run/substrate/fabric.sock")]
|
||||
fabric_socket: String,
|
||||
#[arg(long, global = true)]
|
||||
broker_url: Option<String>,
|
||||
|
||||
#[arg(long, global = true)]
|
||||
agent_did: Option<String>,
|
||||
|
|
@ -92,10 +92,6 @@ struct GshOutput {
|
|||
corpus_cid: String,
|
||||
}
|
||||
|
||||
fn fabric_sock(args: &Args) -> String {
|
||||
std::env::var("GSH_FABRIC_SOCKET").unwrap_or_else(|_| args.fabric_socket.clone())
|
||||
}
|
||||
|
||||
// ── Main ─────────────────────────────────────────────────────
|
||||
|
||||
fn main() {
|
||||
|
|
@ -118,8 +114,7 @@ fn main() {
|
|||
|
||||
fn run(args: Args) -> Result<i32> {
|
||||
let corpus = std::env::var("GSAP_CORPUS_CID").unwrap_or_else(|_| "sha256:ungoverned".into());
|
||||
let rt = tokio::runtime::Runtime::new().context("creating tokio runtime")?;
|
||||
let sock = fabric_sock(&args);
|
||||
let token = std::env::var("GSAP_TOKEN").ok();
|
||||
|
||||
// ── Ungoverned machine mode ────────────────────────────────
|
||||
if args.ungoverned && args.exec.is_some() {
|
||||
|
|
@ -145,6 +140,7 @@ fn run(args: Args) -> Result<i32> {
|
|||
|
||||
// ── Subcommands ─────────────────────────────────────────
|
||||
if let Some(cmd) = &args.command {
|
||||
// Register is handled separately — it doesn't need a broker.
|
||||
if let Cmd::Register {
|
||||
service_name,
|
||||
fabric_socket,
|
||||
|
|
@ -154,11 +150,15 @@ fn run(args: Args) -> Result<i32> {
|
|||
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 {
|
||||
Cmd::SessionStart { scope } => {
|
||||
let hash = sha256_hash(format!("session:{}", scope).as_bytes());
|
||||
eprintln!("gsh: starting session (scope: {})", scope);
|
||||
let ac_id = rt.block_on(request_ac_inline(Some(&sock), scope, &hash, &corpus))
|
||||
let ac_id = request_ac_inline(&client, &base, scope, &hash, &corpus)
|
||||
.map_err(|e| anyhow::anyhow!(e))?;
|
||||
eprintln!("gsh: session AC — {}", &ac_id[..8.min(ac_id.len())]);
|
||||
println!("export GSAP_SESSION_AC=\"{}\";", ac_id);
|
||||
|
|
@ -169,7 +169,7 @@ fn run(args: Args) -> Result<i32> {
|
|||
Cmd::SessionEnd => {
|
||||
let ac_id = std::env::var("GSAP_SESSION_AC")
|
||||
.context("No session active (GSAP_SESSION_AC not set)")?;
|
||||
let cr = rt.block_on(post_cr(Some(&sock), &ac_id, "completed"));
|
||||
let cr = post_cr(&client, &base, &ac_id, "completed");
|
||||
eprintln!("gsh: session closed");
|
||||
if !cr.chronicle_cid.is_empty() {
|
||||
eprintln!("gsh: session CID {}", &cr.chronicle_cid[..40.min(cr.chronicle_cid.len())]);
|
||||
|
|
@ -194,12 +194,16 @@ fn run(args: Args) -> Result<i32> {
|
|||
}
|
||||
|
||||
// ── Mode detection ─────────────────────────────────────────
|
||||
// No --exec: human mode (if TTY) or error (if piped)
|
||||
if args.exec.is_none() {
|
||||
let is_tty = atty::is(atty::Stream::Stdin);
|
||||
if !is_tty {
|
||||
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 mut session = if args.ungoverned {
|
||||
|
|
@ -209,31 +213,36 @@ fn run(args: Args) -> Result<i32> {
|
|||
let ac = libgsh::ac::validate_ac(&ac_json, &corpus, &mut registry)
|
||||
.map_err(|e| anyhow::anyhow!("AC validation failed (exit {}): {}", e.exit_code(), e))?;
|
||||
SessionState::from_ac(&ac, &corpus)
|
||||
} else {
|
||||
} else if let Some(ref base) = broker {
|
||||
// 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");
|
||||
match rt.block_on(request_ac_inline(Some(&sock), "shell:session", &hash, &corpus)) {
|
||||
Ok(ac_id) => {
|
||||
let ac_id = request_ac_inline(&client, base, "shell:session", &hash, &corpus)
|
||||
.map_err(|e| anyhow::anyhow!(e))?;
|
||||
let mut s = SessionState::ungoverned(&corpus);
|
||||
s.ac_id = ac_id;
|
||||
s.risk_level = "standard".to_string();
|
||||
s
|
||||
}
|
||||
Err(_) => {
|
||||
eprintln!("gsh: fabric unreachable — running ungoverned");
|
||||
} else {
|
||||
// No AC and no broker — run ungoverned with warning
|
||||
eprintln!("gsh: no GSAP_AC or GSAP_BROKER_URL — running ungoverned");
|
||||
SessionState::ungoverned(&corpus)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return Ok(human::run_human_mode(&mut session, &sock, &rt));
|
||||
return Ok(human::run_human_mode(&mut session, &broker, &token));
|
||||
}
|
||||
|
||||
let exec = args.exec.as_ref().unwrap();
|
||||
let exec = args.exec.as_ref().unwrap(); // safe: checked above
|
||||
let run_id = Uuid::new_v4().to_string();
|
||||
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());
|
||||
|
||||
// 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 mut registry = ConsumedRegistry::default_location();
|
||||
let ac = libgsh::ac::validate_ac(&ac_json, &corpus, &mut registry)
|
||||
|
|
@ -246,8 +255,12 @@ fn run(args: Args) -> Result<i32> {
|
|||
} else if let Ok(session_ac) = std::env::var("GSAP_SESSION_AC") {
|
||||
(session_ac, "session".to_string(), None)
|
||||
} 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);
|
||||
let id = rt.block_on(request_ac_inline(Some(&sock), &args.operation, &command_hash, &corpus))
|
||||
let id = request_ac_inline(&client, &base, &args.operation, &command_hash, &corpus)
|
||||
.map_err(|e| anyhow::anyhow!(e))?;
|
||||
eprintln!("gsh: AC issued — {}", &id[..8.min(id.len())]);
|
||||
(id, "inline".to_string(), None)
|
||||
|
|
@ -267,7 +280,10 @@ fn run(args: Args) -> Result<i32> {
|
|||
} => {
|
||||
eprintln!(
|
||||
"gsh: command '{}' content does not match CID {} (found {}, path {}): execution denied (tamper signal)",
|
||||
command, corpus_cid, actual_cid, path.display()
|
||||
command,
|
||||
corpus_cid,
|
||||
actual_cid,
|
||||
path.display()
|
||||
);
|
||||
return Ok(3);
|
||||
}
|
||||
|
|
@ -279,7 +295,10 @@ fn run(args: Args) -> Result<i32> {
|
|||
} => {
|
||||
eprintln!(
|
||||
"gsh: command '{}' in corpus {} could not be read for hash verification ({}); execution denied fail-closed (path {})",
|
||||
command, corpus_cid, detail, path.display()
|
||||
command,
|
||||
corpus_cid,
|
||||
detail,
|
||||
path.display()
|
||||
);
|
||||
return Ok(3);
|
||||
}
|
||||
|
|
@ -301,17 +320,6 @@ fn run(args: Args) -> Result<i32> {
|
|||
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:
|
||||
let mut command = process::Command::new("sh");
|
||||
command.arg("-c").arg(exec);
|
||||
|
|
@ -325,23 +333,31 @@ fn run(args: Args) -> Result<i32> {
|
|||
|
||||
// Post CR:
|
||||
let outcome = if exit_code == 0 { "completed" } else { "failed" };
|
||||
let cr = rt.block_on(post_cr(Some(&sock), &ac_id, outcome));
|
||||
let base = args.broker_url.or_else(|| std::env::var("GSAP_BROKER_URL").ok());
|
||||
|
||||
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() {
|
||||
eprintln!("gsh: session CR not recorded (fabric session support pending)");
|
||||
eprintln!("gsh: session CR not recorded (broker session support pending)");
|
||||
}
|
||||
(cr.receipt_id, cr.chronicle_cid)
|
||||
} else {
|
||||
eprintln!("gsh: no GSAP_BROKER_URL — CR not posted");
|
||||
(String::new(), String::new())
|
||||
};
|
||||
|
||||
// Output:
|
||||
if args.json {
|
||||
println!("{}", serde_json::to_string_pretty(&GshOutput {
|
||||
exit_code, stdout: stdout_str, stderr: stderr_str,
|
||||
ac_id, ac_mode, cr_id: cr.receipt_id, chronicle_cid: cr.chronicle_cid.clone(),
|
||||
command_hash, run_id, corpus_cid: corpus,
|
||||
ac_id, ac_mode, cr_id, chronicle_cid, command_hash, run_id, corpus_cid: corpus,
|
||||
})?);
|
||||
} else {
|
||||
print!("{}", stdout_str);
|
||||
eprint!("{}", stderr_str);
|
||||
if !cr.chronicle_cid.is_empty() {
|
||||
eprintln!("gsh: CID {}", &cr.chronicle_cid[..40.min(cr.chronicle_cid.len())]);
|
||||
if !chronicle_cid.is_empty() {
|
||||
eprintln!("gsh: CID {}", &chronicle_cid[..40.min(chronicle_cid.len())]);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ description = "Governed shell library — AC validation, CR building, corpus gat
|
|||
guildhouse-did = { path = "../../guildhouse-did" }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
|
|
@ -15,14 +16,8 @@ chrono = { workspace = true }
|
|||
dirs = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
substrate-ipc = { workspace = true }
|
||||
substrate-identity-store = { workspace = true, optional = true }
|
||||
tokio = { workspace = true }
|
||||
libc = { workspace = true }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
lmdb = ["substrate-identity-store"]
|
||||
|
||||
[dev-dependencies]
|
||||
substrate-identity-store = { workspace = true }
|
||||
tempfile = "3"
|
||||
|
|
|
|||
|
|
@ -31,10 +31,6 @@ pub struct AuthorizationContext {
|
|||
/// `"Application"` | `"System"` (or future variants); threaded into `GSH_SHELL_CLASS`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
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}`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub capability_set: Option<u32>,
|
||||
|
|
|
|||
|
|
@ -93,7 +93,6 @@ pub async fn register_agent_shell(
|
|||
}
|
||||
FabricResponse::Denied { reason } => Err(AgentError::Denied { reason }),
|
||||
FabricResponse::Error(msg) => Err(AgentError::FabricError(msg)),
|
||||
_ => Err(AgentError::FabricError("unexpected response".into())),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,441 +0,0 @@
|
|||
//! 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,192 +1,203 @@
|
|||
//! Completion Receipt and Authorization Context via fabric IPC.
|
||||
//! Completion Receipt construction and posting.
|
||||
|
||||
use substrate_ipc::fabric_api::{FabricRequest, FabricResponse};
|
||||
use substrate_ipc::wire;
|
||||
use tokio::net::UnixStream;
|
||||
use reqwest::blocking::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
|
||||
const DEFAULT_FABRIC_SOCKET: &str = "/run/substrate/fabric.sock";
|
||||
#[derive(Serialize)]
|
||||
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 receipt_id: String,
|
||||
pub chronicle_cid: String,
|
||||
}
|
||||
|
||||
/// Post a Completion Receipt through the fabric socket.
|
||||
pub async fn post_cr(
|
||||
fabric_socket: Option<&str>,
|
||||
ac_id: &str,
|
||||
outcome: &str,
|
||||
) -> CrResult {
|
||||
let sock = fabric_socket.unwrap_or(DEFAULT_FABRIC_SOCKET);
|
||||
let req = FabricRequest::SubmitCompletionRecord {
|
||||
ac_id: ac_id.into(),
|
||||
outcome: outcome.into(),
|
||||
};
|
||||
/// Build an HTTP client with optional bearer token.
|
||||
pub fn build_client(token: &Option<String>) -> Result<Client, String> {
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
if let Some(tok) = token {
|
||||
headers.insert(
|
||||
"Authorization",
|
||||
format!("Bearer {}", tok)
|
||||
.parse()
|
||||
.map_err(|_| "Invalid token".to_string())?,
|
||||
);
|
||||
}
|
||||
Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.default_headers(headers)
|
||||
.build()
|
||||
.map_err(|e| format!("HTTP client failed: {}", e))
|
||||
}
|
||||
|
||||
match send_fabric_request(sock, &req).await {
|
||||
Ok(FabricResponse::CompletionRecordAccepted { cr_id, chronicle_cid }) => {
|
||||
CrResult { receipt_id: cr_id, chronicle_cid }
|
||||
/// Format a broker URL path.
|
||||
pub fn broker_url(base: &str, path: &str) -> String {
|
||||
format!(
|
||||
"{}/{}",
|
||||
base.trim_end_matches('/'),
|
||||
path.trim_start_matches('/')
|
||||
)
|
||||
}
|
||||
Ok(FabricResponse::Error(msg)) => {
|
||||
eprintln!("gsh: CR failed: {}", msg);
|
||||
CrResult { receipt_id: String::new(), chronicle_cid: String::new() }
|
||||
|
||||
/// 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(),
|
||||
completed_at: now,
|
||||
chronicle_evidence: CrEvidence {
|
||||
session_id: if session_id.is_empty() { None } else { Some(session_id.clone()) },
|
||||
events: vec![],
|
||||
merkle_root: None,
|
||||
},
|
||||
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(r) => {
|
||||
eprintln!("gsh: CR failed: {}", r.status());
|
||||
CrResult {
|
||||
receipt_id: String::new(),
|
||||
chronicle_cid: String::new(),
|
||||
}
|
||||
Ok(_) => {
|
||||
eprintln!("gsh: CR unexpected response");
|
||||
CrResult { receipt_id: String::new(), chronicle_cid: String::new() }
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("gsh: CR error: {}", e);
|
||||
CrResult { receipt_id: String::new(), chronicle_cid: String::new() }
|
||||
CrResult {
|
||||
receipt_id: String::new(),
|
||||
chronicle_cid: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Request an inline Authorization Context through the fabric socket.
|
||||
pub async fn request_ac_inline(
|
||||
fabric_socket: Option<&str>,
|
||||
/// Request an inline AC from the broker (fallback mode).
|
||||
pub fn request_ac_inline(
|
||||
client: &Client,
|
||||
base: &str,
|
||||
operation: &str,
|
||||
command_hash: &str,
|
||||
corpus_cid: &str,
|
||||
) -> Result<String, String> {
|
||||
let sock = fabric_socket.unwrap_or(DEFAULT_FABRIC_SOCKET);
|
||||
let req = FabricRequest::RequestAuthorization {
|
||||
operation: operation.into(),
|
||||
command_hash: command_hash.into(),
|
||||
corpus_cid: corpus_cid.into(),
|
||||
};
|
||||
|
||||
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(Serialize)]
|
||||
struct AcRequest {
|
||||
driver_id: String,
|
||||
playbook: String,
|
||||
corpus_entry_cid: String,
|
||||
parameters_cid: String,
|
||||
accord_template: String,
|
||||
}
|
||||
|
||||
async fn send_fabric_request(
|
||||
sock_path: &str,
|
||||
req: &FabricRequest,
|
||||
) -> Result<FabricResponse, String> {
|
||||
let stream = UnixStream::connect(sock_path)
|
||||
.await
|
||||
.map_err(|e| format!("connecting to {}: {}", sock_path, e))?;
|
||||
let (mut rd, mut wr) = tokio::io::split(stream);
|
||||
wire::send_msg(&mut wr, req)
|
||||
.await
|
||||
.map_err(|e| format!("sending request: {}", e))?;
|
||||
wire::recv_msg(&mut rd)
|
||||
.await
|
||||
.map_err(|e| format!("receiving response: {}", e))
|
||||
#[derive(Deserialize)]
|
||||
struct AcResponse {
|
||||
authorization_context: Option<AcCtx>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AcCtx {
|
||||
context_id: String,
|
||||
}
|
||||
|
||||
let resp = client
|
||||
.post(broker_url(base, "governance/authorize/"))
|
||||
.json(&AcRequest {
|
||||
driver_id: "keycloak".into(),
|
||||
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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_cr_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 {
|
||||
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());
|
||||
#[test]
|
||||
fn test_broker_url() {
|
||||
assert_eq!(
|
||||
broker_url("http://localhost:8000", "governance/complete/"),
|
||||
"http://localhost:8000/governance/complete/"
|
||||
);
|
||||
assert_eq!(
|
||||
broker_url("http://localhost:8000/", "/governance/complete/"),
|
||||
"http://localhost:8000/governance/complete/"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@
|
|||
//! | `GSH_DID` | `principal.did` (canonical string) |
|
||||
//! | `GSH_ACCORD_HASH` | `accord_hash` |
|
||||
//! | `GSH_SHELL_CLASS` | `shell_class` |
|
||||
//! | `GSH_SHELL_TIER` | `shell_tier` numeric (0–6) |
|
||||
//! | `GSH_POSTURE_LEVEL` | `posture_level` (decimal) |
|
||||
//! | `GSH_CAPABILITY_SET` | `capability_set` formatted `0x{:08x}` |
|
||||
//!
|
||||
|
|
@ -19,7 +18,6 @@
|
|||
use std::process::Command;
|
||||
|
||||
use crate::ac::AuthorizationContext;
|
||||
use crate::shell_tier::ShellTier;
|
||||
|
||||
/// Apply the `GSH_*` env-var contract to a child `Command`.
|
||||
///
|
||||
|
|
@ -32,10 +30,8 @@ pub fn apply(
|
|||
did: Option<&str>,
|
||||
accord_hash: Option<&str>,
|
||||
shell_class: Option<&str>,
|
||||
shell_tier: Option<ShellTier>,
|
||||
posture_level: Option<u8>,
|
||||
capability_set: Option<u32>,
|
||||
cap_bounding: Option<u64>,
|
||||
) {
|
||||
if let Some(d) = did {
|
||||
cmd.env("GSH_DID", d);
|
||||
|
|
@ -46,18 +42,12 @@ pub fn apply(
|
|||
if let Some(c) = shell_class {
|
||||
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 {
|
||||
cmd.env("GSH_POSTURE_LEVEL", p.to_string());
|
||||
}
|
||||
if let Some(c) = capability_set {
|
||||
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.
|
||||
|
|
@ -67,21 +57,13 @@ pub fn apply_from_ac(cmd: &mut Command, ac: &AuthorizationContext) {
|
|||
.as_ref()
|
||||
.and_then(|p| p.did.as_ref())
|
||||
.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(
|
||||
cmd,
|
||||
did.as_deref(),
|
||||
ac.accord_hash.as_deref(),
|
||||
ac.shell_class.as_deref(),
|
||||
tier,
|
||||
ac.posture_level,
|
||||
ac.capability_set,
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -108,10 +90,8 @@ mod tests {
|
|||
Some("did:web:guildhouse.dev:user:tking"),
|
||||
Some("sha256:abcd"),
|
||||
Some("Application"),
|
||||
Some(ShellTier::T2Operator),
|
||||
Some(3),
|
||||
Some(0xCAFEBABE),
|
||||
Some(0x0000000000200404),
|
||||
);
|
||||
assert_eq!(
|
||||
cmd_env(&cmd, "GSH_DID").as_deref(),
|
||||
|
|
@ -119,38 +99,24 @@ mod tests {
|
|||
);
|
||||
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_TIER").as_deref(), Some("2"));
|
||||
assert_eq!(cmd_env(&cmd, "GSH_POSTURE_LEVEL").as_deref(), Some("3"));
|
||||
assert_eq!(
|
||||
cmd_env(&cmd, "GSH_CAPABILITY_SET").as_deref(),
|
||||
Some("0xcafebabe")
|
||||
);
|
||||
assert_eq!(
|
||||
cmd_env(&cmd, "GSH_CAP_BOUNDING").as_deref(),
|
||||
Some("0x0000000000200404")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_partial_only_did() {
|
||||
let mut cmd = Command::new("true");
|
||||
apply(&mut cmd, Some("did:web:foo:bar"), None, None, None, None, None, None);
|
||||
apply(&mut cmd, Some("did:web:foo:bar"), None, None, None, None);
|
||||
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_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_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]
|
||||
fn apply_from_ac_full() {
|
||||
let ac_json = r#"{
|
||||
|
|
@ -170,7 +136,6 @@ mod tests {
|
|||
);
|
||||
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_TIER").as_deref(), Some("1"));
|
||||
assert_eq!(cmd_env(&cmd, "GSH_POSTURE_LEVEL").as_deref(), Some("5"));
|
||||
assert_eq!(
|
||||
cmd_env(&cmd, "GSH_CAPABILITY_SET").as_deref(),
|
||||
|
|
@ -178,21 +143,6 @@ 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]
|
||||
fn apply_from_legacy_ac_no_governance_fields() {
|
||||
let ac_json = r#"{"context_id":"legacy","principal":{"did":"did:web:foo:bar"}}"#;
|
||||
|
|
|
|||
|
|
@ -1,18 +1,14 @@
|
|||
pub mod ac;
|
||||
pub mod agent_api;
|
||||
pub mod capabilities;
|
||||
pub mod chronicle_events;
|
||||
pub mod classifier;
|
||||
pub mod config;
|
||||
pub mod corpus;
|
||||
pub mod cr;
|
||||
pub mod governance_env;
|
||||
#[cfg(feature = "lmdb")]
|
||||
pub mod lmdb_enrichment;
|
||||
pub mod register;
|
||||
pub mod registry;
|
||||
pub mod session;
|
||||
pub mod shell_tier;
|
||||
|
||||
pub use ac::{AcValidationError, AuthorizationContext};
|
||||
pub use classifier::{classify_command, CommandClass, FREE_COMMANDS};
|
||||
|
|
@ -21,7 +17,6 @@ pub use corpus::{corpus_check, corpus_check_with_base, CorpusCheckResult, DEFAUL
|
|||
pub use cr::{post_cr, CrResult};
|
||||
pub use registry::ConsumedRegistry;
|
||||
pub use session::SessionState;
|
||||
pub use shell_tier::ShellTier;
|
||||
|
||||
/// Compute SHA-256 hash with "sha256:" prefix.
|
||||
pub fn sha256_hash(data: &[u8]) -> String {
|
||||
|
|
|
|||
|
|
@ -1,95 +0,0 @@
|
|||
//! 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,7 +79,6 @@ pub async fn register_app_shell(
|
|||
}
|
||||
FabricResponse::Denied { reason } => Err(RegisterError::Denied { reason }),
|
||||
FabricResponse::Error(msg) => Err(RegisterError::FabricError(msg)),
|
||||
_ => Err(RegisterError::FabricError("unexpected response".into())),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
//! Session state tracking for human mode.
|
||||
|
||||
use crate::ac::AuthorizationContext;
|
||||
use crate::shell_tier::ShellTier;
|
||||
|
||||
/// Tracks session state across the REPL loop.
|
||||
pub struct SessionState {
|
||||
|
|
@ -23,15 +22,8 @@ pub struct SessionState {
|
|||
/// `GSH_*` env vars via [`SessionState::apply_governance_env`].
|
||||
pub accord_hash: Option<String>,
|
||||
pub shell_class: Option<String>,
|
||||
pub shell_tier: ShellTier,
|
||||
pub capability_set: Option<u32>,
|
||||
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 {
|
||||
|
|
@ -65,7 +57,7 @@ impl SessionState {
|
|||
.ok().and_then(|v| v.parse().ok()).unwrap_or(5);
|
||||
let defcon_reason = std::env::var("BASCULE_DEFCON_REASON").ok();
|
||||
|
||||
let mut session = Self {
|
||||
Self {
|
||||
ac_id: ac.context_id.clone(),
|
||||
corpus_cid: corpus_cid.to_string(),
|
||||
principal,
|
||||
|
|
@ -81,26 +73,11 @@ impl SessionState {
|
|||
denied_count: 0,
|
||||
accord_hash: ac.accord_hash.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,
|
||||
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.
|
||||
pub fn ungoverned(corpus_cid: &str) -> Self {
|
||||
// Principal resolution: GSH_DID → BASCULE_USER_DID → whoami()
|
||||
|
|
@ -116,7 +93,7 @@ impl SessionState {
|
|||
.ok().and_then(|v| v.parse().ok()).unwrap_or(5);
|
||||
let defcon_reason = std::env::var("BASCULE_DEFCON_REASON").ok();
|
||||
|
||||
let mut session = Self {
|
||||
Self {
|
||||
ac_id: "ungoverned".to_string(),
|
||||
corpus_cid: corpus_cid.to_string(),
|
||||
principal,
|
||||
|
|
@ -132,26 +109,11 @@ impl SessionState {
|
|||
denied_count: 0,
|
||||
accord_hash: None,
|
||||
shell_class: None,
|
||||
shell_tier: resolve_shell_tier(None, None),
|
||||
capability_set: 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`.
|
||||
///
|
||||
/// Always exports `GSH_DID` (the resolved principal). Governance
|
||||
|
|
@ -165,10 +127,8 @@ impl SessionState {
|
|||
Some(&self.principal),
|
||||
self.accord_hash.as_deref(),
|
||||
self.shell_class.as_deref(),
|
||||
Some(self.shell_tier),
|
||||
self.posture_level,
|
||||
self.capability_set,
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -184,23 +144,6 @@ 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 {
|
||||
std::env::var("USER")
|
||||
.or_else(|_| std::env::var("USERNAME"))
|
||||
|
|
|
|||
|
|
@ -1,263 +0,0 @@
|
|||
// 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