feat(mcp): confine external MCP servers in a jail (reuse spawner primitive) #38
2 changed files with 105 additions and 3 deletions
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue