diff --git a/Cargo.lock b/Cargo.lock index a857127..0e93123 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -175,6 +175,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chrono" version = "0.4.44" @@ -189,6 +195,33 @@ dependencies = [ "windows-link", ] +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + [[package]] name = "clap" version = "4.6.0" @@ -318,6 +351,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -651,6 +690,8 @@ dependencies = [ "reedline", "serde", "serde_json", + "substrate-ipc", + "tokio", "uuid", ] @@ -690,6 +731,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -1067,8 +1119,10 @@ dependencies = [ "serde", "serde_json", "sha2", + "substrate-ipc", "tempfile", "thiserror 2.0.18", + "tokio", "tracing", ] @@ -1131,6 +1185,15 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "mime" version = "0.3.17" @@ -1178,6 +1241,19 @@ dependencies = [ "tempfile", ] +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1765,6 +1841,17 @@ dependencies = [ "syn", ] +[[package]] +name = "substrate-ipc" +version = "0.1.0" +dependencies = [ + "ciborium", + "nix", + "serde", + "thiserror 2.0.18", + "tokio", +] + [[package]] name = "subtle" version = "2.6.1" @@ -1895,11 +1982,25 @@ dependencies = [ "bytes", "libc", "mio", + "parking_lot", "pin-project-lite", + "signal-hook-registry", "socket2", + "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tokio-native-tls" version = "0.3.1" @@ -2596,6 +2697,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zerofrom" version = "0.1.7" diff --git a/Cargo.toml b/Cargo.toml index 97f0cf3..5480398 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,3 +24,5 @@ reedline = "0.38" colored = "2" atty = "0.2" tracing = "0.1" +substrate-ipc = { path = "../substrate/crates/substrate-ipc" } +tokio = { version = "1", features = ["full"] } diff --git a/gsh/Cargo.toml b/gsh/Cargo.toml index 5bd273e..15c521b 100644 --- a/gsh/Cargo.toml +++ b/gsh/Cargo.toml @@ -19,3 +19,5 @@ chrono = { workspace = true } reedline = { workspace = true } colored = { workspace = true } atty = { workspace = true } +tokio = { workspace = true } +substrate-ipc = { workspace = true } diff --git a/gsh/src/main.rs b/gsh/src/main.rs index e829d78..cd766e4 100644 --- a/gsh/src/main.rs +++ b/gsh/src/main.rs @@ -63,6 +63,19 @@ enum Cmd { }, SessionEnd, SessionStatus, + /// Register an app shell with substrate-fabric (systemd ExecStartPre). + Register { + /// Service name for the shell registration. + #[arg(long)] + service_name: String, + /// Path to the fabric Unix socket. + #[arg(long, default_value = "/run/substrate/fabric.sock")] + fabric_socket: String, + /// Directory to write shell.env into. Defaults to + /// /run/substrate/shells/{service_name}/ + #[arg(long)] + env_dir: Option, + }, } #[derive(Serialize)] @@ -125,8 +138,18 @@ fn run(args: Args) -> Result { return Ok(code); } - // ── Session subcommands ────────────────────────────────── + // ── 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, + env_dir, + } = cmd + { + 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")?; @@ -166,6 +189,7 @@ fn run(args: Args) -> Result { } Ok(0) } + Cmd::Register { .. } => unreachable!("handled above"), }; } @@ -339,3 +363,24 @@ fn run(args: Args) -> Result { Ok(exit_code) } + +fn run_register(service_name: &str, fabric_socket: &str, env_dir: Option<&str>) -> Result { + let rt = tokio::runtime::Runtime::new().context("creating tokio runtime")?; + let env = rt.block_on(libgsh::register::register_app_shell( + service_name, + Some(fabric_socket), + ))?; + + let dir = match env_dir { + Some(d) => std::path::PathBuf::from(d), + None => std::path::PathBuf::from(format!("/run/substrate/shells/{service_name}")), + }; + + let path = libgsh::register::write_shell_env(&dir, &env)?; + eprintln!( + "gsh: registered shell {} — env at {}", + env.shell_id, + path.display() + ); + Ok(0) +} diff --git a/libgsh/Cargo.toml b/libgsh/Cargo.toml index ec215c2..395c06d 100644 --- a/libgsh/Cargo.toml +++ b/libgsh/Cargo.toml @@ -15,6 +15,8 @@ hex = { workspace = true } chrono = { workspace = true } dirs = { workspace = true } tracing = { workspace = true } +substrate-ipc = { workspace = true } +tokio = { workspace = true } [dev-dependencies] diff --git a/libgsh/src/agent_api.rs b/libgsh/src/agent_api.rs new file mode 100644 index 0000000..4d147f0 --- /dev/null +++ b/libgsh/src/agent_api.rs @@ -0,0 +1,129 @@ +//! Agent shell registration API. +//! +//! Unix socket server that accepts RegisterAgentShell requests from +//! callers wanting to spawn governed agent sub-shells. Validates +//! capability attenuation (requested ⊆ parent) before forwarding +//! to substrate-fabric. + +use std::collections::HashMap; + +use thiserror::Error; +use tokio::net::UnixStream; + +use substrate_ipc::fabric_api::{FabricRequest, FabricResponse}; +use substrate_ipc::wire; + +const DEFAULT_FABRIC_SOCKET: &str = "/run/substrate/fabric.sock"; + +#[derive(Debug, Error)] +pub enum AgentError { + #[error("capability widening: requested 0x{requested:08x} exceeds parent 0x{parent:08x}")] + CapabilityWidening { requested: u32, parent: u32 }, + #[error("connecting to fabric socket at {path}: {source}")] + Connect { + path: String, + source: std::io::Error, + }, + #[error("fabric IPC: {0}")] + Wire(#[from] wire::WireError), + #[error("registration denied: {reason}")] + Denied { reason: String }, + #[error("fabric error: {0}")] + FabricError(String), + #[error("reading cgroup: {0}")] + Cgroup(String), +} + +#[derive(Debug, Clone)] +pub struct AgentShellEnv { + pub shell_id: String, + pub env_vars: HashMap, +} + +/// Validate that requested capabilities are a subset of parent's. +pub fn validate_attenuation( + requested: u32, + parent: u32, +) -> Result<(), AgentError> { + if requested & !parent != 0 { + return Err(AgentError::CapabilityWidening { + requested, + parent, + }); + } + Ok(()) +} + +/// Register an agent shell via substrate-fabric. +/// +/// Validates capability attenuation before forwarding. The caller +/// must be in the parent shell's cgroup subtree. +pub async fn register_agent_shell( + parent_shell_id: &str, + parent_capabilities: u32, + requested_capabilities: u32, + fabric_socket: Option<&str>, +) -> Result { + validate_attenuation(requested_capabilities, parent_capabilities)?; + + let cgroup_path = substrate_ipc::cgroup::get_self_cgroup_path() + .map_err(|e| AgentError::Cgroup(e.to_string()))?; + + let sock_path = fabric_socket.unwrap_or(DEFAULT_FABRIC_SOCKET); + let stream = UnixStream::connect(sock_path) + .await + .map_err(|e| AgentError::Connect { + path: sock_path.into(), + source: e, + })?; + + let (mut rd, mut wr) = tokio::io::split(stream); + + let req = FabricRequest::RegisterAgentShell { + parent_shell_id: parent_shell_id.into(), + requested_capabilities, + cgroup_path, + }; + wire::send_msg(&mut wr, &req).await?; + + let resp: FabricResponse = wire::recv_msg(&mut rd).await?; + match resp { + FabricResponse::ShellCreated { shell_id, env_vars } => { + Ok(AgentShellEnv { shell_id, env_vars }) + } + FabricResponse::Denied { reason } => Err(AgentError::Denied { reason }), + FabricResponse::Error(msg) => Err(AgentError::FabricError(msg)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn attenuation_allows_subset() { + validate_attenuation(0x01, 0x03).unwrap(); // READ ⊆ READ|PROPOSE + } + + #[test] + fn attenuation_allows_equal() { + validate_attenuation(0x03, 0x03).unwrap(); // exact match + } + + #[test] + fn attenuation_allows_empty() { + validate_attenuation(0x00, 0x0F).unwrap(); // nothing requested + } + + #[test] + fn attenuation_rejects_widening() { + let err = validate_attenuation(0x04, 0x03).unwrap_err(); // MUTATE not in READ|PROPOSE + assert!(matches!(err, AgentError::CapabilityWidening { .. })); + } + + #[test] + fn attenuation_rejects_superset() { + let err = validate_attenuation(0x0F, 0x03).unwrap_err(); // ALL not in READ|PROPOSE + assert!(matches!(err, AgentError::CapabilityWidening { .. })); + } +} diff --git a/libgsh/src/lib.rs b/libgsh/src/lib.rs index a26ea06..0c4e2fc 100644 --- a/libgsh/src/lib.rs +++ b/libgsh/src/lib.rs @@ -1,10 +1,12 @@ pub mod ac; +pub mod agent_api; pub mod chronicle_events; pub mod classifier; pub mod config; pub mod corpus; pub mod cr; pub mod governance_env; +pub mod register; pub mod registry; pub mod session; diff --git a/libgsh/src/register.rs b/libgsh/src/register.rs new file mode 100644 index 0000000..3f42f60 --- /dev/null +++ b/libgsh/src/register.rs @@ -0,0 +1,182 @@ +//! App shell registration via substrate-fabric IPC. +//! +//! Used by `gsh --register --service-name ` in systemd +//! ExecStartPre to create a governed shell for an application +//! service before its main process starts. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use thiserror::Error; +use tokio::net::UnixStream; + +use substrate_ipc::fabric_api::{FabricRequest, FabricResponse}; +use substrate_ipc::wire; + +const DEFAULT_FABRIC_SOCKET: &str = "/run/substrate/fabric.sock"; + +#[derive(Debug, Error)] +pub enum RegisterError { + #[error("connecting to fabric socket at {path}: {source}")] + Connect { + path: String, + source: std::io::Error, + }, + #[error("fabric IPC: {0}")] + Wire(#[from] wire::WireError), + #[error("registration denied: {reason}")] + Denied { reason: String }, + #[error("fabric error: {0}")] + FabricError(String), + #[error("writing shell env to {path}: {source}")] + WriteEnv { + path: PathBuf, + source: std::io::Error, + }, + #[error("reading cgroup: {0}")] + Cgroup(String), +} + +#[derive(Debug, Clone)] +pub struct ShellEnv { + pub shell_id: String, + pub env_vars: HashMap, +} + +/// Register an app shell with substrate-fabric. +/// +/// 1. Discovers the caller's cgroup path +/// 2. Connects to the fabric Unix socket +/// 3. Sends RegisterAppShell +/// 4. Returns the shell environment on success +pub async fn register_app_shell( + service_name: &str, + fabric_socket: Option<&str>, +) -> Result { + let cgroup_path = substrate_ipc::cgroup::get_self_cgroup_path() + .map_err(|e| RegisterError::Cgroup(e.to_string()))?; + + let sock_path = fabric_socket.unwrap_or(DEFAULT_FABRIC_SOCKET); + let stream = UnixStream::connect(sock_path) + .await + .map_err(|e| RegisterError::Connect { + path: sock_path.into(), + source: e, + })?; + + let (mut rd, mut wr) = tokio::io::split(stream); + + let req = FabricRequest::RegisterAppShell { + service_name: service_name.into(), + cgroup_path, + }; + wire::send_msg(&mut wr, &req).await?; + + let resp: FabricResponse = wire::recv_msg(&mut rd).await?; + match resp { + FabricResponse::ShellCreated { shell_id, env_vars } => { + Ok(ShellEnv { shell_id, env_vars }) + } + FabricResponse::Denied { reason } => Err(RegisterError::Denied { reason }), + FabricResponse::Error(msg) => Err(RegisterError::FabricError(msg)), + } +} + +/// Write shell environment variables to a systemd-compatible +/// EnvironmentFile at the given directory. +/// +/// Creates `{dir}/shell.env` with `KEY=VALUE` lines. +pub fn write_shell_env(dir: &Path, env: &ShellEnv) -> Result { + std::fs::create_dir_all(dir).map_err(|e| RegisterError::WriteEnv { + path: dir.to_path_buf(), + source: e, + })?; + + let env_path = dir.join("shell.env"); + let mut content = String::new(); + content.push_str(&format!("SUBSTRATE_SHELL_ID={}\n", env.shell_id)); + for (k, v) in &env.env_vars { + content.push_str(&format!("{k}={v}\n")); + } + + std::fs::write(&env_path, &content).map_err(|e| RegisterError::WriteEnv { + path: env_path.clone(), + source: e, + })?; + + Ok(env_path) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn write_shell_env_creates_file() { + let dir = tempfile::tempdir().unwrap(); + let env = ShellEnv { + shell_id: "test-shell-001".into(), + env_vars: { + let mut m = HashMap::new(); + m.insert("GSH_DID".into(), "did:web:example:user:test".into()); + m.insert("GSH_SHELL_CLASS".into(), "Application".into()); + m + }, + }; + + let path = write_shell_env(dir.path(), &env).unwrap(); + assert!(path.exists()); + + let content = std::fs::read_to_string(&path).unwrap(); + assert!(content.contains("SUBSTRATE_SHELL_ID=test-shell-001")); + assert!(content.contains("GSH_DID=did:web:example:user:test")); + assert!(content.contains("GSH_SHELL_CLASS=Application")); + } + + #[tokio::test] + async fn register_fails_when_socket_missing() { + let result = register_app_shell("test-svc", Some("/tmp/nonexistent-fabric.sock")).await; + assert!(matches!(result, Err(RegisterError::Connect { .. }))); + } + + #[tokio::test] + async fn register_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 { + register_app_shell("my-daemon", Some(&sock_clone)).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::RegisterAppShell { service_name, .. } => { + assert_eq!(service_name, "my-daemon"); + } + _ => panic!("wrong request variant"), + } + + let mut env_vars = HashMap::new(); + env_vars.insert("GSH_DID".into(), "did:web:test:hosts:h1:shells:my-daemon".into()); + env_vars.insert("GSH_SHELL_CLASS".into(), "Application".into()); + let resp = FabricResponse::ShellCreated { + shell_id: "shell-abc".into(), + env_vars, + }; + wire::send_msg(&mut wr, &resp).await.unwrap(); + + let result = client.await.unwrap().unwrap(); + assert_eq!(result.shell_id, "shell-abc"); + assert_eq!( + result.env_vars.get("GSH_DID").unwrap(), + "did:web:test:hosts:h1:shells:my-daemon" + ); + } +}