feat(gsh): add --register mode and agent API for shellbound host

Adds `gsh register --service-name <name>` subcommand for systemd
ExecStartPre integration. Connects to substrate-fabric Unix socket,
sends RegisterAppShell, writes shell.env for EnvironmentFile= loading.

New libgsh modules:
- register.rs: fabric IPC client for app shell creation + env writer
- agent_api.rs: capability attenuation validation for agent sub-shells

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Tyler J King <tking@guildhouse.dev>
This commit is contained in:
Tyler J King 2026-05-15 05:47:29 -04:00
parent 061e2206ea
commit 7c84854222
8 changed files with 486 additions and 1 deletions

121
Cargo.lock generated
View file

@ -175,6 +175,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]] [[package]]
name = "chrono" name = "chrono"
version = "0.4.44" version = "0.4.44"
@ -189,6 +195,33 @@ dependencies = [
"windows-link", "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]] [[package]]
name = "clap" name = "clap"
version = "4.6.0" version = "4.6.0"
@ -318,6 +351,12 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]] [[package]]
name = "crypto-common" name = "crypto-common"
version = "0.1.7" version = "0.1.7"
@ -651,6 +690,8 @@ dependencies = [
"reedline", "reedline",
"serde", "serde",
"serde_json", "serde_json",
"substrate-ipc",
"tokio",
"uuid", "uuid",
] ]
@ -690,6 +731,17 @@ dependencies = [
"tracing", "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]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.15.5" version = "0.15.5"
@ -1067,8 +1119,10 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2",
"substrate-ipc",
"tempfile", "tempfile",
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio",
"tracing", "tracing",
] ]
@ -1131,6 +1185,15 @@ version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "memoffset"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
dependencies = [
"autocfg",
]
[[package]] [[package]]
name = "mime" name = "mime"
version = "0.3.17" version = "0.3.17"
@ -1178,6 +1241,19 @@ dependencies = [
"tempfile", "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]] [[package]]
name = "nu-ansi-term" name = "nu-ansi-term"
version = "0.50.3" version = "0.50.3"
@ -1765,6 +1841,17 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "substrate-ipc"
version = "0.1.0"
dependencies = [
"ciborium",
"nix",
"serde",
"thiserror 2.0.18",
"tokio",
]
[[package]] [[package]]
name = "subtle" name = "subtle"
version = "2.6.1" version = "2.6.1"
@ -1895,11 +1982,25 @@ dependencies = [
"bytes", "bytes",
"libc", "libc",
"mio", "mio",
"parking_lot",
"pin-project-lite", "pin-project-lite",
"signal-hook-registry",
"socket2", "socket2",
"tokio-macros",
"windows-sys 0.61.2", "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]] [[package]]
name = "tokio-native-tls" name = "tokio-native-tls"
version = "0.3.1" version = "0.3.1"
@ -2596,6 +2697,26 @@ dependencies = [
"synstructure", "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]] [[package]]
name = "zerofrom" name = "zerofrom"
version = "0.1.7" version = "0.1.7"

View file

@ -24,3 +24,5 @@ reedline = "0.38"
colored = "2" colored = "2"
atty = "0.2" atty = "0.2"
tracing = "0.1" tracing = "0.1"
substrate-ipc = { path = "../substrate/crates/substrate-ipc" }
tokio = { version = "1", features = ["full"] }

View file

@ -19,3 +19,5 @@ chrono = { workspace = true }
reedline = { workspace = true } reedline = { workspace = true }
colored = { workspace = true } colored = { workspace = true }
atty = { workspace = true } atty = { workspace = true }
tokio = { workspace = true }
substrate-ipc = { workspace = true }

View file

@ -63,6 +63,19 @@ enum Cmd {
}, },
SessionEnd, SessionEnd,
SessionStatus, 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<String>,
},
} }
#[derive(Serialize)] #[derive(Serialize)]
@ -125,8 +138,18 @@ fn run(args: Args) -> Result<i32> {
return Ok(code); return Ok(code);
} }
// ── Session 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 {
service_name,
fabric_socket,
env_dir,
} = cmd
{
return run_register(service_name, fabric_socket, env_dir.as_deref());
}
let base = args.broker_url.clone() let base = args.broker_url.clone()
.or_else(|| std::env::var("GSAP_BROKER_URL").ok()) .or_else(|| std::env::var("GSAP_BROKER_URL").ok())
.context("GSAP_BROKER_URL not set")?; .context("GSAP_BROKER_URL not set")?;
@ -166,6 +189,7 @@ fn run(args: Args) -> Result<i32> {
} }
Ok(0) Ok(0)
} }
Cmd::Register { .. } => unreachable!("handled above"),
}; };
} }
@ -339,3 +363,24 @@ fn run(args: Args) -> Result<i32> {
Ok(exit_code) Ok(exit_code)
} }
fn run_register(service_name: &str, fabric_socket: &str, env_dir: Option<&str>) -> Result<i32> {
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)
}

View file

@ -15,6 +15,8 @@ hex = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
dirs = { workspace = true } dirs = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
substrate-ipc = { workspace = true }
tokio = { workspace = true }
[dev-dependencies] [dev-dependencies]

129
libgsh/src/agent_api.rs Normal file
View file

@ -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<String, String>,
}
/// 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<AgentShellEnv, AgentError> {
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 { .. }));
}
}

View file

@ -1,10 +1,12 @@
pub mod ac; pub mod ac;
pub mod agent_api;
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;
pub mod register;
pub mod registry; pub mod registry;
pub mod session; pub mod session;

182
libgsh/src/register.rs Normal file
View file

@ -0,0 +1,182 @@
//! App shell registration via substrate-fabric IPC.
//!
//! Used by `gsh --register --service-name <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<String, String>,
}
/// 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<ShellEnv, RegisterError> {
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<PathBuf, RegisterError> {
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"
);
}
}