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,29 @@
[package]
name = "bread-module-host"
version = "0.7.0"
edition = "2021"
[[bin]]
name = "bread-module-host"
path = "src/main.rs"
# Deliberately minimal dependency footprint (Workstream G): this binary is
# itself reviewable attack surface running third-party Lua under an
# OS-level sandbox constructed by breadd's parent process (see
# breadd/src/module_host.rs) — it does not depend on landlock itself, since
# the Landlock ruleset is applied by breadd via Command::pre_exec() *before*
# this binary's own main() ever runs (landlock_restrict_self() applies to
# the calling process across the subsequent execve()).
[dependencies]
bread-shared = { path = "../bread-shared" }
serde.workspace = true
serde_json.workspace = true
tokio = { version = "1.40", features = ["net", "io-util", "rt", "rt-multi-thread", "time", "macros", "sync"] }
anyhow.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
mlua = { version = "0.9", features = ["lua54", "vendored", "serialize"] }
uuid.workspace = true
[dev-dependencies]
tempfile.workspace = true

266
bread-module-host/src/io.rs Normal file
View file

@ -0,0 +1,266 @@
//! The async half of `bread-module-host`: owns the Unix socket connection
//! back to `breadd` and speaks the newline-delimited-JSON IPC protocol
//! (`breadd/src/ipc/mod.rs`), extended with `module_host.*` methods (see
//! `breadd/src/module_host.rs` for the server side of this bridge).
//!
//! Runs on its own dedicated OS thread with its own single-threaded Tokio
//! runtime — mirroring `breadd`'s own `spawn_runtime` split between an async
//! IPC/adapters world and a synchronous, single-threaded Lua world (see
//! `breadd/src/lua/mod.rs`'s `spawn_runtime`). The Lua-driving thread in
//! `main.rs` talks to this thread over two plain `std::sync::mpsc` channels
//! (`IoCommand` out, `HostMessage` in) rather than sharing an async runtime,
//! since `mlua::Lua` values are not `Send` and Lua callbacks need to make
//! synchronous (blocking, from Lua's point of view) RPC calls.
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{mpsc, Arc, Mutex};
use std::time::Duration;
use bread_shared::{ModuleHostHello, ModuleHostPush};
use serde_json::{json, Value};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::UnixStream;
use tracing::warn;
/// A request the Lua-driving thread wants sent to `breadd`, with a reply
/// channel for the (blocking, from Lua's perspective) response.
pub enum IoCommand {
Request {
method: String,
params: Value,
reply: mpsc::Sender<Result<Value, String>>,
},
}
/// Something the IO thread has for the Lua-driving thread: either an
/// unsolicited push (a subscribed event fired, a timer fired) or "the
/// connection is gone" (breadd exited, socket closed, etc).
pub enum HostMessage {
Push(ModuleHostPush),
Closed,
}
#[derive(serde::Deserialize)]
struct RpcResponse {
#[allow(dead_code)]
id: String,
#[serde(default)]
result: Option<Value>,
#[serde(default)]
error: Option<String>,
}
/// Connect, perform the `module_host.hello` handshake, and — on success —
/// run the steady-state request/response + push-forwarding loop until the
/// connection closes. `hello_tx` is always sent to exactly once, before
/// anything else; the caller blocks on it to learn the module's granted
/// identity (or why the handshake failed) before doing anything else.
pub fn run(
socket_path: PathBuf,
token: String,
cmd_rx: mpsc::Receiver<IoCommand>,
host_tx: mpsc::Sender<HostMessage>,
hello_tx: mpsc::Sender<Result<ModuleHostHello, String>>,
) {
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(e) => {
let _ = hello_tx.send(Err(format!("failed to start io runtime: {e}")));
return;
}
};
rt.block_on(async move {
let stream = match UnixStream::connect(&socket_path).await {
Ok(s) => s,
Err(e) => {
let _ = hello_tx.send(Err(format!(
"failed to connect to {}: {e}",
socket_path.display()
)));
return;
}
};
let (read_half, mut write_half) = stream.into_split();
let mut lines = BufReader::new(read_half).lines();
let hello_req = json!({
"id": "hello",
"method": "module_host.hello",
"params": { "token": token },
});
let Ok(hello_line) = serde_json::to_string(&hello_req) else {
let _ = hello_tx.send(Err("failed to encode hello request".to_string()));
return;
};
if write_half
.write_all(format!("{hello_line}\n").as_bytes())
.await
.is_err()
{
let _ = hello_tx.send(Err("failed to write hello request".to_string()));
return;
}
let response_line = match lines.next_line().await {
Ok(Some(line)) => line,
Ok(None) => {
let _ = hello_tx.send(Err(
"connection closed before hello response".to_string(),
));
return;
}
Err(e) => {
let _ = hello_tx.send(Err(format!("read error awaiting hello: {e}")));
return;
}
};
let resp: RpcResponse = match serde_json::from_str(&response_line) {
Ok(r) => r,
Err(e) => {
let _ = hello_tx.send(Err(format!("malformed hello response: {e}")));
return;
}
};
if let Some(err) = resp.error {
let _ = hello_tx.send(Err(err));
return;
}
let hello: ModuleHostHello = match resp
.result
.and_then(|v| serde_json::from_value(v).ok())
{
Some(h) => h,
None => {
let _ = hello_tx.send(Err("hello response missing result".to_string()));
return;
}
};
if hello_tx.send(Ok(hello)).is_err() {
return;
}
// Steady state. `pending` routes response lines back to whichever
// Lua-side call is blocked waiting for them; a dedicated thread
// bridges the synchronous `cmd_rx` (fed from the Lua thread) onto an
// async channel this task can select on.
let pending: Arc<Mutex<HashMap<String, mpsc::Sender<Result<Value, String>>>>> =
Arc::new(Mutex::new(HashMap::new()));
let (async_cmd_tx, mut async_cmd_rx) = tokio::sync::mpsc::unbounded_channel::<IoCommand>();
std::thread::spawn(move || {
while let Ok(cmd) = cmd_rx.recv() {
if async_cmd_tx.send(cmd).is_err() {
break;
}
}
});
let pending_for_writer = pending.clone();
let write_task = tokio::spawn(async move {
let mut next_id: u64 = 1;
while let Some(IoCommand::Request {
method,
params,
reply,
}) = async_cmd_rx.recv().await
{
let id = format!("m{next_id}");
next_id += 1;
let req = json!({ "id": id, "method": method, "params": params });
let line = match serde_json::to_string(&req) {
Ok(l) => l,
Err(e) => {
let _ = reply.send(Err(e.to_string()));
continue;
}
};
pending_for_writer.lock().unwrap().insert(id.clone(), reply);
if write_half
.write_all(format!("{line}\n").as_bytes())
.await
.is_err()
{
if let Some(tx) = pending_for_writer.lock().unwrap().remove(&id) {
let _ = tx.send(Err("write failed; connection lost".to_string()));
}
break;
}
}
});
loop {
let line = match lines.next_line().await {
Ok(Some(l)) => l,
Ok(None) => break,
Err(e) => {
warn!(error = %e, "module-host: connection read error");
break;
}
};
if line.trim().is_empty() {
continue;
}
let value: Value = match serde_json::from_str(&line) {
Ok(v) => v,
Err(e) => {
warn!(error = %e, "module-host: malformed line from breadd");
continue;
}
};
if value.get("push").is_some() {
match serde_json::from_value::<ModuleHostPush>(value) {
Ok(push) => {
if host_tx.send(HostMessage::Push(push)).is_err() {
break;
}
}
Err(e) => warn!(error = %e, "module-host: malformed push message"),
}
} else if let Ok(resp) = serde_json::from_value::<RpcResponse>(value) {
if let Some(tx) = pending.lock().unwrap().remove(&resp.id) {
let result = match resp.error {
Some(e) => Err(e),
None => Ok(resp.result.unwrap_or(Value::Null)),
};
let _ = tx.send(result);
}
}
}
write_task.abort();
// Any calls still blocked waiting for a reply need to be unblocked
// rather than hanging forever now that the connection is gone.
for (_, tx) in pending.lock().unwrap().drain() {
let _ = tx.send(Err("connection closed".to_string()));
}
let _ = host_tx.send(HostMessage::Closed);
});
}
/// Blocking helper used from Lua callback closures (which run on the
/// Lua-driving thread, not the async IO thread): send a request and wait —
/// with a timeout, so a wedged connection can't hang a Lua callback forever
/// — for its response.
pub fn call(
cmd_tx: &mpsc::Sender<IoCommand>,
method: &str,
params: Value,
timeout: Duration,
) -> Result<Value, String> {
let (reply_tx, reply_rx) = mpsc::channel();
cmd_tx
.send(IoCommand::Request {
method: method.to_string(),
params,
reply: reply_tx,
})
.map_err(|_| "io thread gone".to_string())?;
reply_rx
.recv_timeout(timeout)
.map_err(|_| format!("{method} timed out"))?
}

