Merge pull request 'feat(spawner): JailConfig + jail_wrap for jailed agent spawn' (#35) from feat/spawner-jail-confinement into main
Reviewed-on: #35
This commit is contained in:
commit
abc9174caf
1 changed files with 304 additions and 4 deletions
|
|
@ -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",
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue