Merge pull request 'feat(spawner): JailConfig + jail_wrap for jailed agent spawn' (#35) from feat/spawner-jail-confinement into main
Some checks are pending
CI / rust (push) Waiting to run
CI / markdown (push) Waiting to run

Reviewed-on: #35
This commit is contained in:
clawdie 2026-06-13 19:40:35 +02:00
commit abc9174caf

View file

@ -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 <cmd>` — 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<String>,
/// Root path for an ephemeral jail (`jail -c path=…`). Used when `name` is None.
#[serde(default)]
pub path: Option<String>,
/// IPv4 spec: `inherit` (default) → `ip4=inherit`; any other value →
/// `ip4.addr=<value>`.
#[serde(default)]
pub ip4: Option<String>,
/// 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<String>,
}
/// 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<String>,
priv_mode: &PrivMode,
helper: &str,
) -> (String, Vec<String>) {
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<String>) {
let Some(j) = jail else {
return (binary.to_string(), args.to_vec());
};
let (jail_exe, mut jail_argv): (String, Vec<String>) = 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<String>,
/// Optional FreeBSD jail confinement. None = run on the host (default).
#[serde(default)]
pub jail: Option<JailConfig>,
/// 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<AgentHandle, SpawnerError> {
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<String> {
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",
])
);
}
}