View file

@ -0,0 +1,531 @@
//! The Lua half of `bread-module-host`: a `bread` table whose functions are
//! RPC-backed proxies to `breadd` instead of directly touching daemon state,
//! plus a dispatch loop that turns `ModuleHostPush` messages (from
//! `crate::io`) into Lua callback invocations.
//!
//! Structurally a slimmed-down sibling of `breadd/src/lua/mod.rs`'s
//! `LuaEngine`/`spawn_runtime`: one dedicated thread runs Lua synchronously
//! and reacts to messages from a channel (`HostMessage` here, `LuaMessage`
//! there); a separate thread/task owns the actual async I/O. Only ONE
//! module is ever loaded per `bread-module-host` process, so there's no
//! module registry, load ordering, or `after` dependency resolution here —
//! `breadd` already resolved all of that before deciding this module needed
//! its own process.
//!
//! `bread.module()`'s `store` is process-local (an in-memory table, not
//! synced back to `breadd`) — a documented gap vs. the in-process
//! implementation's `bread.module().store`, which persists in
//! `RuntimeState` and is visible to `bread modules info`/other modules.
//! Fine for a single module's own private scratch state; not fine yet for
//! anything that expects cross-module visibility. See `Documentation.md`.
use std::cell::RefCell;
use std::collections::{HashMap, HashSet};
use std::rc::Rc;
use std::sync::mpsc;
use std::time::Duration;
use anyhow::{anyhow, Result};
use bread_shared::{BreadEvent, ModulePermission, PermissionKind};
use mlua::{Error as LuaError, Function, Lua, LuaSerdeExt, RegistryKey, Table, Value as LuaValue};
use serde_json::{json, Value as JsonValue};
use tracing::error;
use crate::io::{call, IoCommand};
/// Timeout for a single RPC round trip to `breadd`. Generous relative to a
/// same-host Unix socket hop — this exists to fail loudly if the connection
/// wedges rather than to accommodate genuinely slow calls.
const RPC_TIMEOUT: Duration = Duration::from_secs(10);
/// Pure-Lua `bread.spawn`/`bread.wait` sugar, copied verbatim from
/// `breadd/src/lua/mod.rs`'s `install_wait_helper`. It only depends on
/// `coroutine` plus `bread.once`/`bread.on`/`bread.after`/`bread.cancel`,
/// all of which this module provides as RPC-backed bindings above, so the
/// suspension mechanism works unmodified against a remote event source.
///
/// Deliberately duplicated rather than shared: extracting this into
/// `bread-shared` (so `breadd` and `bread-module-host` load the same
/// constant instead of two hand-kept-in-sync copies) is flagged as
/// follow-up work in `Documentation.md` — doing it here would also require
/// making `breadd`'s currently-private `const BUILTIN_*`/wait-helper
/// strings public, which is a larger refactor than this workstream's time
/// budget covers.
const WAIT_HELPER: &str = r#"
bread.spawn = function(fn)
local co = coroutine.create(fn)
local ok, err = coroutine.resume(co)
if not ok then
error(err)
end
end
bread.wait = function(pattern, opts)
if type(pattern) ~= "string" then
error("bread.wait requires a pattern string")
end
opts = opts or {}
local co = coroutine.running()
if not co then
error("bread.wait must be called inside a coroutine")
end
local id
local timer
id = bread.once(pattern, function(event)
if timer then
bread.cancel(timer)
end
coroutine.resume(co, event)
end)
if opts.timeout then
timer = bread.after(opts.timeout, function()
bread.off(id)
coroutine.resume(co, nil)
end)
end
return coroutine.yield()
end
"#;
fn json_to_lua<'lua>(lua: &'lua Lua, value: &JsonValue) -> mlua::Result<LuaValue<'lua>> {
Ok(match value {
JsonValue::Null => LuaValue::Nil,
JsonValue::Bool(b) => LuaValue::Boolean(*b),
JsonValue::Number(n) => {
if let Some(i) = n.as_i64() {
LuaValue::Integer(i as i64)
} else {
LuaValue::Number(n.as_f64().unwrap_or(0.0))
}
}
JsonValue::String(s) => LuaValue::String(lua.create_string(s)?),
JsonValue::Array(arr) => {
let tbl = lua.create_table()?;
for (i, v) in arr.iter().enumerate() {
tbl.set(i + 1, json_to_lua(lua, v)?)?;
}
LuaValue::Table(tbl)
}
JsonValue::Object(obj) => {
let tbl = lua.create_table()?;
for (k, v) in obj.iter() {
tbl.set(k.clone(), json_to_lua(lua, v)?)?;
}
LuaValue::Table(tbl)
}
})
}
/// The Lua VM plus the bookkeeping needed to route `ModuleHostPush`
/// messages to the right registered callback. Lives entirely on one thread
/// (`mlua::Lua` is `!Send`) — see `main.rs`.
pub struct ModuleHostLua {
lua: Lua,
/// subscription_id or timer_id -> the Lua callback registered for it.
handlers: Rc<RefCell<HashMap<String, RegistryKey>>>,
registered: Rc<RefCell<bool>>,
module_table_key: Rc<RefCell<Option<RegistryKey>>>,
module_name: String,
}
impl ModuleHostLua {
pub fn new(
cmd_tx: mpsc::Sender<IoCommand>,
module_name: String,
permissions: Vec<ModulePermission>,
) -> Result<Self> {
let lua = Lua::new();
let bread = lua.create_table()?;
let handlers: Rc<RefCell<HashMap<String, RegistryKey>>> = Rc::new(RefCell::new(HashMap::new()));
let registered = Rc::new(RefCell::new(false));
let module_table_key: Rc<RefCell<Option<RegistryKey>>> = Rc::new(RefCell::new(None));
Self::install_module_fn(
&lua,
&bread,
module_name.clone(),
registered.clone(),
module_table_key.clone(),
)?;
Self::install_logging(&lua, &bread, cmd_tx.clone())?;
Self::install_json(&lua, &bread)?;
Self::install_events(&lua, &bread, cmd_tx.clone(), handlers.clone())?;
Self::install_timers(&lua, &bread, cmd_tx.clone(), handlers.clone())?;
Self::install_emit(&lua, &bread, cmd_tx.clone())?;
let granted: HashSet<PermissionKind> = permissions.iter().map(|p| p.kind).collect();
Self::install_fs(&lua, &bread, cmd_tx.clone(), &granted)?;
Self::install_exec(&lua, &bread, cmd_tx.clone(), &granted)?;
Self::install_state(&lua, &bread, cmd_tx, &granted)?;
lua.globals().set("bread", bread)?;
lua.load(WAIT_HELPER).set_name("<bread-module-host wait helper>").exec()?;
Ok(Self {
lua,
handlers,
registered,
module_table_key,
module_name,
})
}
fn install_module_fn(
lua: &Lua,
bread: &Table,
expected_name: String,
registered: Rc<RefCell<bool>>,
module_table_key: Rc<RefCell<Option<RegistryKey>>>,
) -> Result<()> {
let store: Rc<RefCell<HashMap<String, JsonValue>>> = Rc::new(RefCell::new(HashMap::new()));
let module_fn = lua.create_function(move |lua, table: Table| -> mlua::Result<Table> {
let name: String = table.get("name")?;
if name != expected_name {
return Err(LuaError::RuntimeError(format!(
"bread.module({{name = \"{name}\"}}) does not match the module breadd spawned this process for (\"{expected_name}\")"
)));
}
let version: Option<String> = table.get("version").ok();
let module_tbl = lua.create_table()?;
module_tbl.set("name", name.clone())?;
if let Some(v) = version {
module_tbl.set("version", v)?;
}
let store_tbl = lua.create_table()?;
let store_get = store.clone();
let get_fn = lua.create_function(move |lua, key: String| {
match store_get.borrow().get(&key) {
Some(v) => json_to_lua(lua, v),
None => Ok(LuaValue::Nil),
}
})?;
store_tbl.set("get", get_fn)?;
let store_set = store.clone();
let set_fn = lua.create_function(move |lua, (key, value): (String, LuaValue)| {
let json: JsonValue = lua.from_value(value).unwrap_or(JsonValue::Null);
store_set.borrow_mut().insert(key, json);
Ok(())
})?;
store_tbl.set("set", set_fn)?;
module_tbl.set("store", store_tbl)?;
*registered.borrow_mut() = true;
let key = lua.create_registry_value(module_tbl.clone())?;
*module_table_key.borrow_mut() = Some(key);
Ok(module_tbl)
})?;
bread.set("module", module_fn)?;
Ok(())
}
fn install_logging(lua: &Lua, bread: &Table, cmd_tx: mpsc::Sender<IoCommand>) -> Result<()> {
for (name, method) in [
("log", "module_host.log"),
("warn", "module_host.warn"),
("error", "module_host.error"),
] {
let cmd_tx = cmd_tx.clone();
let f = lua.create_function(move |_, message: String| {
let _ = call(&cmd_tx, method, json!({ "message": message }), RPC_TIMEOUT);
Ok(())
})?;
bread.set(name, f)?;
}
Ok(())
}
fn install_json(lua: &Lua, bread: &Table) -> Result<()> {
let json_tbl = lua.create_table()?;
let decode_fn = lua.create_function(|lua, s: String| {
match serde_json::from_str::<JsonValue>(&s) {
Ok(v) => Ok((json_to_lua(lua, &v)?, LuaValue::Nil)),
Err(e) => Ok((LuaValue::Nil, LuaValue::String(lua.create_string(&e.to_string())?))),
}
})?;
json_tbl.set("decode", decode_fn)?;
bread.set("json", json_tbl)?;
Ok(())
}
fn install_events(
lua: &Lua,
bread: &Table,
cmd_tx: mpsc::Sender<IoCommand>,
handlers: Rc<RefCell<HashMap<String, RegistryKey>>>,
) -> Result<()> {
for (name, once) in [("on", false), ("once", true)] {
let cmd_tx = cmd_tx.clone();
let handlers = handlers.clone();
let method = if once { "module_host.once" } else { "module_host.on" };
let f = lua.create_function(move |lua, (pattern, callback): (String, Function)| {
let result = call(&cmd_tx, method, json!({ "pattern": pattern }), RPC_TIMEOUT)
.map_err(LuaError::external)?;
let id = result
.get("subscription_id")
.and_then(|v| v.as_str())
.ok_or_else(|| LuaError::external("module_host.on: missing subscription_id"))?
.to_string();
let key = lua.create_registry_value(callback)?;
handlers.borrow_mut().insert(id.clone(), key);
Ok(id)
})?;
bread.set(name, f)?;
}
let cmd_tx_off = cmd_tx.clone();
let handlers_off = handlers.clone();
let off_fn = lua.create_function(move |_, id: String| {
let _ = call(&cmd_tx_off, "module_host.off", json!({ "id": id }), RPC_TIMEOUT);
handlers_off.borrow_mut().remove(&id);
Ok(())
})?;
bread.set("off", off_fn)?;
Ok(())
}
fn install_timers(
lua: &Lua,
bread: &Table,
cmd_tx: mpsc::Sender<IoCommand>,
handlers: Rc<RefCell<HashMap<String, RegistryKey>>>,
) -> Result<()> {
for (name, method, param_key) in [
("after", "module_host.after", "delay_ms"),
("every", "module_host.every", "interval_ms"),
] {
let cmd_tx = cmd_tx.clone();
let handlers = handlers.clone();
let f = lua.create_function(move |lua, (delay_ms, callback): (u64, Function)| {
let result = call(&cmd_tx, method, json!({ param_key: delay_ms }), RPC_TIMEOUT)
.map_err(LuaError::external)?;
let id = result
.get("timer_id")
.and_then(|v| v.as_str())
.ok_or_else(|| LuaError::external(format!("{method}: missing timer_id")))?
.to_string();
let key = lua.create_registry_value(callback)?;
handlers.borrow_mut().insert(id.clone(), key);
Ok(id)
})?;
bread.set(name, f)?;
}
let cmd_tx_cancel = cmd_tx.clone();
let handlers_cancel = handlers.clone();
let cancel_fn = lua.create_function(move |_, id: String| {
let _ = call(&cmd_tx_cancel, "module_host.cancel", json!({ "id": id }), RPC_TIMEOUT);
handlers_cancel.borrow_mut().remove(&id);
Ok(())
})?;
bread.set("cancel", cancel_fn)?;
Ok(())
}
fn install_emit(lua: &Lua, bread: &Table, cmd_tx: mpsc::Sender<IoCommand>) -> Result<()> {
let emit_fn = lua.create_function(move |lua, (event, data): (String, Option<LuaValue>)| {
let data_json: JsonValue = match data {
Some(v) => lua.from_value(v).unwrap_or(JsonValue::Null),
None => json!({}),
};
call(
&cmd_tx,
"module_host.emit",
json!({ "event": event, "data": data_json }),
RPC_TIMEOUT,
)
.map(|_| ())
.map_err(LuaError::external)
})?;
bread.set("emit", emit_fn)?;
Ok(())
}
/// `bread.state.get(path)`, gated on `state.read`. Only the `get`
/// shorthand is bridged here — `.monitors()`/`.active_workspace()`/etc.
/// convenience wrappers and `state.watch` (a standing subscription, a
/// materially different capability — see `PermissionKind::StateWatch`'s
/// doc comment in `bread-shared`) are deferred; see `Documentation.md`'s
/// Workstream G section for the full list of what's bridged vs. not.
fn install_state(
lua: &Lua,
bread: &Table,
cmd_tx: mpsc::Sender<IoCommand>,
granted: &HashSet<PermissionKind>,
) -> Result<()> {
if !granted.contains(&PermissionKind::StateRead) {
return Ok(());
}
let state_tbl = lua.create_table()?;
let get_fn = lua.create_function(move |lua, key: String| {
let result = call(&cmd_tx, "module_host.state_get", json!({ "key": key }), RPC_TIMEOUT)
.map_err(LuaError::external)?;
match result.get("value") {
Some(v) => json_to_lua(lua, v),
None => Ok(LuaValue::Nil),
}
})?;
state_tbl.set("get", get_fn)?;
bread.set("state", state_tbl)?;
Ok(())
}
fn install_fs(
lua: &Lua,
bread: &Table,
cmd_tx: mpsc::Sender<IoCommand>,
granted: &HashSet<PermissionKind>,
) -> Result<()> {
if !granted.contains(&PermissionKind::FsRead) && !granted.contains(&PermissionKind::FsWrite) {
return Ok(());
}
let fs_tbl = lua.create_table()?;
if granted.contains(&PermissionKind::FsRead) {
let cmd_tx = cmd_tx.clone();
let read_fn = lua.create_function(move |_, path: String| {
let result = call(&cmd_tx, "module_host.fs_read", json!({ "path": path }), RPC_TIMEOUT)
.map_err(LuaError::external)?;
Ok(result
.get("content")
.and_then(|v| v.as_str())
.map(|s| s.to_string()))
})?;
fs_tbl.set("read", read_fn)?;
}
if granted.contains(&PermissionKind::FsWrite) {
let cmd_tx = cmd_tx.clone();
let write_fn = lua.create_function(move |_, (path, content): (String, String)| {
call(
&cmd_tx,
"module_host.fs_write",
json!({ "path": path, "content": content }),
RPC_TIMEOUT,
)
.map(|_| ())
.map_err(LuaError::external)
})?;
fs_tbl.set("write", write_fn)?;
}
bread.set("fs", fs_tbl)?;
Ok(())
}
fn install_exec(
lua: &Lua,
bread: &Table,
cmd_tx: mpsc::Sender<IoCommand>,
granted: &HashSet<PermissionKind>,
) -> Result<()> {
if !granted.contains(&PermissionKind::Exec) {
return Ok(());
}
let cmd_tx_exec = cmd_tx.clone();
let exec_fn = lua.create_function(move |_, cmd: String| {
call(&cmd_tx_exec, "module_host.exec", json!({ "cmd": cmd }), RPC_TIMEOUT)
.map(|_| ())
.map_err(LuaError::external)
})?;
bread.set("exec", exec_fn)?;
let exec_capture_fn = lua.create_function(move |_, (cmd, opts): (String, Option<Table>)| {
let timeout_ms: u64 = opts
.as_ref()
.and_then(|o| o.get("timeout_ms").ok())
.unwrap_or(2000);
let call_timeout = RPC_TIMEOUT + Duration::from_millis(timeout_ms);
let result = call(
&cmd_tx,
"module_host.exec_capture",
json!({ "cmd": cmd, "timeout_ms": timeout_ms }),
call_timeout,
)
.map_err(LuaError::external)?;
let ok = result.get("ok").and_then(|v| v.as_bool()).unwrap_or(false);
let stdout = result
.get("stdout")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
Ok((ok, stdout))
})?;
bread.set("exec_capture", exec_capture_fn)?;
Ok(())
}
/// Load and execute the module's `init.lua`, then verify it actually
/// called `bread.module(...)` — mirrors `breadd`'s own
/// `load_module`/`load_scoped_lua_file` contract exactly (see
/// `breadd/src/lua/mod.rs`).
pub fn load_entry(&self, entry_path: &std::path::Path) -> Result<()> {
let src = std::fs::read_to_string(entry_path)
.map_err(|e| anyhow!("failed to read {}: {e}", entry_path.display()))?;
self.lua
.load(&src)
.set_name(entry_path.to_string_lossy().as_ref())
.exec()
.map_err(|e| anyhow!(e.to_string()))?;
if !*self.registered.borrow() {
return Err(anyhow!("module did not call bread.module(...)"));
}
self.run_on_load()
}
fn run_on_load(&self) -> Result<()> {
let key_ref = self.module_table_key.borrow();
let Some(key) = key_ref.as_ref() else {
return Ok(());
};
let module_tbl: Table = self
.lua
.registry_value(key)
.map_err(|e| anyhow!(e.to_string()))?;
let hook: Option<Function> = module_tbl.get("on_load").ok();
drop(key_ref);
if let Some(hook) = hook {
hook.call::<_, ()>(())
.map_err(|e| anyhow!("{} on_load failed: {e}", self.module_name))?;
}
Ok(())
}
pub fn dispatch_event(&self, subscription_id: &str, event: &BreadEvent) {
let func = self.lookup(subscription_id);
if let Some(func) = func {
if let Err(e) = self.call_event_handler(&func, event) {
error!(subscription_id, error = %e, "module-host: event handler error");
}
}
}
pub fn dispatch_timer(&self, timer_id: &str) {
let func = self.lookup(timer_id);
if let Some(func) = func {
if let Err(e) = func.call::<_, ()>(()) {
error!(timer_id, error = %e, "module-host: timer handler error");
}
}
}
fn lookup(&self, id: &str) -> Option<Function<'_>> {
let handlers = self.handlers.borrow();
let key = handlers.get(id)?;
self.lua.registry_value::<Function>(key).ok()
}
fn call_event_handler(&self, func: &Function, event: &BreadEvent) -> mlua::Result<()> {
let data = json_to_lua(&self.lua, &event.data)?;
let evt_tbl = self.lua.create_table()?;
evt_tbl.set("event", event.event.clone())?;
evt_tbl.set("data", data)?;
evt_tbl.set("timestamp", event.timestamp)?;
evt_tbl.set("id", event.id.clone())?;
if let Some(caused_by) = &event.caused_by {
evt_tbl.set("caused_by", caused_by.clone())?;
}
func.call::<_, ()>(evt_tbl)
}
}

