feat(mcp): confine external MCP servers in a jail (reuse spawner primitive)
Some checks failed
CI / rust (pull_request) Has been cancelled
CI / markdown (pull_request) Has been cancelled

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<JailConfig>` (#[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 <noreply@anthropic.com>
This commit is contained in:
Sam & Claude 2026-06-13 20:08:24 +02:00
parent e0a1298809
commit 87c075d6ba
2 changed files with 105 additions and 3 deletions

View file

@ -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<String>,
#[serde(default)]
pub env: BTreeMap<String, String>,
/// 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<JailConfig>,
}
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<String>) {
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<Self, McpError> {
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"));
}
}

View file

@ -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": "<jail>" }` — 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: