Add Workstream G: out-of-process, Landlock-sandboxed module runtime

Closes the gap Workstream D's in-process capability scoping left open:
build_scoped_env only gated presence of bread.* bindings, but os.execute/
io.open/debug.* remained fully reachable since a module's Lua still ran
inside breadd's own process. A module that declares [[permissions]] in
bread.module.toml (including an explicit empty list) is now spawned as a
separate bread-module-host process instead, restricted by a Landlock
ruleset breadd builds from that module's granted permissions and applies
via Command::pre_exec before the child executes any Lua at all. A module
with no manifest at all keeps today's in-process, ungated behavior for
backward compatibility.

- bread-module-host: new minimal binary (mlua + tokio + serde_json) that
  connects to breadd's existing IPC socket, presents a one-time spawn
  token, and proxies bread.* calls as RPC instead of direct bindings.
- breadd/src/module_host.rs: spawn + token registry + apply_sandbox
  (Landlock ruleset construction), with unit tests that spawn a real
  child and verify denial at the OS level, not a Lua-level check.
- breadd/src/ipc/module_host_bridge.rs: the module_host.* RPC bridge
  (on/once/off/emit/after/every/cancel, fs.read/write, exec/exec_capture,
  state.get, log/warn/error, status) plus the hello handshake. Bumped
  API_VERSION to 1.6.0.
- breadd/tests/module_host_sandbox.rs: end-to-end acceptance tests going
  through a real spawned breadd + bread-module-host + IPC handshake —
  os.execute/io.open denied outside a module's granted fs.read scope, and
  kill -9 on a module-host child leaving breadd and other modules intact
  while breadd reports bread.module.crashed.
- bread-shared/src/module_host_ipc.rs: shared wire types (hello result,
  tagged event/timer push envelope) so breadd and bread-module-host can't
  drift on the handshake/push shape.

