From 66cbc76a5b635e207bb25b2f31ccf0b4cb8c95e7 Mon Sep 17 00:00:00 2001 From: Sam & Claude Date: Sat, 13 Jun 2026 19:31:09 +0200 Subject: [PATCH] feat(spawner): JailConfig + jail_wrap for jailed agent spawn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the spawner half of docs/COLIBRI-JAILED-AGENT-SPAWN-DESIGN.md so Colibri can confine a spawned agent (e.g. pi) in a FreeBSD jail. zot untouched. - PrivMode {Mdo, Helper, None}: how the (unprivileged) daemon gets the root that jail attach/create needs. Resolved from COLIBRI_JAIL_PRIV_MODE (default mdo — the live-USB posture); deployed hosts set helper. Only consulted when a spawn requests a jail. - JailConfig {name, path, ip4, user}: `name` enters a persistent jail (jexec, precedence); `path` makes an ephemeral `jail -c command=` that self-cleans on exit. Neither set = no-op. (Refines the design's `ephemeral` flag into the clearer name-vs-path choice.) - jail_wrap(): pure (binary,args)->(program,argv) wrapper. No-op without a jail. jexec runs without -l so injected COLIBRI_*/provider env is inherited; stdio flows through mdo/jexec/jail so glasspane ingestion is unchanged. - AgentSpawnConfig gains `jail: Option` (#[serde(default)]); spawn() resolves PrivMode/helper once and routes the command through jail_wrap. - kill(): documented jail teardown semantics + the in-jail process-group reaping follow-up. - 7 jail_wrap unit tests. Full daemon lib suite (58) green; clippy -D warnings clean. Not wired through the SpawnAgent socket command yet (it builds AgentSpawnConfig with jail=None) — that protocol field is the next small step. Co-Authored-By: Claude Opus 4.8 --- crates/colibri-daemon/src/spawner.rs | 308 ++++++++++++++++++++++++++- 1 file changed, 304 insertions(+), 4 deletions(-) diff --git a/crates/colibri-daemon/src/spawner.rs b/crates/colibri-daemon/src/spawner.rs index aa9598f..4a79eb2 100644 --- a/crates/colibri-daemon/src/spawner.rs +++ b/crates/colibri-daemon/src/spawner.rs @@ -76,6 +76,156 @@ impl Provider { } } +// --------------------------------------------------------------------------- +// Jail confinement (FreeBSD) +// --------------------------------------------------------------------------- + +/// How the daemon obtains the root privilege that jail attach/create requires. +/// +/// Selected per host (see `docs/COLIBRI-JAILED-AGENT-SPAWN-DESIGN.md`): the +/// live USB reuses mac_do (`mdo -u root`), deployed/hardened hosts use a narrow +/// setuid helper. `None` runs the jail command directly (already root, or tests). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum PrivMode { + /// `mdo -u root ` — live USB (operator already holds wheel→root). + Mdo, + /// Setuid helper that performs only the jail spawn — deployed hosts. + Helper, + /// No escalation: run the jail command directly. + None, +} + +impl PrivMode { + /// Resolve from `COLIBRI_JAIL_PRIV_MODE` (`mdo` | `helper` | `none`). + /// Defaults to `Mdo` (the live-USB posture); only consulted when a spawn + /// actually requests a jail. + pub fn from_env() -> Self { + match std::env::var("COLIBRI_JAIL_PRIV_MODE") + .unwrap_or_default() + .trim() + .to_ascii_lowercase() + .as_str() + { + "helper" => PrivMode::Helper, + "none" => PrivMode::None, + _ => PrivMode::Mdo, + } + } +} + +/// Default path to the setuid jail-spawn helper used in `PrivMode::Helper`. +pub const DEFAULT_JAIL_HELPER: &str = "/usr/local/libexec/colibri-jail-spawn"; + +/// Optional FreeBSD jail confinement for a spawned agent. +/// +/// The field that is set selects the lifecycle: +/// * `name` — enter an already-running **persistent** jail via `jexec` +/// (created/destroyed out of band by rc.d / the operator). Takes precedence. +/// * `path` — create an **ephemeral** jail via `jail -c … command=…`, which +/// exists only while the agent runs and is removed when it exits (no teardown +/// wiring needed). Used when `name` is unset. +/// +/// A config with neither `name` nor `path` is a no-op (runs on the host). +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct JailConfig { + /// Name of an existing, running jail to enter (`jexec`). Takes precedence. + #[serde(default)] + pub name: Option, + /// Root path for an ephemeral jail (`jail -c path=…`). Used when `name` is None. + #[serde(default)] + pub path: Option, + /// IPv4 spec: `inherit` (default) → `ip4=inherit`; any other value → + /// `ip4.addr=`. + #[serde(default)] + pub ip4: Option, + /// In-jail user to run as (`jexec -U`). Default: the jail's default (root). + /// Only applied on the `name` / `jexec` path. + #[serde(default)] + pub user: Option, +} + +/// Apply privilege escalation to a `(program, argv)` pair for the root-only +/// jail step. Pure helper shared by [`jail_wrap`]. +fn priv_wrap( + exe: String, + argv: Vec, + priv_mode: &PrivMode, + helper: &str, +) -> (String, Vec) { + match priv_mode { + PrivMode::None => (exe, argv), + PrivMode::Mdo => { + let mut a = vec!["-u".to_string(), "root".to_string(), exe]; + a.extend(argv); + ("mdo".to_string(), a) + } + PrivMode::Helper => { + let mut a = vec![exe]; + a.extend(argv); + (helper.to_string(), a) + } + } +} + +/// Wrap an agent command for optional jail confinement plus privilege +/// escalation, returning the `(program, argv)` to hand to `Command::new`. +/// +/// With `jail == None` (or a `JailConfig` carrying neither `name` nor `path`) +/// the command is returned unchanged — today's host behavior. stdio is +/// unaffected: `mdo`, `jexec`, and `jail -c command=` all run the child in the +/// foreground and inherit stdio, so the agent's stdout JSONL still flows to +/// glasspane. +/// +/// `jexec` is invoked **without `-l`** so the `COLIBRI_*` / provider environment +/// injected via `Command::envs` is inherited by the jailed process. +pub fn jail_wrap( + binary: &str, + args: &[String], + jail: Option<&JailConfig>, + priv_mode: &PrivMode, + helper: &str, +) -> (String, Vec) { + let Some(j) = jail else { + return (binary.to_string(), args.to_vec()); + }; + + let (jail_exe, mut jail_argv): (String, Vec) = if let Some(name) = j.name.as_deref() { + // Enter an existing persistent jail. No `-l`: preserve injected env. + let mut a = Vec::new(); + if let Some(user) = j.user.as_deref() { + a.push("-U".to_string()); + a.push(user.to_string()); + } + a.push(name.to_string()); + a.push(binary.to_string()); + ("jexec".to_string(), a) + } else if let Some(path) = j.path.as_deref() { + // Ephemeral jail: lives only for the command, removed on exit. + let ip4 = j.ip4.as_deref().unwrap_or("inherit"); + let ip4_param = if ip4.eq_ignore_ascii_case("inherit") { + "ip4=inherit".to_string() + } else { + format!("ip4.addr={ip4}") + }; + let a = vec![ + "-c".to_string(), + format!("path={path}"), + "mount.devfs".to_string(), + ip4_param, + "command".to_string(), + binary.to_string(), + ]; + ("jail".to_string(), a) + } else { + // Neither name nor path: no-op, run on host. + return (binary.to_string(), args.to_vec()); + }; + + jail_argv.extend(args.iter().cloned()); + priv_wrap(jail_exe, jail_argv, priv_mode, helper) +} + // --------------------------------------------------------------------------- // Agent spawn configuration // --------------------------------------------------------------------------- @@ -94,6 +244,9 @@ pub struct AgentSpawnConfig { /// Working directory for the subprocess. #[serde(default)] pub working_dir: Option, + /// Optional FreeBSD jail confinement. None = run on the host (default). + #[serde(default)] + pub jail: Option, /// Primary provider. #[serde(default = "default_provider")] pub provider: Provider, @@ -132,6 +285,7 @@ impl Default for AgentSpawnConfig { args: Vec::new(), env: HashMap::new(), working_dir: None, + jail: None, provider: default_provider(), model: String::new(), session_id: None, @@ -194,6 +348,12 @@ impl AgentHandle { } /// Kill the agent subprocess. + /// + /// For a jailed agent the tracked child is the wrapper (`mdo`/`jexec`/ + /// `jail`); killing it removes a `jail -c command=` ephemeral jail (the jail + /// is torn down when its command process dies). Reaping a deeply nested + /// in-jail process tree may need a process-group kill — tracked as a + /// follow-up; see `docs/COLIBRI-JAILED-AGENT-SPAWN-DESIGN.md`. pub async fn kill(&self) -> Result<(), SpawnerError> { let mut child = self.child.lock().await; if let Some(ref mut child) = *child { @@ -262,6 +422,11 @@ impl Spawner { pub async fn spawn(&self, agent_config: AgentSpawnConfig) -> Result { let agent_id = Uuid::new_v4().to_string(); + // Jail confinement is host-level policy; resolve once per spawn. + let priv_mode = PrivMode::from_env(); + let jail_helper = std::env::var("COLIBRI_JAIL_HELPER") + .unwrap_or_else(|_| DEFAULT_JAIL_HELPER.to_string()); + // Build the provider routing list: primary first, then fallbacks. // Local agents are deterministic/no-network and should never fall back // to remote providers. @@ -331,15 +496,22 @@ impl Spawner { // Spawn with retry/backoff let spawn_result = { - let binary = agent_config.binary.clone(); - let args = agent_config.args.clone(); + // Apply optional jail confinement + privilege escalation. With + // no jail this is the bare (binary, args); stdio is unaffected. + let (exe, argv) = jail_wrap( + &agent_config.binary, + &agent_config.args, + agent_config.jail.as_ref(), + &priv_mode, + &jail_helper, + ); let env = env_map.clone(); let working_dir = agent_config.working_dir.clone(); let _startup_timeout = agent_config.startup_timeout_secs; let op = || async { - let mut cmd = Command::new(&binary); - cmd.args(&args) + let mut cmd = Command::new(&exe); + cmd.args(&argv) .envs(&env) .stdin(Stdio::null()) .stdout(Stdio::piped()) @@ -417,3 +589,131 @@ fn provider_name_upper(provider: &Provider) -> &'static str { Provider::Local => "LOCAL", } } + +#[cfg(test)] +mod jail_tests { + use super::*; + + fn argv(v: &[&str]) -> Vec { + v.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn no_jail_returns_command_unchanged() { + let (exe, a) = jail_wrap( + "pi", + &argv(&["--mode", "json"]), + None, + &PrivMode::Mdo, + DEFAULT_JAIL_HELPER, + ); + assert_eq!(exe, "pi"); + assert_eq!(a, argv(&["--mode", "json"])); + } + + #[test] + fn empty_jailconfig_is_noop() { + let j = JailConfig::default(); + let (exe, a) = jail_wrap( + "pi", + &argv(&["-p", "task"]), + Some(&j), + &PrivMode::Mdo, + DEFAULT_JAIL_HELPER, + ); + assert_eq!(exe, "pi"); + assert_eq!(a, argv(&["-p", "task"])); + } + + #[test] + fn named_jail_via_mdo() { + let j = JailConfig { + name: Some("pi0".into()), + ..Default::default() + }; + let (exe, a) = jail_wrap( + "pi", + &argv(&["-p", "task"]), + Some(&j), + &PrivMode::Mdo, + DEFAULT_JAIL_HELPER, + ); + assert_eq!(exe, "mdo"); + assert_eq!(a, argv(&["-u", "root", "jexec", "pi0", "pi", "-p", "task"])); + } + + #[test] + fn named_jail_with_user_no_priv() { + let j = JailConfig { + name: Some("pi0".into()), + user: Some("clawdie".into()), + ..Default::default() + }; + let (exe, a) = jail_wrap("pi", &argv(&["x"]), Some(&j), &PrivMode::None, DEFAULT_JAIL_HELPER); + assert_eq!(exe, "jexec"); + assert_eq!(a, argv(&["-U", "clawdie", "pi0", "pi", "x"])); + } + + #[test] + fn name_takes_precedence_over_path() { + let j = JailConfig { + name: Some("pi0".into()), + path: Some("/var/jails/pi".into()), + ..Default::default() + }; + let (exe, a) = jail_wrap("pi", &[], Some(&j), &PrivMode::None, DEFAULT_JAIL_HELPER); + assert_eq!(exe, "jexec"); + assert_eq!(a, argv(&["pi0", "pi"])); + } + + #[test] + fn ephemeral_jail_via_helper() { + let j = JailConfig { + path: Some("/var/jails/pi".into()), + ..Default::default() + }; + let (exe, a) = jail_wrap( + "pi", + &argv(&["go"]), + Some(&j), + &PrivMode::Helper, + DEFAULT_JAIL_HELPER, + ); + assert_eq!(exe, DEFAULT_JAIL_HELPER); + assert_eq!( + a, + argv(&[ + "jail", + "-c", + "path=/var/jails/pi", + "mount.devfs", + "ip4=inherit", + "command", + "pi", + "go", + ]) + ); + } + + #[test] + fn ephemeral_jail_explicit_ip() { + let j = JailConfig { + path: Some("/var/jails/pi".into()), + ip4: Some("10.0.0.5".into()), + ..Default::default() + }; + let (exe, a) = jail_wrap("pi", &[], Some(&j), &PrivMode::None, DEFAULT_JAIL_HELPER); + assert_eq!(exe, "jail"); + assert_eq!( + a, + argv(&[ + "-c", + "path=/var/jails/pi", + "mount.devfs", + "ip4.addr=10.0.0.5", + "command", + "pi", + ]) + ); + } +}