View file

@ -0,0 +1,164 @@
//! `bread-module-host` — the out-of-process runtime for a single third-party
//! Bread module (Workstream G).
//!
//! `breadd` spawns one of these per out-of-process module (see
//! `breadd/src/module_host.rs`), sandboxed at the OS level via a Landlock
//! ruleset applied by the parent *before* this binary's own `main()` ever
//! runs (through `Command::pre_exec` — see that module's doc comment for
//! why this binary itself has no Landlock dependency at all). This process
//! then:
//!
//! 1. Connects to `breadd`'s existing IPC socket
//! (`$XDG_RUNTIME_DIR/bread/breadd.sock` by default).
//! 2. Presents the one-time token `breadd` gave it (via `$BREAD_MODULE_TOKEN`,
//! an env var rather than argv, which is visible to any process via
//! `/proc/*/cmdline`) via `module_host.hello` and learns its own identity
//! (module name + granted permissions) from `breadd`'s answer — it never
//! asserts its own name and have that trusted.
//! 3. Loads exactly one module's `init.lua` (`$BREAD_MODULE_ENTRY`) into a
//! fresh Lua VM whose `bread` table is built entirely from RPC-backed
//! proxies (see `lua_env`) instead of direct in-process bindings.
//! 4. Reports load success/failure back to `breadd` (`module_host.status`),
//! then dispatches subscribed events/timers pushed down the same
//! connection until it closes.
//!
//! Env vars, all required except `BREAD_MODULE_SOCKET` and
//! `BREAD_MODULE_NAME`:
//! - `BREAD_MODULE_TOKEN` — one-time handshake token.
//! - `BREAD_MODULE_ENTRY` — absolute path to the module's entry `.lua` file.
//! - `BREAD_MODULE_SOCKET` — override for breadd's socket path (defaults to
//! `bread_shared::resolve_socket_path()`, the same resolution breadd's own
//! `Config::socket_path` uses).
//! - `BREAD_MODULE_NAME` — informational only (early log lines before the
//! hello response arrives); never trusted for permission lookup.
mod io;
mod lua_env;
use std::path::PathBuf;
use std::sync::mpsc;
use std::time::Duration;
use bread_shared::{ModuleHostHello, ModuleHostPush};
use tracing::{error, info, warn};
use io::{HostMessage, IoCommand};
use lua_env::ModuleHostLua;
fn main() {
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.init();
let module_name_hint = std::env::var("BREAD_MODULE_NAME").unwrap_or_else(|_| "?".to_string());
let token = match std::env::var("BREAD_MODULE_TOKEN") {
Ok(t) => t,
Err(_) => {
eprintln!("bread-module-host: missing BREAD_MODULE_TOKEN env var");
std::process::exit(1);
}
};
let entry = match std::env::var("BREAD_MODULE_ENTRY") {
Ok(e) => PathBuf::from(e),
Err(_) => {
eprintln!("bread-module-host: missing BREAD_MODULE_ENTRY env var");
std::process::exit(1);
}
};
let socket_path = match std::env::var("BREAD_MODULE_SOCKET") {
Ok(s) => PathBuf::from(s),
Err(_) => bread_shared::resolve_socket_path(),
};
info!(
module_hint = %module_name_hint,
entry = %entry.display(),
socket = %socket_path.display(),
"bread-module-host starting"
);
let (cmd_tx, cmd_rx) = mpsc::channel::<IoCommand>();
let (host_tx, host_rx) = mpsc::channel::<HostMessage>();
let (hello_tx, hello_rx) = mpsc::channel::<Result<ModuleHostHello, String>>();
if std::thread::Builder::new()
.name("module-host-io".to_string())
.spawn(move || io::run(socket_path, token, cmd_rx, host_tx, hello_tx))
.is_err()
{
eprintln!("bread-module-host: failed to spawn io thread");
std::process::exit(1);
}
let hello = match hello_rx.recv_timeout(Duration::from_secs(15)) {
Ok(Ok(h)) => h,
Ok(Err(e)) => {
error!(error = %e, "bread-module-host: hello handshake failed");
std::process::exit(1);
}
Err(_) => {
error!("bread-module-host: timed out waiting for hello handshake");
std::process::exit(1);
}
};
info!(
module = %hello.module,
permissions = ?hello.permissions,
api_version = %hello.api_version,
"bread-module-host: identity established by breadd"
);
let engine = match ModuleHostLua::new(cmd_tx.clone(), hello.module.clone(), hello.permissions.clone()) {
Ok(e) => e,
Err(e) => {
error!(error = %e, "bread-module-host: failed to build lua environment");
report_status(&cmd_tx, false, Some(e.to_string()));
std::process::exit(1);
}
};
match engine.load_entry(&entry) {
Ok(()) => {
info!(module = %hello.module, "bread-module-host: module loaded successfully");
report_status(&cmd_tx, true, None);
}
Err(e) => {
error!(module = %hello.module, error = %e, "bread-module-host: module load failed");
report_status(&cmd_tx, false, Some(e.to_string()));
std::process::exit(1);
}
}
// Steady state: dispatch pushed events/timers until the connection to
// breadd drops (breadd exited, socket closed, or we were killed and
// this line never runs at all — see breadd/src/module_host.rs's
// child-reap thread for the other half of that crash-isolation story).
loop {
match host_rx.recv() {
Ok(HostMessage::Push(ModuleHostPush::Event {
subscription_id,
event,
})) => {
engine.dispatch_event(&subscription_id, &event);
}
Ok(HostMessage::Push(ModuleHostPush::Timer { timer_id })) => {
engine.dispatch_timer(&timer_id);
}
Ok(HostMessage::Closed) | Err(_) => {
warn!("bread-module-host: connection to breadd closed, exiting");
break;
}
}
}
}
fn report_status(cmd_tx: &mpsc::Sender<IoCommand>, ok: bool, error: Option<String>) {
let params = if ok {
serde_json::json!({ "state": "loaded" })
} else {
serde_json::json!({ "state": "load_error", "error": error })
};
let _ = io::call(cmd_tx, "module_host.status", params, Duration::from_secs(5));
}