Merge pull request 'feat(mcp): prototype external MCP host tools (Sam & Codex)' (#36) from feat/external-mcp-client-prototype into main
Some checks are pending
CI / rust (push) Waiting to run
CI / markdown (push) Waiting to run

Reviewed-on: #36
This commit is contained in:
clawdie 2026-06-13 19:55:15 +02:00
commit e0a1298809
9 changed files with 527 additions and 42 deletions

View file

@ -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

View file

@ -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"]));
}

View file

@ -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"] }

View file

@ -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<String, ExternalMcpServer>,
}
/// One stdio MCP server entry.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExternalMcpServer {
pub command: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub env: BTreeMap<String, String>,
}
impl ExternalMcpRegistry {
pub async fn load(path: &Path) -> Result<Self, McpError> {
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<Value> = self
.servers
.iter()
.map(|(name, server)| {
json!({
"name": name,
"command": server.command,
"args": server.args,
"env_keys": server.env.keys().collect::<Vec<_>>()
})
})
.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<ExternalMcpRegistry, McpError> {
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<Value, McpError> {
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<Value, McpError> {
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<tokio::process::ChildStdout>,
next_id: i64,
}
impl ExternalMcpSession {
async fn start(server: &ExternalMcpServer) -> Result<Self, McpError> {
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<Value, McpError> {
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<Value, McpError> {
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");
}
}

View file

@ -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<Value> {
"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<String, McpError> {
args.get(key)
.and_then(|v| v.as_str())

View file

@ -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<String>,
/// Enable calls to external MCP server tools
#[arg(long)]
external_call: bool,
#[command(subcommand)]
command: Option<SubCmd>,
}
@ -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) => {

View file

@ -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);
}

View file

@ -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.

View file

@ -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 <name>` 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 |