# Colibri jailed agent spawn **Status:** Accepted — implemented · **Date:** 13.jun.2026 How Colibri confines a spawned agent (e.g. `pi`) inside a FreeBSD jail, and how the unprivileged daemon gets the root that jails require. This describes the shipped code in `crates/colibri-daemon/src/spawner.rs`. ## Why this lives in Colibri, not zot Colibri is the supervisor and already spawns agents — `spawner.rs` runs the subprocess, captures its JSONL, and feeds glasspane. Confinement is a supervisor concern, so it lives here, and zot stays a clean upstream mirror. (zot's own `swarm` only spawns copies of zot and has no isolation, so it was never the right place for this.) ## How it works A spawn can carry an optional `JailConfig`; with none, the agent runs on the host as before. The field that is set picks the jail lifecycle: - **`name`** — enter an already-running **persistent** jail with `jexec` (created/destroyed out of band by rc.d / the operator). Takes precedence. - **`path`** — create an **ephemeral** jail with `jail -c … command=`, which exists only while the agent runs and is removed when it exits (no teardown needed). - **`root_path`** — host-visible root path for a named jail; required when staged env/working-dir payload delivery is needed. Falls back to `path` for ephemeral jails. - optional `ip4` (`inherit` by default) and `user` (in-jail user, `jexec` path). `jail_wrap()` turns `(binary, args)` into the `(program, argv)` to exec. stdio is untouched — `jexec`, `jail`, and `mdo` all run the child in the foreground and inherit stdin/stdout — so the agent's JSON stream still reaches glasspane and the MCP host's stdin/stdout transport still works. This is wired through the `spawn-agent` socket command (any caller can request a jail) and reused by the external-MCP host (`colibri-mcp`), which confines arbitrary third-party MCP servers the same way. ## Privilege: how the unprivileged daemon gets root Jail attach (`jexec`) and create (`jail`) are root-only, but `colibri_daemon` runs unprivileged. The deciding fact: FreeBSD `mac_do` rules are **identity** mappings (`security.mac.do.rules=gid=0>uid=0` means "wheel may become root"), not command filters — so granting the daemon `mdo` access grants it _full_ root, not just `jexec`. We choose the escalation per host via `PrivMode` (`COLIBRI_JAIL_PRIV_MODE`): - **Live operator USB → `mdo` (default).** The single operator already holds wheel→root, so a trusted local daemon is the same trust domain — `mdo -u root` reuses the image's existing `mac_do` plumbing, no new privileged binary. - **Deployed / shared host → setuid helper.** A socket-facing daemon with blanket root is a real escalation surface, so use a narrow setuid helper (`/usr/local/libexec/colibri-jail-spawn`) that only performs the jail spawn, and keep the daemon unprivileged. - **Validated hosts with existing sudo policy → `sudo`.** `sudo -n` can be used as an interim proof/ops mode when a narrow sudoers rule already permits the daemon user to run the jail command without prompting. Prefer the setuid helper for long-lived production hosts once packaged. - **`none`** — run the jail command directly (already root, or tests). ## Staged env payloads When a jailed spawn needs env vars or a working dir, `prepare_spawn_command()` writes a 0600 `env.sh` (sorted, single-quoted exports) and a `launch.sh` wrapper into a staged directory under the jail's `root_path` at `/home/clawdie/.cache/colibri/stage//` (the daemon user's home, so the daemon creates it with no privileged step; overridable via `COLIBRI_JAIL_STAGE_DIR`). The jail command runs `/bin/sh launch.sh`, which sources the env file and `cd`s to the working dir before `exec`-ing the agent binary. This bypasses the env-passthrough problem entirely — no reliance on `jexec`/`mdo` inheriting env vars. The staged directory is cleaned up when the agent stops, fails, exits early, or encounters a poll error. The same mechanism is used by the external-MCP host for jailed MCP servers. ## Open items - **Teardown:** ephemeral `jail -c command=` self-cleans; reaping a deeply nested in-jail process tree may want a process-group kill (follow-up). - **Jail filesystem provisioning** (ISO / deploy): the jailed binary needs its runtime + work dir — a pre-provisioned persistent jail, or nullfs mounts for an ephemeral one. ## References - `crates/colibri-daemon/src/spawner.rs` — `JailConfig`, `PrivMode`, `jail_wrap`, `prepare_spawn_command`, `PreparedSpawnCommand` - `crates/colibri-daemon/src/lib.rs` + `socket.rs` — `jail` on the spawn-agent command - `crates/colibri-mcp/src/external.rs` — jailed external MCP servers