Deferred (documented in Documentation.md's Workstream G section): the
trust="in-process" opt-out, remaining bread.* namespaces over RPC
(hyprland/widget/machine/bluetooth/notify/state.watch), network
sandboxing, and a fully static build that would remove the Execute grant
Landlock's dynamic-linker requirement forces on system library dirs.
This commit is contained in:
Breadway 2026-08-05 04:02:05 +08:00
parent 450454d164
commit 1e2817537b
17 changed files with 3585 additions and 70 deletions

View file

@ -0,0 +1,552 @@
//! The `module_host.*` side of the IPC protocol (Workstream G): once a
//! connection presents a valid one-time token via `module_host.hello`, this
//! module takes over its remaining lifetime as a bidirectional RPC bridge —
//! ordinary request/response lines interleaved with unsolicited
//! event/timer pushes — for exactly one `bread-module-host` child.
//!
//! # Wire shape
//!
//! Requests/responses reuse the existing `IpcRequest`/`IpcResponse`
//! envelope unchanged. Pushes are a separate, `"push"`-tagged envelope
//! (`bread_shared::ModuleHostPush`) that never collides with a response —
//! see that type's doc comment. A single `mpsc` channel (`out_tx`/`out_rx`)
//! feeds one writer task so both kinds of outgoing line interleave safely
//! on the one underlying socket without any extra locking.
//!
//! # Where the "belt" is, relative to the "suspenders"
//!
//! Every method here re-checks the module's granted `PermissionKind`s
//! before doing anything — `fs_read`/`fs_write`/`exec`/`exec_capture`
//! additionally check the manifest's `path`/`bin` scoping hint. This is
//! the belt; `module_host::apply_sandbox`'s Landlock ruleset (enforced by
//! the kernel on the child process directly, independent of whether the
//! child even uses this RPC bridge at all) is the suspenders. A module
//! that skips this bridge entirely and calls `os.execute`/`io.open`
//! directly from Lua bypasses every check in this file — that's the
//! scenario the sandbox exists for, not this file.
use std::collections::HashMap;
use std::time::Duration;
use bread_shared::{glob, ModuleHostHello, ModuleHostPush, ModulePermission, PermissionKind};
use serde_json::{json, Value};
use tokio::io::{AsyncWriteExt, BufReader, Lines};
use tokio::net::unix::{OwnedReadHalf, OwnedWriteHalf};
use tokio::sync::{broadcast, mpsc};
use tokio::task::JoinHandle;
use tracing::{error, info, warn};
use uuid::Uuid;
use crate::core::types::ModuleLoadState;
use crate::module_host::ModuleHostOutcome;
use super::{IpcRequest, IpcResponse, Server, API_VERSION};
impl Server {
/// Authenticate a `module_host.hello` request against the pending-token
/// registry and, on success, run this connection's dedicated
/// request/response + push loop until it closes. Mirrors
/// `handle_connection`'s `events.subscribe` special-case in spirit
/// (taking over the rest of the connection's lifetime) but is
/// bidirectional rather than one-directional.
pub(super) async fn handle_module_host_connection(
&self,
hello_req: IpcRequest,
mut lines: Lines<BufReader<OwnedReadHalf>>,
write_half: OwnedWriteHalf,
) -> anyhow::Result<()> {
let hello_id = hello_req.id.clone();
let token = hello_req
.params
.get("token")
.and_then(Value::as_str)
.map(str::to_string);
let (out_tx, mut out_rx) = mpsc::unbounded_channel::<String>();
let writer_task = tokio::spawn(async move {
let mut write_half = write_half;
while let Some(line) = out_rx.recv().await {
if write_half.write_all(line.as_bytes()).await.is_err() {
break;
}
}
});
let send = |resp: IpcResponse| -> anyhow::Result<()> {
let line = format!("{}\n", serde_json::to_string(&resp)?);
let _ = out_tx.send(line);
Ok(())
};
let Some(token) = token else {
send(IpcResponse {
id: hello_id,
result: None,
error: Some("module_host.hello: missing token".to_string()),
})?;
drop(out_tx);
let _ = writer_task.await;
return Ok(());
};
let Some(pending) = self.module_host_registry.take_pending(&token) else {
send(IpcResponse {
id: hello_id,
result: None,
error: Some("invalid or expired module-host token".to_string()),
})?;
drop(out_tx);
let _ = writer_task.await;
return Ok(());
};
let module_name = pending.module_name.clone();
let permissions = pending.permissions.clone();
let mut outcome_tx = Some(pending.outcome_tx);
let hello_result = ModuleHostHello {
module: module_name.clone(),
permissions: permissions.clone(),
api_version: API_VERSION.to_string(),
};
send(IpcResponse {
id: hello_id,
result: Some(serde_json::to_value(&hello_result)?),
error: None,
})?;
info!(module = %module_name, permissions = ?permissions, "module-host authenticated");
let mut subs: HashMap<String, JoinHandle<()>> = HashMap::new();
let mut timers: HashMap<String, JoinHandle<()>> = HashMap::new();
loop {
let line = match lines.next_line().await {
Ok(Some(l)) => l,
Ok(None) => break,
Err(e) => {
warn!(module = %module_name, error = %e, "module-host connection read error");
break;
}
};
if line.trim().is_empty() {
continue;
}
let req: IpcRequest = match serde_json::from_str(&line) {
Ok(r) => r,
Err(e) => {
send(IpcResponse {
id: "?".to_string(),
result: None,
error: Some(format!("parse error: {e}")),
})?;
continue;
}
};
let req_id = req.id.clone();
let result = self
.dispatch_module_host_method(
&req,
&module_name,
&permissions,
&out_tx,
&mut subs,
&mut timers,
&mut outcome_tx,
)
.await;
let resp = match result {
Ok(v) => IpcResponse {
id: req_id,
result: Some(v),
error: None,
},
Err(e) => IpcResponse {
id: req_id,
result: None,
error: Some(e),
},
};
send(resp)?;
}
for (_, h) in subs.drain() {
h.abort();
}
for (_, h) in timers.drain() {
h.abort();
}
drop(out_tx);
let _ = writer_task.await;
// The connection dropped before the module ever reported
// load-success/load-failure (e.g. it crashed mid-`init.lua`, or
// never got that far) — unblock whatever's still waiting in
// `spawn_module_host` rather than leaving it to time out.
if let Some(tx) = outcome_tx.take() {
let _ = tx.send(ModuleHostOutcome::LoadError(format!(
"module-host connection for '{module_name}' closed before reporting ready"
)));
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
async fn dispatch_module_host_method(
&self,
req: &IpcRequest,
module_name: &str,
permissions: &[ModulePermission],
out_tx: &mpsc::UnboundedSender<String>,
subs: &mut HashMap<String, JoinHandle<()>>,
timers: &mut HashMap<String, JoinHandle<()>>,
outcome_tx: &mut Option<std::sync::mpsc::Sender<ModuleHostOutcome>>,
) -> std::result::Result<Value, String> {
match req.method.as_str() {
"module_host.on" | "module_host.once" => {
let once = req.method == "module_host.once";
let pattern = req
.params
.get("pattern")
.and_then(Value::as_str)
.ok_or("missing pattern")?
.to_string();
let sub_id = Uuid::new_v4().to_string();
let mut rx = self.event_tx.subscribe();
let out_tx2 = out_tx.clone();
let sid = sub_id.clone();
let handle = tokio::spawn(async move {
loop {
match rx.recv().await {
Ok(evt) => {
if glob::matches_pattern(&pattern, &evt.event) {
let push = ModuleHostPush::Event {
subscription_id: sid.clone(),
event: evt,
};
let Ok(line) = serde_json::to_string(&push) else {
continue;
};
if out_tx2.send(format!("{line}\n")).is_err() {
break;
}
if once {
break;
}
}
}
Err(broadcast::error::RecvError::Lagged(_)) => continue,
Err(broadcast::error::RecvError::Closed) => break,
}
}
});
subs.insert(sub_id.clone(), handle);
Ok(json!({ "subscription_id": sub_id }))
}
"module_host.off" => {
let id = req
.params
.get("id")
.and_then(Value::as_str)
.ok_or("missing id")?
.to_string();
if let Some(h) = subs.remove(&id) {
h.abort();
}
Ok(json!({ "ok": true }))
}
"module_host.after" | "module_host.every" => {
let every = req.method == "module_host.every";
let key = if every { "interval_ms" } else { "delay_ms" };
let ms = req
.params
.get(key)
.and_then(Value::as_u64)
.unwrap_or(0)
.max(1);
let timer_id = Uuid::new_v4().to_string();
let out_tx2 = out_tx.clone();
let tid = timer_id.clone();
let handle = tokio::spawn(async move {
if every {
let mut iv = tokio::time::interval(Duration::from_millis(ms));
iv.tick().await; // first tick fires immediately; consume it so the module's first callback fires after one full interval
loop {
iv.tick().await;
let push = ModuleHostPush::Timer {
timer_id: tid.clone(),
};
let Ok(line) = serde_json::to_string(&push) else {
continue;
};
if out_tx2.send(format!("{line}\n")).is_err() {
break;
}
}
} else {
tokio::time::sleep(Duration::from_millis(ms)).await;
let push = ModuleHostPush::Timer { timer_id: tid };
if let Ok(line) = serde_json::to_string(&push) {
let _ = out_tx2.send(format!("{line}\n"));
}
}
});
timers.insert(timer_id.clone(), handle);
Ok(json!({ "timer_id": timer_id }))
}
"module_host.cancel" => {
let id = req
.params
.get("id")
.and_then(Value::as_str)
.ok_or("missing id")?
.to_string();
if let Some(h) = timers.remove(&id) {
h.abort();
}
Ok(json!({ "ok": true }))
}
"module_host.state_get" => {
if !permissions
.iter()
.any(|p| p.kind == PermissionKind::StateRead)
{
return Err("state.read not granted to this module".to_string());
}
let key = req
.params
.get("key")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
match self.state_handle.state_get(&key).await {
Some(v) => Ok(json!({ "value": v })),
None => Err("state path not found".to_string()),
}
}
"module_host.emit" => {
let event = req
.params
.get("event")
.and_then(Value::as_str)
.ok_or("missing event")?
.to_string();
let data = req.params.get("data").cloned().unwrap_or_else(|| json!({}));
self.manual_emit(&event, data)
}
"module_host.log" | "module_host.warn" | "module_host.error" => {
let message = req
.params
.get("message")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
match req.method.as_str() {
"module_host.log" => info!(module = %module_name, "{message}"),
"module_host.warn" => warn!(module = %module_name, "{message}"),
_ => error!(module = %module_name, "{message}"),
}
Ok(json!({ "ok": true }))
}
"module_host.fs_read" => {
if !permissions
.iter()
.any(|p| p.kind == PermissionKind::FsRead)
{
return Err("fs.read not granted to this module".to_string());
}
let path = req
.params
.get("path")
.and_then(Value::as_str)
.ok_or("missing path")?
.to_string();
if !path_allowed(permissions, PermissionKind::FsRead, &path) {
return Err(format!(
"path '{path}' is outside this module's granted fs.read scope"
));
}
let expanded = bread_shared::expand_path(&path);
let content = std::fs::read_to_string(&expanded).ok();
Ok(json!({ "content": content }))
}
"module_host.fs_write" => {
if !permissions
.iter()
.any(|p| p.kind == PermissionKind::FsWrite)
{
return Err("fs.write not granted to this module".to_string());
}
let path = req
.params
.get("path")
.and_then(Value::as_str)
.ok_or("missing path")?
.to_string();
if !path_allowed(permissions, PermissionKind::FsWrite, &path) {
return Err(format!(
"path '{path}' is outside this module's granted fs.write scope"
));
}
let content = req
.params
.get("content")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let expanded = bread_shared::expand_path(&path);
if let Some(parent) = expanded.parent() {
let _ = std::fs::create_dir_all(parent);
}
std::fs::write(&expanded, content).map_err(|e| e.to_string())?;
Ok(json!({ "ok": true }))
}
"module_host.exec" => {
if !permissions.iter().any(|p| p.kind == PermissionKind::Exec) {
return Err("exec not granted to this module".to_string());
}
let cmd = req
.params
.get("cmd")
.and_then(Value::as_str)
.ok_or("missing cmd")?
.to_string();
if !bin_allowed(permissions, &cmd) {
return Err("command is outside this module's granted exec bin scope".to_string());
}
tokio::task::spawn_blocking(move || {
match std::process::Command::new("sh").arg("-c").arg(&cmd).status() {
Ok(status) if !status.success() => {
warn!(cmd = %cmd, code = ?status.code(), "module_host.exec exited non-zero");
}
Err(e) => {
error!(cmd = %cmd, error = %e, "module_host.exec failed to spawn");
}
_ => {}
}
});
Ok(json!({ "ok": true }))
}
"module_host.exec_capture" => {
if !permissions.iter().any(|p| p.kind == PermissionKind::Exec) {
return Err("exec not granted to this module".to_string());
}
let cmd = req
.params
.get("cmd")
.and_then(Value::as_str)
.ok_or("missing cmd")?
.to_string();
if !bin_allowed(permissions, &cmd) {
return Err("command is outside this module's granted exec bin scope".to_string());
}
let timeout_ms = req
.params
.get("timeout_ms")
.and_then(Value::as_u64)
.unwrap_or(2000);
let handle =
tokio::task::spawn_blocking(move || {
std::process::Command::new("sh").arg("-c").arg(&cmd).output()
});
match tokio::time::timeout(Duration::from_millis(timeout_ms + 500), handle).await {
Ok(Ok(Ok(out))) => Ok(json!({
"ok": out.status.success(),
"stdout": String::from_utf8_lossy(&out.stdout),
})),
_ => Ok(json!({ "ok": false, "stdout": "" })),
}
}
"module_host.status" => {
let state = req
.params
.get("state")
.and_then(Value::as_str)
.unwrap_or("load_error");
let error = req
.params
.get("error")
.and_then(Value::as_str)
.map(str::to_string);
let (load_state, outcome) = if state == "loaded" {
(ModuleLoadState::Loaded, ModuleHostOutcome::Ready)
} else {
(
ModuleLoadState::LoadError,
ModuleHostOutcome::LoadError(
error.clone().unwrap_or_else(|| "module load failed".to_string()),
),
)
};
// Out-of-process modules are never "ungated": they only
// exist in this branch because they declared a manifest
// (see lua/mod.rs's load_module), so `ungated=false`
// unconditionally here is correct, not a placeholder.
self.state_handle.set_module_status_ex(
module_name.to_string(),
load_state,
error,
false,
false,
);
if let Some(tx) = outcome_tx.take() {
let _ = tx.send(outcome);
}
Ok(json!({ "ok": true }))
}
other => Err(format!("unknown module_host method: {other}")),
}
}
}
/// Belt-and-suspenders path scoping for `fs_read`/`fs_write`: if the
/// manifest declared a `path` hint for this permission kind, the requested
/// path (after `~`-expansion) must fall under at least one granted prefix.
/// No hint at all means this RPC-level check stays permissive (matching
/// Workstream D's existing "un-hinted grant = ungated within that
/// namespace" behavior) — Landlock's own ruleset (built independently in
/// `module_host::apply_sandbox`) does NOT grant a filesystem rule for an
/// un-hinted permission, so the direct `os`/`io` escape hatch remains
/// kernel-denied for that case regardless of what this function returns.
fn path_allowed(permissions: &[ModulePermission], kind: PermissionKind, path: &str) -> bool {
let hints: Vec<&String> = permissions
.iter()
.filter(|p| p.kind == kind)
.filter_map(|p| p.path.as_ref())
.collect();
if hints.is_empty() {
return true;
}
let expanded = bread_shared::expand_path(path);
hints.iter().any(|hint| {
let hint_expanded = bread_shared::expand_path(hint);
expanded.starts_with(&hint_expanded)
})
}
/// Same idea as [`path_allowed`] for `exec`'s `bin` hint: compares by file
/// name (so `bin = "hyprpaper"` matches a command invoking
/// `/usr/bin/hyprpaper` as well as a bare `hyprpaper`) or an exact leading
/// token match.
fn bin_allowed(permissions: &[ModulePermission], cmd: &str) -> bool {
let hints: Vec<&String> = permissions
.iter()
.filter(|p| p.kind == PermissionKind::Exec)
.filter_map(|p| p.bin.as_ref())
.collect();
if hints.is_empty() {
return true;
}
let first_word = cmd.split_whitespace().next().unwrap_or("");
let cmd_leaf = std::path::Path::new(first_word)
.file_name()
.and_then(|f| f.to_str())
.unwrap_or(first_word);
hints.iter().any(|hint| {
let hint_leaf = std::path::Path::new(hint.as_str())
.file_name()
.and_then(|f| f.to_str())
.unwrap_or(hint.as_str());
cmd_leaf == hint_leaf || first_word == hint.as_str()
})
}