From 87c075d6bab596decd1df2d8736359ecb65936cb Mon Sep 17 00:00:00 2001 From: Sam & Claude Date: Sat, 13 Jun 2026 20:08:24 +0200 Subject: [PATCH] feat(mcp): confine external MCP servers in a jail (reuse spawner primitive) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit External MCP servers are arbitrary third-party binaries — at least as untrusted as the agents the spawner already jails — but the #36 prototype spawned them directly on the host. Close that gap by reusing the existing confinement primitive instead of growing a second one. - ExternalMcpServer gains `jail: Option` (#[serde(default)]). - ExternalMcpSession::start routes Command::new through colibri_daemon::spawner::jail_wrap with the shared COLIBRI_JAIL_PRIV_MODE policy (mdo live / helper deploy). No jail => unchanged. stdio (incl. the piped JSON-RPC stdin/stdout) flows through jexec/jail/mdo unaffected. - docs/COLIBRI-EXTERNAL-MCP-PROTOTYPE: document the `jail` field + confinement. - 3 tests (no-jail passthrough, jexec wrap, registry jail deserialize). colibri-mcp already depends on colibri-daemon, so no new dep. Build/test/clippy/ fmt green. Co-Authored-By: Claude Opus 4.8 --- crates/colibri-mcp/src/external.rs | 84 +++++++++++++++++++++++++- docs/COLIBRI-EXTERNAL-MCP-PROTOTYPE.md | 24 +++++++- 2 files changed, 105 insertions(+), 3 deletions(-) diff --git a/crates/colibri-mcp/src/external.rs b/crates/colibri-mcp/src/external.rs index 204ccb0..03b2dfc 100644 --- a/crates/colibri-mcp/src/external.rs +++ b/crates/colibri-mcp/src/external.rs @@ -13,6 +13,8 @@ use tokio::{ time::timeout, }; +use colibri_daemon::spawner::{jail_wrap, JailConfig, PrivMode, DEFAULT_JAIL_HELPER}; + use crate::protocol::McpError; const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(15); @@ -32,6 +34,32 @@ pub struct ExternalMcpServer { pub args: Vec, #[serde(default)] pub env: BTreeMap, + /// Optional FreeBSD jail confinement. None = run on the host (default). + /// + /// External MCP servers are arbitrary third-party binaries — at least as + /// untrusted as the agents the spawner already jails — so they reuse the + /// same confinement primitive (`colibri_daemon::spawner::jail_wrap`) and + /// `COLIBRI_JAIL_PRIV_MODE` policy rather than growing a parallel one. + #[serde(default)] + pub jail: Option, +} + +impl ExternalMcpServer { + /// Resolve the `(program, argv)` to exec, applying optional jail + /// confinement + privilege escalation via the shared spawner policy. + /// With `jail == None` this is just `(command, args)`. + fn resolved_command(&self) -> (String, Vec) { + let priv_mode = PrivMode::from_env(); + let helper = std::env::var("COLIBRI_JAIL_HELPER") + .unwrap_or_else(|_| DEFAULT_JAIL_HELPER.to_string()); + jail_wrap( + &self.command, + &self.args, + self.jail.as_ref(), + &priv_mode, + &helper, + ) + } } impl ExternalMcpRegistry { @@ -119,8 +147,12 @@ struct ExternalMcpSession { impl ExternalMcpSession { async fn start(server: &ExternalMcpServer) -> Result { - let mut cmd = Command::new(&server.command); - cmd.args(&server.args) + // Apply optional jail confinement. With no jail this is (command, args); + // jexec/jail/mdo all inherit stdio, so the piped stdin/stdout below still + // reach the actual MCP server inside the jail. + let (program, argv) = server.resolved_command(); + let mut cmd = Command::new(&program); + cmd.args(&argv) .envs(&server.env) .stdin(Stdio::piped()) .stdout(Stdio::piped()) @@ -258,4 +290,52 @@ mod tests { assert_eq!(demo.args, vec!["server.js"]); assert_eq!(demo.env.get("DEMO").unwrap(), "1"); } + + #[test] + fn no_jail_resolves_to_bare_command() { + // jail == None ⇒ unchanged regardless of COLIBRI_JAIL_PRIV_MODE. + let s = ExternalMcpServer { + command: "mcp-fs".into(), + args: vec!["--root".into(), "/srv".into()], + env: BTreeMap::new(), + jail: None, + }; + let (program, argv) = s.resolved_command(); + assert_eq!(program, "mcp-fs"); + assert_eq!(argv, vec!["--root".to_string(), "/srv".to_string()]); + } + + #[test] + fn jailed_server_wraps_via_jexec() { + // Exercise the shared primitive deterministically (no env dependency). + let jail = JailConfig { + name: Some("mcp0".into()), + ..Default::default() + }; + let (program, argv) = jail_wrap( + "mcp-fs", + &["--root".to_string(), "/srv".to_string()], + Some(&jail), + &PrivMode::None, + DEFAULT_JAIL_HELPER, + ); + assert_eq!(program, "jexec"); + assert_eq!( + argv, + vec![ + "mcp0".to_string(), + "mcp-fs".to_string(), + "--root".to_string(), + "/srv".to_string(), + ] + ); + } + + #[test] + fn jail_config_deserializes_from_registry_json() { + let raw = r#"{ "servers": { "fs": { "command": "mcp-fs", "jail": { "name": "mcp0" } } } }"#; + let registry: ExternalMcpRegistry = serde_json::from_str(raw).unwrap(); + let fs = registry.servers.get("fs").unwrap(); + assert_eq!(fs.jail.as_ref().unwrap().name.as_deref(), Some("mcp0")); + } } diff --git a/docs/COLIBRI-EXTERNAL-MCP-PROTOTYPE.md b/docs/COLIBRI-EXTERNAL-MCP-PROTOTYPE.md index 47366fd..a11b8c3 100644 --- a/docs/COLIBRI-EXTERNAL-MCP-PROTOTYPE.md +++ b/docs/COLIBRI-EXTERNAL-MCP-PROTOTYPE.md @@ -42,12 +42,34 @@ Schema: "args": ["--stdio"], "env": { "DEMO_MODE": "1" - } + }, + "jail": { "name": "mcp0" } } } } ``` +The optional `jail` field confines the server in a FreeBSD jail (see below). + +## Confinement (`jail`) + +External MCP servers are arbitrary third-party binaries — at least as untrusted +as the agents Colibri already jails — so the host reuses the agent spawner's +confinement primitive rather than growing a parallel one: +`colibri_daemon::spawner::{jail_wrap, JailConfig, PrivMode}`. + +Per-server `jail` accepts the same shape as the agent spawner +(`docs/COLIBRI-JAILED-AGENT-SPAWN-DESIGN.md`): + +- `{ "name": "" }` — enter an existing persistent jail via `jexec`. +- `{ "path": "/var/jails/mcp" }` — ephemeral `jail -c command=…` for the call. +- optional `ip4`, `user`. + +The root-only jail step uses the shared `COLIBRI_JAIL_PRIV_MODE` policy +(`mdo` on the live USB, `helper` on deployed hosts). Omit `jail` to run the +server on the host, as before. stdio still flows through `jexec`/`jail`/`mdo`, +so the stdin/stdout JSON-RPC transport is unaffected. + ## Trusted call mode External tool calls are disabled unless explicitly enabled: