diff --git a/README.md b/README.md index f964289..7be38b0 100644 --- a/README.md +++ b/README.md @@ -9,21 +9,22 @@ with cache-first cost discipline (byte-stable prompt prefixes, cache-hit meterin Next ISO integration plan: `docs/ISO-INTEGRATION-PLAN.md`. ISO acceptance runbook: `docs/ISO-ACCEPTANCE-RUNBOOK.md`. Clawdie Studio/Zed proposal: `docs/CLAWDIE-STUDIO-PROPOSAL.md`. +External MCP host prototype: `docs/COLIBRI-EXTERNAL-MCP-PROTOTYPE.md`. ## Workspace — 10 crates -| Crate | Role | -| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | -| `colibri-mcp` | MCP bridge for editor integration (Zed, Claude Code) via stdio JSON-RPC | -| `colibri-contracts` | JSON schema contracts (golden tests) | -| `colibri-deepseek` | DeepSeek cache-hit probe, prefix metering | -| `colibri-runtime` | Host status ingestion, runtime inventory | -| `colibri-glasspane` | Agent 5-state machine (Pi events → state) | -| `colibri-daemon` | Always-on Unix socket server, session lifecycle | -| `colibri-client` | Typed Unix-socket client + operator CLI | -| `colibri-glasspane-tui` | ratatui live dashboard (FreeBSD-native) | -| `colibri-store` | Embedded SQLite coordination (task board, agents, skills) | -| `colibri-skills` | Skills catalog crate | +| Crate | Role | +| ----------------------- | ----------------------------------------------------------------------- | +| `colibri-mcp` | MCP bridge for editor integration (Zed, Claude Code) via stdio JSON-RPC | +| `colibri-contracts` | JSON schema contracts (golden tests) | +| `colibri-deepseek` | DeepSeek cache-hit probe, prefix metering | +| `colibri-runtime` | Host status ingestion, runtime inventory | +| `colibri-glasspane` | Agent 5-state machine (Pi events → state) | +| `colibri-daemon` | Always-on Unix socket server, session lifecycle | +| `colibri-client` | Typed Unix-socket client + operator CLI | +| `colibri-glasspane-tui` | ratatui live dashboard (FreeBSD-native) | +| `colibri-store` | Embedded SQLite coordination (task board, agents, skills) | +| `colibri-skills` | Skills catalog crate | ## Build diff --git a/crates/colibri-daemon/src/spawner.rs b/crates/colibri-daemon/src/spawner.rs index 4a79eb2..77fd3d9 100644 --- a/crates/colibri-daemon/src/spawner.rs +++ b/crates/colibri-daemon/src/spawner.rs @@ -649,7 +649,13 @@ mod jail_tests { user: Some("clawdie".into()), ..Default::default() }; - let (exe, a) = jail_wrap("pi", &argv(&["x"]), Some(&j), &PrivMode::None, DEFAULT_JAIL_HELPER); + 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"])); } diff --git a/crates/colibri-mcp/Cargo.toml b/crates/colibri-mcp/Cargo.toml index 710b3f6..8adb862 100644 --- a/crates/colibri-mcp/Cargo.toml +++ b/crates/colibri-mcp/Cargo.toml @@ -15,7 +15,7 @@ colibri-daemon = { path = "../colibri-daemon" } clap = { version = "4", features = ["derive"] } serde = { version = "1", features = ["derive"] } serde_json = "1" -tokio = { version = "1", features = ["io-util", "macros", "rt-multi-thread"] } +tokio = { version = "1", features = ["fs", "io-util", "macros", "process", "rt-multi-thread", "time"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/crates/colibri-mcp/src/external.rs b/crates/colibri-mcp/src/external.rs new file mode 100644 index 0000000..204ccb0 --- /dev/null +++ b/crates/colibri-mcp/src/external.rs @@ -0,0 +1,261 @@ +//! Prototype MCP client/host support for connecting Colibri to external MCP servers. +//! +//! This is intentionally thin: stdio transport only, one process per request, +//! no long-lived registry, and no production-grade policy engine yet. + +use std::{collections::BTreeMap, path::Path, process::Stdio, time::Duration}; + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use tokio::{ + io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, + process::Command, + time::timeout, +}; + +use crate::protocol::McpError; + +const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(15); + +/// External MCP server registry file. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ExternalMcpRegistry { + #[serde(default)] + pub servers: BTreeMap, +} + +/// One stdio MCP server entry. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExternalMcpServer { + pub command: String, + #[serde(default)] + pub args: Vec, + #[serde(default)] + pub env: BTreeMap, +} + +impl ExternalMcpRegistry { + pub async fn load(path: &Path) -> Result { + let raw = tokio::fs::read_to_string(path).await.map_err(|e| { + McpError::internal(format!( + "failed to read external MCP config {}: {e}", + path.display() + )) + })?; + serde_json::from_str(&raw).map_err(|e| { + McpError::invalid_params(format!( + "invalid external MCP config {}: {e}", + path.display() + )) + }) + } + + pub fn summaries(&self) -> Value { + let servers: Vec = self + .servers + .iter() + .map(|(name, server)| { + json!({ + "name": name, + "command": server.command, + "args": server.args, + "env_keys": server.env.keys().collect::>() + }) + }) + .collect(); + json!({ "servers": servers }) + } +} + +/// Load a registry if configured and present. Missing config is not an error; +/// it just means no external MCP servers are configured yet. +pub async fn load_registry_if_present(path: &Path) -> Result { + match tokio::fs::try_exists(path).await { + Ok(true) => ExternalMcpRegistry::load(path).await, + Ok(false) => Ok(ExternalMcpRegistry::default()), + Err(e) => Err(McpError::internal(format!( + "failed to stat external MCP config {}: {e}", + path.display() + ))), + } +} + +/// List tools from one external MCP server. +pub async fn list_tools(server: &ExternalMcpServer) -> Result { + let mut session = ExternalMcpSession::start(server).await?; + session.initialize().await?; + let result = session.request("tools/list", json!({})).await?; + session.shutdown().await; + Ok(result) +} + +/// Call a tool on one external MCP server. +pub async fn call_tool( + server: &ExternalMcpServer, + name: &str, + arguments: Value, +) -> Result { + let mut session = ExternalMcpSession::start(server).await?; + session.initialize().await?; + let result = session + .request( + "tools/call", + json!({ + "name": name, + "arguments": arguments, + }), + ) + .await?; + session.shutdown().await; + Ok(result) +} + +struct ExternalMcpSession { + child: tokio::process::Child, + stdin: tokio::process::ChildStdin, + stdout: BufReader, + next_id: i64, +} + +impl ExternalMcpSession { + async fn start(server: &ExternalMcpServer) -> Result { + let mut cmd = Command::new(&server.command); + cmd.args(&server.args) + .envs(&server.env) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + + let mut child = cmd.spawn().map_err(|e| { + McpError::internal(format!( + "failed to spawn external MCP server {}: {e}", + server.command + )) + })?; + let stdin = child.stdin.take().ok_or_else(|| { + McpError::internal("external MCP server stdin was not piped".to_string()) + })?; + let stdout = child.stdout.take().ok_or_else(|| { + McpError::internal("external MCP server stdout was not piped".to_string()) + })?; + + Ok(Self { + child, + stdin, + stdout: BufReader::new(stdout), + next_id: 1, + }) + } + + async fn initialize(&mut self) -> Result<(), McpError> { + self.request( + "initialize", + json!({ + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": { + "name": "colibri-mcp-host-prototype", + "version": env!("CARGO_PKG_VERSION") + } + }), + ) + .await?; + self.notify("notifications/initialized", json!({})).await + } + + async fn request(&mut self, method: &str, params: Value) -> Result { + let id = self.next_id; + self.next_id += 1; + let line = json!({ + "jsonrpc": "2.0", + "id": id, + "method": method, + "params": params, + }); + self.write_line(&line).await?; + + let response = self.read_response().await?; + if let Some(error) = response.get("error") { + return Err(McpError::internal(format!( + "external MCP server returned error for {method}: {error}" + ))); + } + response.get("result").cloned().ok_or_else(|| { + McpError::internal(format!("external MCP response missing result for {method}")) + }) + } + + async fn notify(&mut self, method: &str, params: Value) -> Result<(), McpError> { + let line = json!({ + "jsonrpc": "2.0", + "method": method, + "params": params, + }); + self.write_line(&line).await + } + + async fn write_line(&mut self, value: &Value) -> Result<(), McpError> { + let mut raw = serde_json::to_string(value) + .map_err(|e| McpError::internal(format!("failed to encode MCP request: {e}")))?; + raw.push('\n'); + timeout( + DEFAULT_REQUEST_TIMEOUT, + self.stdin.write_all(raw.as_bytes()), + ) + .await + .map_err(|_| McpError::internal("external MCP write timed out".to_string()))? + .map_err(|e| McpError::internal(format!("external MCP write failed: {e}")))?; + timeout(DEFAULT_REQUEST_TIMEOUT, self.stdin.flush()) + .await + .map_err(|_| McpError::internal("external MCP flush timed out".to_string()))? + .map_err(|e| McpError::internal(format!("external MCP flush failed: {e}"))) + } + + async fn read_response(&mut self) -> Result { + let mut raw = String::new(); + let read = timeout(DEFAULT_REQUEST_TIMEOUT, self.stdout.read_line(&mut raw)) + .await + .map_err(|_| McpError::internal("external MCP read timed out".to_string()))? + .map_err(|e| McpError::internal(format!("external MCP read failed: {e}")))?; + if read == 0 { + return Err(McpError::internal( + "external MCP server closed stdout".to_string(), + )); + } + serde_json::from_str(raw.trim_end()).map_err(|e| { + McpError::internal(format!( + "external MCP response was not valid JSON: {e}: {raw}" + )) + }) + } + + async fn shutdown(&mut self) { + let _ = self.child.kill().await; + let _ = self.child.wait().await; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_registry() { + let raw = r#" + { + "servers": { + "demo": { + "command": "node", + "args": ["server.js"], + "env": { "DEMO": "1" } + } + } + } + "#; + let registry: ExternalMcpRegistry = serde_json::from_str(raw).unwrap(); + let demo = registry.servers.get("demo").unwrap(); + assert_eq!(demo.command, "node"); + assert_eq!(demo.args, vec!["server.js"]); + assert_eq!(demo.env.get("DEMO").unwrap(), "1"); + } +} diff --git a/crates/colibri-mcp/src/lib.rs b/crates/colibri-mcp/src/lib.rs index b860d66..17890e6 100644 --- a/crates/colibri-mcp/src/lib.rs +++ b/crates/colibri-mcp/src/lib.rs @@ -18,8 +18,10 @@ //! //! Write tools require `COLIBRI_MCP_WRITE=1`. +mod external; mod protocol; +pub use external::{ExternalMcpRegistry, ExternalMcpServer}; pub use protocol::{McpError, McpRequest, McpResponse, McpResult, McpValue, ToolHandler}; use std::path::PathBuf; @@ -46,15 +48,19 @@ pub fn resolve_socket_path() -> PathBuf { pub struct McpServerConfig { pub socket_path: PathBuf, pub write_enabled: bool, + pub external_config_path: PathBuf, + pub external_call_enabled: bool, } impl Default for McpServerConfig { fn default() -> Self { Self { socket_path: resolve_socket_path(), - write_enabled: std::env::var("COLIBRI_MCP_WRITE") - .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) - .unwrap_or(false), + write_enabled: env_bool("COLIBRI_MCP_WRITE"), + external_config_path: std::env::var("COLIBRI_MCP_EXTERNAL_CONFIG") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from("/usr/local/etc/colibri/external-mcp.json")), + external_call_enabled: env_bool("COLIBRI_MCP_EXTERNAL_CALL"), } } } @@ -139,6 +145,37 @@ pub fn tool_list() -> Vec { "required": ["mode"] })), ), + json_tool( + "colibri_external_mcp_servers", + "List configured external MCP servers from COLIBRI_MCP_EXTERNAL_CONFIG", + None, + ), + json_tool( + "colibri_external_mcp_list_tools", + "List tools exposed by configured external MCP servers (stdio prototype)", + Some(serde_json::json!({ + "type": "object", + "properties": { + "server": { + "type": "string", + "description": "Optional server name. If omitted, lists tools for all configured servers." + } + } + })), + ), + json_tool( + "colibri_external_mcp_call_tool", + "Call a tool on an external MCP server (requires COLIBRI_MCP_EXTERNAL_CALL=1)", + Some(serde_json::json!({ + "type": "object", + "properties": { + "server": { "type": "string", "description": "External MCP server name" }, + "name": { "type": "string", "description": "External MCP tool name" }, + "arguments": { "type": "object", "description": "Tool arguments" } + }, + "required": ["server", "name"] + })), + ), ] } @@ -241,6 +278,62 @@ pub async fn dispatch_tool( "note": "Cost mode change is runtime-only/status-intent until live config mutation exists." }))) } + "colibri_external_mcp_servers" => { + let registry = external::load_registry_if_present(&config.external_config_path).await?; + Ok(tool_text(serde_json::json!({ + "config_path": config.external_config_path, + "external_call_enabled": config.external_call_enabled, + "registry": registry.summaries() + }))) + } + "colibri_external_mcp_list_tools" => { + let registry = external::load_registry_if_present(&config.external_config_path).await?; + let filter = optional_string(arguments, "server"); + let mut servers = Vec::new(); + for (server_name, server) in registry.servers.iter() { + if filter + .as_deref() + .is_some_and(|wanted| wanted != server_name) + { + continue; + } + let tools = external::list_tools(server).await?; + servers.push(serde_json::json!({ + "server": server_name, + "tools": tools.get("tools").cloned().unwrap_or(tools) + })); + } + if let Some(wanted) = filter { + if servers.is_empty() { + return Err(McpError::not_found(format!( + "external MCP server not configured: {wanted}" + ))); + } + } + Ok(tool_text(serde_json::json!({ + "config_path": config.external_config_path, + "servers": servers + }))) + } + "colibri_external_mcp_call_tool" => { + require_external_call(config)?; + let server_name = require_string(arguments, "server")?; + let tool_name = require_string(arguments, "name")?; + let tool_args = arguments + .get("arguments") + .cloned() + .unwrap_or_else(|| serde_json::json!({})); + let registry = external::load_registry_if_present(&config.external_config_path).await?; + let server = registry.servers.get(&server_name).ok_or_else(|| { + McpError::not_found(format!("external MCP server not configured: {server_name}")) + })?; + let data = external::call_tool(server, &tool_name, tool_args).await?; + Ok(tool_text(serde_json::json!({ + "server": server_name, + "tool": tool_name, + "result": data + }))) + } other => Err(McpError::not_found(format!("unknown tool: {other}"))), } } @@ -249,6 +342,12 @@ pub async fn dispatch_tool( // Helpers // --------------------------------------------------------------------------- +fn env_bool(name: &str) -> bool { + std::env::var(name) + .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) + .unwrap_or(false) +} + fn require_write(config: &McpServerConfig) -> Result<(), McpError> { if config.write_enabled { Ok(()) @@ -259,6 +358,16 @@ fn require_write(config: &McpServerConfig) -> Result<(), McpError> { } } +fn require_external_call(config: &McpServerConfig) -> Result<(), McpError> { + if config.external_call_enabled { + Ok(()) + } else { + Err(McpError::permission( + "external MCP tool calls require COLIBRI_MCP_EXTERNAL_CALL=1", + )) + } +} + fn require_string(args: &Value, key: &str) -> Result { args.get(key) .and_then(|v| v.as_str()) diff --git a/crates/colibri-mcp/src/main.rs b/crates/colibri-mcp/src/main.rs index 69f603a..d8ac31e 100644 --- a/crates/colibri-mcp/src/main.rs +++ b/crates/colibri-mcp/src/main.rs @@ -7,6 +7,8 @@ //! COLIBRI_MCP_SOCKET — override daemon socket path //! COLIBRI_DAEMON_SOCKET — fallback daemon socket path //! COLIBRI_MCP_WRITE=1 — enable write tools (create/intake/set-cost-mode) +//! COLIBRI_MCP_EXTERNAL_CONFIG — external MCP server registry JSON +//! COLIBRI_MCP_EXTERNAL_CALL=1 — enable external MCP tools/call proxying use std::process::ExitCode; @@ -26,6 +28,14 @@ struct Cli { #[arg(long)] write: bool, + /// Path to external MCP server registry JSON + #[arg(long)] + external_config: Option, + + /// Enable calls to external MCP server tools + #[arg(long)] + external_call: bool, + #[command(subcommand)] command: Option, } @@ -57,6 +67,12 @@ async fn main() -> ExitCode { if cli.write { config.write_enabled = true; } + if let Some(ref p) = cli.external_config { + config.external_config_path = std::path::PathBuf::from(p); + } + if cli.external_call { + config.external_call_enabled = true; + } match cli.command { Some(SubCmd::Tools) => { diff --git a/crates/colibri-mcp/tests/tool_dispatch.rs b/crates/colibri-mcp/tests/tool_dispatch.rs index 065832c..fbe7971 100644 --- a/crates/colibri-mcp/tests/tool_dispatch.rs +++ b/crates/colibri-mcp/tests/tool_dispatch.rs @@ -41,6 +41,8 @@ fn config(socket: PathBuf, write_enabled: bool) -> McpServerConfig { McpServerConfig { socket_path: socket, write_enabled, + external_config_path: PathBuf::from("/nonexistent/colibri-external-mcp-test.json"), + external_call_enabled: false, } } @@ -247,5 +249,8 @@ fn tool_list_has_all_phase1_tools() { assert!(names.contains(&"colibri_create_task")); assert!(names.contains(&"colibri_intake_task")); assert!(names.contains(&"colibri_set_cost_mode")); - assert_eq!(names.len(), 7); + assert!(names.contains(&"colibri_external_mcp_servers")); + assert!(names.contains(&"colibri_external_mcp_list_tools")); + assert!(names.contains(&"colibri_external_mcp_call_tool")); + assert_eq!(names.len(), 10); } diff --git a/docs/COLIBRI-EXTERNAL-MCP-PROTOTYPE.md b/docs/COLIBRI-EXTERNAL-MCP-PROTOTYPE.md new file mode 100644 index 0000000..47366fd --- /dev/null +++ b/docs/COLIBRI-EXTERNAL-MCP-PROTOTYPE.md @@ -0,0 +1,86 @@ +# Colibri external MCP host prototype + +**Status:** prototype. `colibri-mcp` is still primarily an MCP server that exposes +Colibri to editors/assistants. This prototype also lets that server act as a +small MCP client/host for other stdio MCP servers. + +## What it does now + +`colibri-mcp` always exposes three prototype tools: + +- `colibri_external_mcp_servers` — read the configured external server registry +- `colibri_external_mcp_list_tools` — spawn configured servers and call `tools/list` +- `colibri_external_mcp_call_tool` — call an external server tool; gated by + `COLIBRI_MCP_EXTERNAL_CALL=1` + +The default is read-only/discovery: listing configured servers and their tools is +allowed, but calling external tools requires an explicit trusted profile. + +## Registry file + +By default, `colibri-mcp` looks for: + +```text +/usr/local/etc/colibri/external-mcp.json +``` + +Override with: + +```sh +COLIBRI_MCP_EXTERNAL_CONFIG=/path/to/external-mcp.json colibri-mcp +# or +colibri-mcp --external-config /path/to/external-mcp.json +``` + +Schema: + +```json +{ + "servers": { + "demo": { + "command": "/usr/local/bin/demo-mcp-server", + "args": ["--stdio"], + "env": { + "DEMO_MODE": "1" + } + } + } +} +``` + +## Trusted call mode + +External tool calls are disabled unless explicitly enabled: + +```sh +COLIBRI_MCP_EXTERNAL_CALL=1 colibri-mcp +# or +colibri-mcp --external-call +``` + +This is intentionally separate from `COLIBRI_MCP_WRITE=1`. A Colibri write tool +and an external MCP tool are different trust surfaces. + +## Quick smoke shape + +From an MCP client: + +```json +{ "name": "colibri_external_mcp_servers", "arguments": {} } +{ "name": "colibri_external_mcp_list_tools", "arguments": { "server": "demo" } } +{ "name": "colibri_external_mcp_call_tool", "arguments": { "server": "demo", "name": "tool_name", "arguments": {} } } +``` + +## Limits + +This prototype is intentionally simple: + +- stdio transport only +- one external process per request +- no long-lived connection pool +- no server/tool allowlist beyond the registry file +- no streaming tool results +- no production-grade secret manager integration + +That is enough for experimental ISO/operator work. A production host should add +policy, lifecycle management, audit logs, and per-tool permission controls. diff --git a/docs/COLIBRI-JAILED-AGENT-SPAWN-DESIGN.md b/docs/COLIBRI-JAILED-AGENT-SPAWN-DESIGN.md index 06c5760..b7388f6 100644 --- a/docs/COLIBRI-JAILED-AGENT-SPAWN-DESIGN.md +++ b/docs/COLIBRI-JAILED-AGENT-SPAWN-DESIGN.md @@ -17,7 +17,7 @@ anyway). See the agent-harness consolidation notes. Colibri is the **supervisor**, already models `AgentRuntime{Pi, Zot}`, and — critically — **already spawns pi**: `crates/colibri-daemon/src/spawner.rs` runs agent subprocesses, captures their stdout JSONL, and hands it to glasspane. -`socket.rs:345` even comments *"enables real Pi spawn."* Confinement is a +`socket.rs:345` even comments _"enables real Pi spawn."_ Confinement is a supervisor concern and root-adjacent, so it belongs here. zot stays a clean upstream mirror, untouched. @@ -32,7 +32,7 @@ SpawnAgent socket cmd (lib.rs:40) → glasspane apply_pi_event (AgentRuntime::Pi, socket.rs:410) ``` -Everything except *what binary gets exec'd* is jail-agnostic and stays as-is. +Everything except _what binary gets exec'd_ is jail-agnostic and stays as-is. ## Design @@ -109,34 +109,35 @@ pi shows up as `AgentRuntime::Pi` with zero glasspane changes. ### 3. Teardown — `AgentHandle::kill` (spawner.rs:197) Add a jail-aware branch: + - **jexec:** kill the **process group** (`-pid`); killing the jexec process alone does not reliably reap the in-jail child. - **ephemeral jail:** also `jail -r ` so jails are not leaked per spawn. ## Privilege model — the decision -Jail *attach* (`jexec`) and *create* (`jail`) are **root-only** in base FreeBSD; +Jail _attach_ (`jexec`) and _create_ (`jail`) are **root-only** in base FreeBSD; there is no unprivileged path. But `colibri_daemon` runs as the unprivileged `colibri` user (`nologin`), so it cannot attach a jail by itself. Two ways to cross that line — and we pick **per deployment context**, matching the live-vs-deployed split. -The deciding fact: the ISO's mac_do rules are **identity** mappings, not command +The deciding fact: the ISO's mac*do rules are **identity** mappings, not command filters — `security.mac.do.rules=gid=0>uid=0` (clawdie-iso `build.sh:1274`) means -"wheel may become root." mac_do **cannot** restrict *which* command runs as root. +"wheel may become root." mac_do **cannot** restrict \_which* command runs as root. -| | `mdo -u root` | setuid/Capsicum helper | -|---|---|---| -| New privileged binary to write+audit | none (reuses mac_do) | yes | -| Kernel-enforced | yes | yes | -| Non-interactive from daemon | yes (no password prompt) | yes | -| Root blast radius if daemon is popped | **full root** | **just jexec-pi** | -| Extra setup | one mac_do rule | helper + install | +| | `mdo -u root` | setuid/Capsicum helper | +| ------------------------------------- | ------------------------ | ---------------------- | +| New privileged binary to write+audit | none (reuses mac_do) | yes | +| Kernel-enforced | yes | yes | +| Non-interactive from daemon | yes (no password prompt) | yes | +| Root blast radius if daemon is popped | **full root** | **just jexec-pi** | +| Extra setup | one mac_do rule | helper + install | -Because mac_do is command-blind, **wrapping mdo in a helper does NOT narrow it**: +Because mac*do is command-blind, **wrapping mdo in a helper does NOT narrow it**: once `colibri` may `mdo -u root`, a compromise just runs `mdo -u root sh`. The helper is hygiene, not a boundary. Only a setuid/Capsicum helper (where colibri -is *not* granted general root) is a true boundary. +is \_not* granted general root) is a true boundary. ### Decision @@ -172,13 +173,13 @@ packaging stages `Helper`), so the same spawner serves both. ## Scope summary -| Piece | Effort | Where | -|---|---|---| -| `JailConfig` + field | trivial | spawner.rs:84, lib.rs:40 | -| `jail_wrap` + call site | small | spawner.rs:341 | -| jail-aware `kill` / `-r` | small | spawner.rs:197 | -| `PrivMode` (mdo vs helper) selection | small | daemon config | -| glasspane observation | none | already works | -| zot changes | none | mirror untouched | -| setuid `colibri-jail-spawn` helper | medium + security review | new (deploy lane) | -| jail FS provisioning | medium (ops) | ISO / deploy packaging | +| Piece | Effort | Where | +| ------------------------------------ | ------------------------ | ------------------------ | +| `JailConfig` + field | trivial | spawner.rs:84, lib.rs:40 | +| `jail_wrap` + call site | small | spawner.rs:341 | +| jail-aware `kill` / `-r` | small | spawner.rs:197 | +| `PrivMode` (mdo vs helper) selection | small | daemon config | +| glasspane observation | none | already works | +| zot changes | none | mirror untouched | +| setuid `colibri-jail-spawn` helper | medium + security review | new (deploy lane) | +| jail FS provisioning | medium (ops) | ISO / deploy packaging |