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

@ -22,6 +22,8 @@ netlink-packet-route = "0.11"
netlink-packet-core = "0.4"
libc = "0.2"
notify = "6.1"
landlock.workspace = true
uuid.workspace = true
[dev-dependencies]
tempfile.workspace = true

View file

@ -20,6 +20,9 @@ use tracing::{error, info, warn};
use crate::adapters::AdapterStatus;
use crate::core::state_engine::StateHandle;
use crate::lua::RuntimeHandle;
use crate::module_host::ModuleHostRegistry;
mod module_host_bridge;
/// The Bread Automation API version (Lua API surface + IPC methods + event
/// vocabulary + runtime-state schema), per `Documentation.md`'s "API
@ -27,7 +30,11 @@ use crate::lua::RuntimeHandle;
/// something new-but-additive (a binding, an event, an IPC param); bump the
/// major version only for a breaking change, which should not happen inside
/// this daemon's v1 lifetime per that section's stated policy.
const API_VERSION: &str = "1.5.0";
///
/// *Since 1.6.0* — Workstream G's `module_host.*` methods (hello handshake
/// plus the RPC bridge a `bread-module-host` child uses in place of direct
/// in-process `bread.*` bindings).
const API_VERSION: &str = "1.6.0";
#[derive(Clone)]
pub struct Server {
@ -42,6 +49,11 @@ pub struct Server {
event_buffer: Arc<std::sync::Mutex<VecDeque<BreadEvent>>>,
started_at: Instant,
pid: u32,
/// Workstream G: token/identity bookkeeping for out-of-process module
/// hosts, shared with the Lua engine (which spawns them). See
/// `crate::module_host` and `module_host_bridge` (this module's
/// `module_host.*` method handling).
module_host_registry: ModuleHostRegistry,
}
#[derive(Debug, Deserialize)]
@ -62,7 +74,7 @@ struct IpcResponse {
}
impl Server {
// Server::new legitimately requires all 8 fields; a builder pattern here would be
// Server::new legitimately requires all 10 fields; a builder pattern here would be
// over-engineering for a single-call-site constructor.
#[allow(clippy::too_many_arguments)]
pub fn new(
@ -75,6 +87,7 @@ impl Server {
adapter_status: Arc<RwLock<HashMap<String, AdapterStatus>>>,
subscription_count: Arc<AtomicU64>,
event_buffer: Arc<std::sync::Mutex<VecDeque<BreadEvent>>>,
module_host_registry: ModuleHostRegistry,
) -> Self {
Self {
socket_path,
@ -84,6 +97,7 @@ impl Server {
emit_tx,
raw_tx,
adapter_status,
module_host_registry,
subscription_count,
event_buffer,
started_at: Instant::now(),
@ -176,6 +190,20 @@ impl Server {
return Ok(());
}
// Workstream G: a `bread-module-host` child's very first message
// presents its one-time spawn token. From here on this
// connection is a dedicated, bidirectional module-host bridge
// (RPC requests interleaved with async event/timer pushes) —
// see `module_host_bridge::handle_module_host_connection` —
// rather than a one-shot request/response exchange, so it takes
// over the rest of this connection's lifetime exactly like
// `events.subscribe` above does for a plain event stream.
if req.method == "module_host.hello" {
return self
.handle_module_host_connection(req, lines, write_half)
.await;
}
let response = match self.handle_request(req).await {
Ok(res) => IpcResponse {
id: res.0,
@ -339,24 +367,7 @@ impl Server {
let Some(event) = req.params.get("event").and_then(Value::as_str) else {
return Err((id, "missing event name".to_string()));
};
if let Some(domain) = event_domain(event) {
if is_reserved_domain(domain) {
return Err((
id,
format!(
"event '{event}' claims the reserved '{domain}' domain — manual emit cannot impersonate an adapter-owned event; use a custom event name, or a sourced emit if this should go through the normalizer"
),
));
}
}
if self
.emit_tx
.send(BreadEvent::new(event, AdapterSource::Manual, data))
.is_err()
{
return Err((id, "emit channel closed".to_string()));
}
Ok(json!({ "emitted": true }))
self.manual_emit(event, data)
}
}
"health" => {
@ -409,6 +420,31 @@ impl Server {
}
}
/// Unsourced-emit logic, factored out of `handle_request`'s `"emit"`
/// case so `module_host_bridge`'s `module_host.emit` (Workstream G) can
/// share the exact same reserved-domain guard rather than re-deriving
/// it — see the original inline comment (still above the one call site
/// in `handle_request`) for why the guard exists: a same-UID socket
/// client (or, now, a module-host child) must not be able to
/// impersonate a real adapter-owned event namespace.
fn manual_emit(&self, event: &str, data: Value) -> std::result::Result<Value, String> {
if let Some(domain) = event_domain(event) {
if is_reserved_domain(domain) {
return Err(format!(
"event '{event}' claims the reserved '{domain}' domain — manual emit cannot impersonate an adapter-owned event; use a custom event name, or a sourced emit if this should go through the normalizer"
));
}
}
if self
.emit_tx
.send(BreadEvent::new(event, AdapterSource::Manual, data))
.is_err()
{
return Err("emit channel closed".to_string());
}
Ok(json!({ "emitted": true }))
}
async fn stream_events(
&self,
writer: &mut tokio::net::unix::OwnedWriteHalf,

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()
})
}

View file

@ -25,6 +25,7 @@ use crate::core::subscriptions::SubscriptionId;
use crate::core::types::{
DeviceRule, MatchCondition, ModuleLoadState, RuntimeState, WorkflowState, WorkflowStatus,
};
use crate::module_host::{self, ModuleHostOutcome, ModuleHostRegistry};
use bread_shared::now_unix_ms;
pub enum LuaMessage {
@ -90,6 +91,7 @@ pub fn spawn_runtime(
config: Config,
state_handle: StateHandle,
emit_tx: mpsc::UnboundedSender<BreadEvent>,
module_host_registry: ModuleHostRegistry,
) -> Result<RuntimeHandle> {
let (tx, mut rx) = mpsc::unbounded_channel();
let recent_errors = Arc::new(Mutex::new(VecDeque::with_capacity(50)));
@ -114,6 +116,7 @@ pub fn spawn_runtime(
emit_tx,
thread_tx.clone(),
recent_errors,
module_host_registry,
) {
Ok(engine) => engine,
Err(err) => {
@ -246,6 +249,11 @@ struct LuaEngine {
modules_config: ModulesConfig,
notifications_config: NotificationsConfig,
recent_errors: Arc<Mutex<VecDeque<ErrorEntry>>>,
/// Workstream G: spawn/token bookkeeping for out-of-process module
/// hosts, shared with `ipc::Server` (which authenticates the spawned
/// children and serves their RPC calls). See `crate::module_host`.
module_host_registry: ModuleHostRegistry,
socket_path: PathBuf,
}
impl LuaEngine {
@ -255,7 +263,9 @@ impl LuaEngine {
emit_tx: mpsc::UnboundedSender<BreadEvent>,
lua_tx: mpsc::UnboundedSender<LuaMessage>,
recent_errors: Arc<Mutex<VecDeque<ErrorEntry>>>,
module_host_registry: ModuleHostRegistry,
) -> Result<Self> {
let socket_path = config.socket_path();
Ok(Self {
lua: Lua::new(),
handlers: Arc::new(Mutex::new(HashMap::new())),
@ -271,6 +281,8 @@ impl LuaEngine {
state_handle,
emit_tx,
lua_tx,
module_host_registry,
socket_path,
entry_point: config.lua_entry_point(),
module_path: config.lua_module_path(),
modules_config: config.modules.clone(),
@ -1525,15 +1537,37 @@ impl LuaEngine {
}
fn load_module(&self, decl: &ModuleDecl) -> Result<()> {
// Workstream G branch point: a third-party module that declared
// `[[permissions]]` (opted into the D capability-manifest system —
// `decl.permissions.is_some()`, including `Some(&[])`) gets spawned
// as a separate, OS-sandboxed `bread-module-host` process instead of
// being loaded into this Lua VM at all. Its Lua state, `bread.on`
// handlers, timers, etc. all live in that other process from here
// on — none of this engine's module-table/on_load bookkeeping below
// applies to it, hence the early return.
//
// `decl.permissions.is_none()` (no manifest, or a manifest with no
// `permissions` key) falls through to the unchanged in-process,
// unscoped path for backward compatibility — see
// `ModuleDecl::permissions`'s doc comment and `Documentation.md`'s
// "Workstream G" section for why this is a deliberate scope
// decision rather than an oversight: Landlock needs concrete rules
// to build from, and "no manifest at all" carries none.
if decl.source.is_none() {
if let Some(permissions) = decl.permissions.as_ref() {
return self.load_out_of_process_module(decl, permissions);
}
}
self.set_current_module(Some(decl.name.clone()));
let result = if let Some(source) = decl.source {
// Builtins (bread.monitors/devices/workspaces/binds) — embedded
// source, always the full ambient bread table, never scoped.
self.load_lua_source(source, &decl.name)
} else {
// Third-party, on-disk modules only. Capability-scoped per
// decl.permissions — see load_scoped_lua_file.
self.load_scoped_lua_file(&decl.path, &decl.name, decl.permissions.as_deref())
// Third-party, on-disk, no-manifest module: today's original
// behavior, unchanged (full ungated in-process access).
self.load_scoped_lua_file(&decl.path, &decl.name, None)
};
self.set_current_module(None);
result?;
@ -1545,6 +1579,33 @@ impl LuaEngine {
self.run_on_load(&decl.name)
}
/// Spawn (or respawn, on `bread reload`) a sandboxed `bread-module-host`
/// child for `decl` and block until it reports ready or fails — see
/// `crate::module_host::spawn_module_host`. Blocking here (rather than
/// making `load_module` async) keeps `load_module`'s existing
/// synchronous "a module either loaded or it didn't" contract intact
/// for callers like `load_init_and_modules` and the `modules.reload`
/// IPC method, which both expect to know Loaded-vs-LoadError before
/// they return.
fn load_out_of_process_module(
&self,
decl: &ModuleDecl,
permissions: &[ModulePermission],
) -> Result<()> {
let outcome = module_host::spawn_module_host(
&self.module_host_registry,
&decl.name,
&decl.path,
permissions,
&self.socket_path,
&self.emit_tx,
)?;
match outcome {
ModuleHostOutcome::Ready => Ok(()),
ModuleHostOutcome::LoadError(err) => Err(anyhow!(err)),
}
}
/// Load `init.lua` (the trusted entry point) or any other file that
/// should see the real, unscoped `bread` global exactly like today.
/// Not used for third-party modules — see [`load_scoped_lua_file`].

View file

@ -2,6 +2,7 @@ mod adapters;
mod core;
mod ipc;
mod lua;
mod module_host;
use std::collections::VecDeque;
use std::sync::atomic::AtomicU64;
@ -37,9 +38,14 @@ async fn main() -> Result<()> {
let subscription_count = Arc::new(AtomicU64::new(0));
let state_handle = StateHandle::new(state.clone(), state_cmd_tx);
let module_host_registry = module_host::ModuleHostRegistry::new();
let lua_runtime =
lua::spawn_runtime(config.clone(), state_handle.clone(), normalized_tx.clone())?;
let lua_runtime = lua::spawn_runtime(
config.clone(),
state_handle.clone(),
normalized_tx.clone(),
module_host_registry.clone(),
)?;
let lua_tx = lua_runtime.sender();
tokio::spawn(run_state_engine(
@ -119,6 +125,7 @@ async fn main() -> Result<()> {
adapter_status,
subscription_count,
event_buffer,
module_host_registry.clone(),
);
info!("breadd fully started");
@ -136,6 +143,7 @@ async fn main() -> Result<()> {
let _ = shutdown_tx.send(true);
lua_runtime.shutdown();
module_host_registry.shutdown_all();
Ok(())
}

732
breadd/src/module_host.rs Normal file
View file

@ -0,0 +1,732 @@
//! Spawning, token-based identity, and OS-level sandboxing for out-of-process
//! module hosts (Workstream G).
//!
//! # Why a process, not just the existing Lua-level scoping
//!
//! Workstream D's `build_scoped_env` (see `lua/mod.rs`) gates the
//! *documented* `bread.*` surface by controlling which keys exist on the
//! `bread` table a module's chunk sees. Its own doc comment says plainly
//! that `os.execute`/`io.open`/`debug.*` remain fully reachable from a
//! scoped module — Lua's stdlib isn't sandboxed at all, only the `bread`
//! table is. A module that never calls `bread.fs`/`bread.exec` and instead
//! calls `io.open`/`os.execute` directly bypasses the whole mechanism,
//! because everything still runs as Lua code inside `breadd`'s own OS
//! process, sharing its real filesystem/exec access at the kernel level.
//!
//! This module closes that gap for any module that opted into the
//! capability-manifest system (`decl.permissions.is_some()` — see
//! `lua/mod.rs`'s `load_module`): instead of loading its chunk in-process,
//! `breadd` spawns a separate `bread-module-host` OS process for it,
//! restricted by a Landlock ruleset built from that module's granted
//! `ModulePermission`s *before* the child ever executes a byte of the
//! module's Lua.
//!
//! # Why Landlock over bubblewrap/firejail
//!
//! - Pure Rust, no external sandboxing binary dependency — this workspace's
//! existing style already favors native Rust crates over shelling out
//! (see e.g. `udev`, `zbus`, `rtnetlink` instead of wrapping CLI tools).
//! - Unprivileged: no setuid helper, no CAP_SYS_ADMIN, works from an
//! ordinary user session exactly like the rest of `breadd`.
//! - Available since Linux 5.13; this repo's dev kernel is 6.18 and the
//! mechanism was verified against it directly before adoption (see the
//! `landlock` entry in the workspace `Cargo.toml` and
//! `module_host::tests::landlock_denies_reads_outside_granted_path`
//! below) — a `pre_exec`-restricted child process attempting to read a
//! file outside its granted rule set gets `EACCES` from the kernel, not a
//! Lua-level error.
//! - `bubblewrap`-wrapping remains a documented fallback if a target
//! platform's kernel lacks Landlock support (pre-5.13, or a hardened
//! kernel config with it compiled out) — not implemented here since
//! Landlock covers this repo's actual target (a modern desktop Linux
//! kernel) and keeps the dependency footprint native-Rust-only.
//!
//! # What Landlock does *not* cover here (P2, explicitly deferred)
//!
//! Network access. Landlock gained TCP bind/connect mediation in ABI v4+
//! (kernel 6.7+), but wiring a `network` permission kind through the
//! manifest schema, `PermissionKind`, and this sandbox builder is scoped
//! out of this workstream's P0 — see `Documentation.md`.
//!
//! # The token handshake
//!
//! Workstream A deliberately did not build a generic IPC connection-identity
//! system (it closed a narrower spoofing gap instead), so there's no
//! existing `module:<name>` identity concept to hook into. This module adds
//! the minimal mechanism Workstream G actually needs: `breadd` generates a
//! random one-time token when spawning a module-host child, hands it to the
//! child via `$BREAD_MODULE_TOKEN` (an env var, not argv — argv is visible
//! to any process on the system via `/proc/<pid>/cmdline`, env vars are not
//! without `/proc/<pid>/environ` + matching privileges), and the child's
//! first message on the IPC socket (`module_host.hello`) presents that
//! token. `breadd` looks up which module name/manifest/permission set the
//! token was issued for — see [`ModuleHostRegistry::take_pending`] — rather
//! than trusting any name the child process might assert about itself.
use std::collections::HashMap;
use std::os::unix::process::{CommandExt, ExitStatusExt};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use anyhow::{anyhow, Result};
use bread_shared::{AdapterSource, BreadEvent, ModulePermission, PermissionKind};
use landlock::{
make_bitflags, Access, AccessFs, PathBeneath, PathFd, Ruleset, RulesetAttr,
RulesetCreatedAttr, RulesetStatus, ABI,
};
use tokio::sync::mpsc::UnboundedSender;
use tracing::{error, info, warn};
/// What `load_module` ultimately learns about a spawn attempt, reported back
/// over IPC (`module_host.hello` consumes the pending entry;
/// `module_host.status` supplies the final verdict) via
/// [`PendingModuleHost::outcome_tx`].
pub enum ModuleHostOutcome {
Ready,
LoadError(String),
}
/// What `breadd` knows about a spawned-but-not-yet-authenticated module-host
/// child, keyed by the one-time token it was handed. Consumed exactly once,
/// by whichever connection presents the matching token first (see
/// `ipc::Server`'s `module_host.hello` handling).
pub struct PendingModuleHost {
pub module_name: String,
pub permissions: Vec<ModulePermission>,
pub outcome_tx: std::sync::mpsc::Sender<ModuleHostOutcome>,
}
struct ActiveModuleHost {
pid: u32,
}
/// Shared handle to the pending-token / active-child bookkeeping, cloned
/// into both the Lua engine thread (which spawns children) and the IPC
/// server (which authenticates them and serves their RPC calls).
#[derive(Clone)]
pub struct ModuleHostRegistry {
inner: Arc<Mutex<Inner>>,
}
#[derive(Default)]
struct Inner {
pending: HashMap<String, PendingModuleHost>,
active: HashMap<String, ActiveModuleHost>,
}
impl Default for ModuleHostRegistry {
fn default() -> Self {
Self::new()
}
}
impl ModuleHostRegistry {
pub fn new() -> Self {
Self {
inner: Arc::new(Mutex::new(Inner::default())),
}
}
fn insert_pending(&self, token: String, pending: PendingModuleHost) {
self.inner
.lock()
.unwrap_or_else(|e| e.into_inner())
.pending
.insert(token, pending);
}
/// One-time consumption of a pending token, called from the IPC side
/// when a connection presents it via `module_host.hello`. Returns
/// `None` for an unknown/already-consumed/expired token — the caller
/// must not extend any trust to that connection in that case.
pub fn take_pending(&self, token: &str) -> Option<PendingModuleHost> {
self.inner
.lock()
.unwrap_or_else(|e| e.into_inner())
.pending
.remove(token)
}
fn insert_active(&self, name: String, pid: u32) {
self.inner
.lock()
.unwrap_or_else(|e| e.into_inner())
.active
.insert(name, ActiveModuleHost { pid });
}
fn remove_active(&self, name: &str) {
self.inner
.lock()
.unwrap_or_else(|e| e.into_inner())
.active
.remove(name);
}
/// Best-effort SIGTERM of a previously spawned module-host for `name`,
/// if still tracked as active. Called at the top of
/// [`spawn_module_host`] so `bread reload`/`modules.reload` respawning
/// the same module doesn't leak an orphaned duplicate process still
/// holding an open IPC connection and reacting to events alongside its
/// replacement. The old process's own reap thread (started when it was
/// first spawned) will still notice it exit and emit
/// `bread.module.crashed` for it — a known rough edge documented in
/// `Documentation.md`: an intentional reload-triggered replacement
/// currently looks identical, on the wire, to an unexpected crash.
fn terminate_existing(&self, name: &str) {
let pid = {
let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
inner.active.get(name).map(|a| a.pid)
};
if let Some(pid) = pid {
unsafe {
libc::kill(pid as libc::pid_t, libc::SIGTERM);
}
}
}
/// Best-effort SIGTERM of every still-tracked module-host child. Called
/// from `breadd`'s shutdown path so stopping the daemon doesn't leave
/// orphaned sandboxed processes holding a now-dead socket connection.
pub fn shutdown_all(&self) {
let pids: Vec<u32> = {
let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
inner.active.values().map(|a| a.pid).collect()
};
for pid in pids {
// SAFETY: kill(2) with a pid we just read from our own
// bookkeeping and a plain termination signal; no memory safety
// concerns, just an FFI call.
unsafe {
libc::kill(pid as libc::pid_t, libc::SIGTERM);
}
}
}
}
/// How long `load_module` blocks waiting for a freshly spawned module-host
/// to either report ready (`module_host.status{state:"loaded"}`) or fail —
/// mirrors the synchronous "a module either loaded or it didn't" contract
/// `load_scoped_lua_file` already has for in-process modules. 25s rather
/// than something tighter: a real spawn (process fork/exec + Landlock
/// ruleset setup + Lua init) takes well under a second in isolation, but
/// this repo's integration test suite spawns many real `breadd` +
/// `bread-module-host` process pairs concurrently (`cargo test`'s default
/// parallelism), and under that load a spawn occasionally takes several
/// seconds of wall-clock time waiting for CPU/scheduler time rather than
/// being slow on its own merits.
const READY_TIMEOUT: Duration = Duration::from_secs(45);
/// Spawn a sandboxed `bread-module-host` child for one third-party module
/// and block (on a `std::sync::mpsc` channel, not an async await — this is
/// called from the Lua engine's own dedicated OS thread, which is not
/// async) until it reports ready or fails to within [`READY_TIMEOUT`].
///
/// `emit_tx` is used once, later, not by this function directly: the
/// crash-detection thread this function spawns uses it to emit
/// `bread.module.crashed` if the child dies after having successfully
/// loaded.
pub fn spawn_module_host(
registry: &ModuleHostRegistry,
module_name: &str,
entry_path: &Path,
permissions: &[ModulePermission],
socket_path: &Path,
emit_tx: &UnboundedSender<BreadEvent>,
) -> Result<ModuleHostOutcome> {
registry.terminate_existing(module_name);
let token = uuid::Uuid::new_v4().to_string();
let (outcome_tx, outcome_rx) = std::sync::mpsc::channel();
registry.insert_pending(
token.clone(),
PendingModuleHost {
module_name: module_name.to_string(),
permissions: permissions.to_vec(),
outcome_tx,
},
);
let bin_path = resolve_module_host_binary();
let mut cmd = Command::new(&bin_path);
cmd.env("BREAD_MODULE_TOKEN", &token)
.env("BREAD_MODULE_ENTRY", entry_path)
.env("BREAD_MODULE_SOCKET", socket_path)
.env("BREAD_MODULE_NAME", module_name)
.stdin(std::process::Stdio::null());
let sandbox_permissions = permissions.to_vec();
let sandbox_bin_path = bin_path.clone();
let sandbox_module_name = module_name.to_string();
let sandbox_entry_path = entry_path.to_path_buf();
// SAFETY: the closure runs in the forked child between fork() and
// execve() (that's what pre_exec is for). It only touches its own
// captured, already-allocated data plus filesystem/landlock syscalls —
// no allocation-unsafe signal-handler tricks, matching the same
// pattern the `landlock` crate's own sandboxing examples use for
// restricting a spawned child.
unsafe {
cmd.pre_exec(move || {
apply_sandbox(&sandbox_bin_path, &sandbox_entry_path, &sandbox_permissions).map_err(|e| {
std::io::Error::other(format!(
"landlock sandbox setup failed for module '{sandbox_module_name}': {e}"
))
})
});
}
let mut child = match cmd.spawn() {
Ok(c) => c,
Err(e) => {
registry.take_pending(&token);
return Err(anyhow!(
"failed to spawn bread-module-host at {}: {e}",
bin_path.display()
));
}
};
let pid = child.id();
registry.insert_active(module_name.to_string(), pid);
info!(module = %module_name, pid, bin = %bin_path.display(), "spawned bread-module-host");
// Reap thread: detects the child exiting for ANY reason (clean exit,
// panic, `kill -9`) without blocking breadd's IPC server or the Lua
// engine thread — this is the mechanism behind the "crash isolation"
// acceptance test (P0 item 5): killing this child must not take breadd
// or any other module down with it, and breadd must notice and report
// it via `bread.module.crashed`.
{
let registry = registry.clone();
let emit_tx = emit_tx.clone();
let module_name = module_name.to_string();
let thread_name = format!("mh-reap-{}", short(&module_name));
if let Err(e) = std::thread::Builder::new()
.name(thread_name)
.spawn(move || {
let status = child.wait();
registry.remove_active(&module_name);
let (reason, exit_code, signal) = describe_exit(&status);
warn!(module = %module_name, pid, reason = %reason, "bread-module-host exited");
let _ = emit_tx.send(BreadEvent::new(
"bread.module.crashed",
AdapterSource::System,
serde_json::json!({
"module": module_name,
"pid": pid,
"reason": reason,
"exit_code": exit_code,
"signal": signal,
}),
));
})
{
error!(error = %e, "failed to spawn module-host reap thread");
}
}
match outcome_rx.recv_timeout(READY_TIMEOUT) {
Ok(outcome) => Ok(outcome),
Err(_) => {
registry.take_pending(&token);
Ok(ModuleHostOutcome::LoadError(format!(
"module-host for '{module_name}' did not report ready within {:?}",
READY_TIMEOUT
)))
}
}
}
fn short(name: &str) -> String {
name.chars().take(12).collect()
}
fn describe_exit(
status: &std::io::Result<std::process::ExitStatus>,
) -> (String, Option<i32>, Option<i32>) {
match status {
Ok(s) => {
if let Some(code) = s.code() {
(format!("exited with code {code}"), Some(code), None)
} else if let Some(sig) = s.signal() {
(format!("killed by signal {sig}"), None, Some(sig))
} else {
("exited (unknown reason)".to_string(), None, None)
}
}
Err(e) => (format!("wait() failed: {e}"), None, None),
}
}
/// Resolve the `bread-module-host` binary's path: prefer the sibling of
/// `breadd`'s own executable (the layout `cargo build --workspace` and this
/// repo's packaging both produce — all workspace binaries land in the same
/// `target/{debug,release}` or install bindir), falling back to a bare
/// `PATH` lookup for layouts where `current_exe()` resolution is
/// unreliable.
pub fn resolve_module_host_binary() -> PathBuf {
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
let candidate = dir.join("bread-module-host");
if candidate.exists() {
return candidate;
}
}
}
PathBuf::from("bread-module-host")
}
/// Build and apply the Landlock ruleset for a module-host child, from
/// inside `Command::pre_exec` (i.e. after `fork()`, before `execve()` of
/// `bread-module-host` itself — so the restriction covers that very
/// `execve()` too, which is why the baseline rules below exist at all).
///
/// # The baseline (always granted, not manifest-driven)
///
/// A dynamically linked binary needs to read its own file (to `execve` it)
/// and load the shared libraries `ld.so` maps into it. The initial version
/// of this function assumed Landlock's `Execute` access right gates
/// `execve()`/`execveat()` only, and that granting plain `ReadFile` on the
/// library directories would be enough for the dynamic linker's
/// `mmap(..., PROT_EXEC, ...)` calls on `.so` files. That assumption was
/// **wrong** — verified empirically (not just reasoned about from the
/// kernel docs) by spawning a real sandboxed child: with library
/// directories restricted to `ReadFile`-only, even `/bin/sh -c "true"`
/// fails `execve()` with `EACCES` before running a single line of script;
/// granting `Execute` on those directories too makes it work. So the
/// running kernel's Landlock implementation *does* mediate the executable
/// `mmap` the dynamic linker performs via the same `Execute` right,
/// contrary to what a first reading of "Execute a file" (the kernel doc's
/// one-line description) suggests. The baseline therefore grants:
/// - `ReadFile | ReadDir | Execute` on the system library directories and
/// `ReadFile` on `/etc/ld.so.cache`/`/etc/ld.so.preload` — what the
/// dynamic linker actually needs to start this binary at all.
/// - `ReadFile | Execute` on the `bread-module-host` binary's own resolved
/// path specifically (not a whole directory).
///
/// **Known trade-off, not swept under the rug**: this means a module-host
/// child's direct `os.execute`/`io.open` escape hatch, if it names a path
/// under `/usr/lib`/`/lib` (etc.) directly, is not denied by Landlock the
/// way an arbitrary path elsewhere on the filesystem is — the baseline
/// necessarily grants real `Execute` there, not just enough for the linker.
/// This is a materially smaller exposure than "no sandbox at all" (it's
/// bounded to files already shipped in the system's own library
/// directories, not the whole filesystem, and not anything a manifest
/// didn't otherwise ask for), but it is a real gap worth being honest
/// about — see `Documentation.md`'s "Workstream G" section. A fully static
/// build of `bread-module-host` (e.g. targeting `x86_64-unknown-linux-musl`
/// — confirmed available via `rustup target list --installed` in this
/// repo's dev environment) would remove the need for this baseline
/// entirely, since there'd be no dynamic linker involved at all; that's
/// flagged as follow-up work rather than attempted here, since it's a
/// build/packaging change (cross-compiling mlua's vendored Lua and every
/// transitive dependency against musl, plus a CI/xtask change) bigger than
/// this workstream's remaining time budget affords.
///
/// # The manifest-driven grants
///
/// - `fs.read` with a `path` hint -> `ReadFile | ReadDir` scoped to that
/// (`~`-expanded) path prefix.
/// - `fs.write` with a `path` hint -> the read bits above plus
/// `WriteFile | MakeReg | MakeDir` (matches `bread.fs.write`'s own
/// `create_dir_all` + `write` behavior).
/// - `exec` with a `bin` hint -> `ReadFile | Execute` scoped to that
/// binary's resolved path (absolute paths used as-is; bare names are
/// resolved via a `$PATH` search, `which`-style).
/// - `fs.read`/`fs.write` with **no** `path` hint: the RPC bridge's
/// belt-and-suspenders permission check still applies (see
/// `ipc/mod.rs`), but no Landlock rule is added, since Landlock scoping
/// needs a concrete path. A module author who wants the direct
/// `os`/`io` escape hatch mediated at the kernel level too needs to
/// declare a `path` — documented as a known sharp edge in
/// `Documentation.md` rather than silently "fixed" by granting
/// filesystem-wide access.
/// - Every other `PermissionKind` (`state.*`, `notify`, `machine`,
/// `hyprland`, `widget`, `bluetooth`, `profile.activate`) is RPC-gated
/// only (see `ipc/mod.rs`) — they have no filesystem shape to hand
/// Landlock in the first place.
fn apply_sandbox(
module_host_bin: &Path,
entry_path: &Path,
permissions: &[ModulePermission],
) -> Result<()> {
let abi = ABI::V1;
let lib_dir_access = make_bitflags!(AccessFs::{ReadFile | ReadDir | Execute});
let read_file_only = make_bitflags!(AccessFs::{ReadFile});
let read_only = make_bitflags!(AccessFs::{ReadFile | ReadDir});
let read_and_exec = make_bitflags!(AccessFs::{ReadFile | Execute});
let read_and_write =
make_bitflags!(AccessFs::{ReadFile | ReadDir | WriteFile | MakeReg | MakeDir});
let mut ruleset = Ruleset::default()
.handle_access(AccessFs::from_all(abi))
.map_err(|e| anyhow!("landlock handle_access: {e}"))?
.create()
.map_err(|e| anyhow!("landlock ruleset create: {e}"))?;
for dir in ["/usr/lib", "/usr/lib64", "/lib", "/lib64"] {
let p = Path::new(dir);
if p.exists() {
if let Ok(fd) = PathFd::new(p) {
ruleset = ruleset
.add_rule(PathBeneath::new(fd, lib_dir_access))
.map_err(|e| anyhow!("landlock rule for {dir}: {e}"))?;
}
}
}
for f in ["/etc/ld.so.cache", "/etc/ld.so.preload"] {
let p = Path::new(f);
if p.exists() {
if let Ok(fd) = PathFd::new(p) {
ruleset = ruleset
.add_rule(PathBeneath::new(fd, read_file_only))
.map_err(|e| anyhow!("landlock rule for {f}: {e}"))?;
}
}
}
if let Ok(fd) = PathFd::new(module_host_bin) {
ruleset = ruleset
.add_rule(PathBeneath::new(fd, read_and_exec))
.map_err(|e| anyhow!("landlock rule for module-host binary: {e}"))?;
}
// The module-host bootstrap process needs to read its OWN module's
// directory (init.lua, bread.module.toml, an optional lib/ subtree —
// the same directory shape load_scoped_lua_file's in-process
// counterpart reads from) to load any Lua at all, entirely separate
// from whatever `fs.read` the manifest grants for the module's own
// runtime file I/O. Without this rule, EVERY out-of-process module
// fails to load — including ones with no `fs.read` permission at
// all — since it can't even read its own entry file.
if let Some(module_dir) = entry_path.parent() {
if let Ok(fd) = PathFd::new(module_dir) {
ruleset = ruleset
.add_rule(PathBeneath::new(fd, read_only))
.map_err(|e| {
anyhow!("landlock rule for module directory {}: {e}", module_dir.display())
})?;
}
}
for perm in permissions {
match perm.kind {
PermissionKind::FsRead => {
if let Some(path) = &perm.path {
let expanded = bread_shared::expand_path(path);
if let Ok(fd) = PathFd::new(&expanded) {
ruleset = ruleset
.add_rule(PathBeneath::new(fd, read_only))
.map_err(|e| {
anyhow!("landlock fs.read rule for {}: {e}", expanded.display())
})?;
}
}
}
PermissionKind::FsWrite => {
if let Some(path) = &perm.path {
let expanded = bread_shared::expand_path(path);
if let Ok(fd) = PathFd::new(&expanded) {
ruleset = ruleset
.add_rule(PathBeneath::new(fd, read_and_write))
.map_err(|e| {
anyhow!("landlock fs.write rule for {}: {e}", expanded.display())
})?;
}
}
}
PermissionKind::Exec => {
if let Some(bin) = &perm.bin {
if let Some(resolved) = resolve_bin_path(bin) {
if let Ok(fd) = PathFd::new(&resolved) {
ruleset = ruleset
.add_rule(PathBeneath::new(fd, read_and_exec))
.map_err(|e| {
anyhow!("landlock exec rule for {}: {e}", resolved.display())
})?;
}
}
}
}
_ => {}
}
}
let status = ruleset
.restrict_self()
.map_err(|e| anyhow!("landlock restrict_self: {e}"))?;
if !matches!(status.ruleset, RulesetStatus::FullyEnforced) {
// Not fatal: PartiallyEnforced still means real kernel enforcement
// for whatever subset the running kernel/LSM stack supports (see
// this module's doc comment — verified directly against this
// repo's dev kernel, which reports PartiallyEnforced yet still
// denies out-of-scope reads). NotEnforced (pre-5.13 kernel, or
// Landlock compiled out) would mean this module is running fully
// unsandboxed — loud enough to want in the log, not loud enough to
// refuse to start the module entirely and regress availability.
eprintln!(
"bread-module-host: landlock ruleset status = {:?} (not fully enforced on this kernel)",
status.ruleset
);
}
Ok(())
}
/// `which`-style resolution for an `exec` permission's `bin` hint: absolute
/// paths are used as-is, bare names are searched on `$PATH`.
fn resolve_bin_path(bin: &str) -> Option<PathBuf> {
let p = Path::new(bin);
if p.is_absolute() {
return Some(p.to_path_buf());
}
let path_var = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&path_var) {
let candidate = dir.join(bin);
if candidate.is_file() {
return Some(candidate);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
/// The single most important test in this whole workstream (see the
/// task's P0 item 4 and `Documentation.md`'s "Workstream G" section):
/// a real spawned child, restricted only by `apply_sandbox` for a
/// module granted `fs.read` on exactly one directory, must be denied
/// by the *kernel* — not a Lua-level check — when it tries to read a
/// file outside that directory. This talks to `apply_sandbox` and
/// `Command::pre_exec` exactly the way `spawn_module_host` does; the
/// full end-to-end version (going through the real IPC handshake and
/// an actual `os.execute`/`io.open` call from inside Lua) lives in
/// `breadd/tests/module_host_sandbox.rs`.
#[test]
fn landlock_denies_reads_outside_granted_path() {
let allowed_dir = tempfile::tempdir().unwrap();
let allowed_file = allowed_dir.path().join("allowed.txt");
std::fs::write(&allowed_file, b"ok").unwrap();
let denied_dir = tempfile::tempdir().unwrap();
let denied_file = denied_dir.path().join("secret.txt");
std::fs::write(&denied_file, b"nope").unwrap();
// Mirrors the task's own acceptance scenario verbatim:
// `os.execute("cat /etc/shadow")` from inside a module granted
// `fs.read` for exactly one other directory. `cat` does a plain
// `open()`+`read()` — no shell builtin involved — which is both
// the most faithful stand-in for the direct `os`/`io` escape hatch
// and (empirically, see the note on `no_exec_permission_...` below)
// avoids a bash `read`-builtin quirk that turned out to need more
// than a `ReadFile` grant for reasons unrelated to what this test
// is actually checking.
let cat_bin = resolve_bin_path("cat").expect("cat not found on $PATH");
let permissions = vec![
ModulePermission {
kind: PermissionKind::FsRead,
path: Some(allowed_dir.path().to_string_lossy().to_string()),
bin: None,
},
ModulePermission {
kind: PermissionKind::Exec,
path: None,
bin: Some(cat_bin.to_string_lossy().to_string()),
},
];
let sh_bin = which_sh();
let mut cmd = Command::new(&sh_bin);
cmd.arg("-c").arg(format!(
"{cat} {allowed} && echo ALLOWED_OK; {cat} {denied} && echo DENIED_UNEXPECTEDLY_OK",
cat = cat_bin.display(),
allowed = allowed_file.display(),
denied = denied_file.display(),
));
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
let sandbox_bin = sh_bin.clone();
unsafe {
cmd.pre_exec(move || {
apply_sandbox(&sandbox_bin, Path::new("/nonexistent/dummy/entry.lua"), &permissions)
.map_err(|e| std::io::Error::other(e.to_string()))
});
}
let output = cmd.output().expect("failed to run sandboxed sh");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stdout.contains("ALLOWED_OK"),
"expected the granted directory to remain readable; stdout={stdout} stderr={stderr}"
);
assert!(
!stdout.contains("DENIED_UNEXPECTEDLY_OK"),
"sandboxed process read a file OUTSIDE its granted fs.read path — Landlock did not enforce; stdout={stdout} stderr={stderr}"
);
// The kernel denial surfaces as `cat`'s own "Permission denied"
// (EACCES from open()), on stderr — confirming this was an OS-level
// denial, not e.g. the file simply not existing.
assert!(
stderr.to_lowercase().contains("permission denied"),
"expected a kernel permission-denied error for the out-of-scope read; stderr={stderr}"
);
}
#[test]
fn no_exec_permission_means_binary_cannot_be_executed_at_all() {
let dir = tempfile::tempdir().unwrap();
let script_path = dir.path().join("run.sh");
{
let mut f = std::fs::File::create(&script_path).unwrap();
writeln!(f, "#!/bin/sh\necho SHOULD_NOT_RUN").unwrap();
}
std::fs::set_permissions(
&script_path,
std::os::unix::fs::PermissionsExt::from_mode(0o755),
)
.unwrap();
// No permissions granted at all: the sandboxed process should not
// be able to execute ANYTHING, including a script sitting right
// next to files it might otherwise be able to read.
let permissions: Vec<ModulePermission> = vec![];
let sh_bin = which_sh();
let mut cmd = Command::new(&sh_bin);
cmd.arg("-c")
.arg(format!("{} && echo RAN", script_path.display()));
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
let sandbox_bin = sh_bin.clone();
unsafe {
cmd.pre_exec(move || {
apply_sandbox(&sandbox_bin, Path::new("/nonexistent/dummy/entry.lua"), &permissions)
.map_err(|e| std::io::Error::other(e.to_string()))
});
}
let output = cmd.output().expect("failed to run sandboxed sh");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
!stdout.contains("RAN"),
"sandboxed process executed a script with no `exec` permission granted; stdout={stdout}"
);
}
fn which_sh() -> PathBuf {
for candidate in ["/bin/sh", "/usr/bin/sh"] {
let p = PathBuf::from(candidate);
if p.exists() {
return p;
}
}
panic!("no /bin/sh or /usr/bin/sh found — cannot run sandbox tests");
}
}

View file

@ -438,6 +438,21 @@ async fn modules_list_returns_array() -> Result<()> {
/// `bread.state.get(...)`, but `bread.fs` and `bread.exec` must be
/// genuinely *absent* from the `bread` table it sees — `nil`, not merely
/// permission-denied when called.
///
/// *Since Workstream G*: a module that declares `[[permissions]]` (any,
/// including an explicit empty list — see
/// `explicit_empty_permissions_is_scoped_but_not_flagged_ungated` below)
/// now runs out-of-process in a real `bread-module-host` child instead of
/// in-process with a scoped Lua `_ENV` (see `breadd/src/lua/mod.rs`'s
/// `load_module`) — the presence/absence check this test exists for still
/// holds, just enforced by what `bread-module-host`'s own `ModuleHostLua`
/// constructs the `bread` table from (see `bread-module-host/src/
/// lua_env.rs`) instead of `build_scoped_env`. `M.store.set(...)` no
/// longer works as the result-reporting channel here, since an
/// out-of-process module's `bread.module().store` is process-local (not
/// synced back to `breadd`'s `RuntimeState` — a documented gap, see
/// `Documentation.md`'s Workstream G section) — `bread.emit(...)` is used
/// instead, which *does* cross the process boundary via the RPC bridge.
#[tokio::test]
async fn scoped_module_sees_only_granted_state_read_permission() -> Result<()> {
let manifest = r#"
@ -455,23 +470,26 @@ path = "monitors"
let module_lua = r#"
local M = bread.module({ name = "scoped-test", version = "1.0.0" })
function M.on_load()
bread.on("test.trigger", function()
local ok = pcall(bread.state.get, "monitors")
M.store.set("state_get_ok", ok)
M.store.set("fs_present", bread.fs ~= nil)
M.store.set("exec_present", bread.exec ~= nil)
M.store.set("exec_capture_present", bread.exec_capture ~= nil)
M.store.set("bluetooth_present", bread.bluetooth ~= nil)
-- Baseline must still work from inside a scoped module.
M.store.set("json_present", bread.json ~= nil)
M.store.set("log_present", bread.log ~= nil)
end
bread.emit("test.scoped_result", {
state_get_ok = ok,
fs_present = bread.fs ~= nil,
exec_present = bread.exec ~= nil,
exec_capture_present = bread.exec_capture ~= nil,
bluetooth_present = bread.bluetooth ~= nil,
-- Baseline must still work from inside a scoped module.
json_present = bread.json ~= nil,
log_present = bread.log ~= nil,
})
end)
return M
"#;
let harness = TestHarness::spawn_with_module("scoped-test", Some(manifest), module_lua)?;
harness.wait_until_ready().await?;
harness.wait_for_module_loaded("scoped-test").await?;
let modules = harness
.send_request("state.get", json!({"key": "modules"}))
@ -487,10 +505,13 @@ return M
Some("loaded"),
"module failed to load: {entry}"
);
assert_eq!(
entry.get("ungated"),
Some(&json!(false)),
"a module with a manifest that declares permissions must not be flagged ungated"
);
let store = entry
.get("store")
.ok_or_else(|| anyhow!("no store on module status: {entry}"))?;
let store = harness.trigger_and_await_result("test.scoped_result").await?;
assert_eq!(store.get("state_get_ok"), Some(&json!(true)));
assert_eq!(
store.get("fs_present"),
@ -507,12 +528,6 @@ return M
assert_eq!(store.get("json_present"), Some(&json!(true)), "baseline bread.json must still be present");
assert_eq!(store.get("log_present"), Some(&json!(true)), "baseline bread.log must still be present");
assert_eq!(
entry.get("ungated"),
Some(&json!(false)),
"a module with a manifest that declares permissions must not be flagged ungated"
);
harness.shutdown();
Ok(())
}
@ -538,6 +553,15 @@ return M
let harness = TestHarness::spawn_with_module("legacy-test", None, module_lua)?;
harness.wait_until_ready().await?;
// `wait_until_ready` only proves the IPC socket is accepting
// connections — module loading runs concurrently on the Lua engine's
// own thread (see `lua::spawn_runtime`), so without this the check
// below races module load completion even for an in-process module.
// Usually fast enough not to matter, but flaky under this suite's
// heavier concurrent process load (see Workstream G's
// `module_host_sandbox.rs` tests, which run real sandboxed child
// processes alongside this one).
harness.wait_for_module_loaded("legacy-test").await?;
let modules = harness
.send_request("state.get", json!({"key": "modules"}))
@ -579,6 +603,12 @@ return M
/// "baseline only" declaration, distinct from no manifest at all: it must
/// scope the module down for real (no fs/exec/etc.) but must *not* trip the
/// `ungated` doctor warning, since the author made a conscious choice.
///
/// *Since Workstream G*: `Some(vec![])` also opts this module into the
/// out-of-process sandboxed path (same as any other declared
/// `[[permissions]]`), and — as in the test above — results come back via
/// `bread.emit` on a `test.trigger` handler rather than `M.store`. See that
/// test's doc comment for the full explanation.
#[tokio::test]
async fn explicit_empty_permissions_is_scoped_but_not_flagged_ungated() -> Result<()> {
let manifest = r#"
@ -593,16 +623,19 @@ permissions = []
let module_lua = r#"
local M = bread.module({ name = "empty-perms-test", version = "1.0.0" })
function M.on_load()
M.store.set("fs_present", bread.fs ~= nil)
M.store.set("state_present", bread.state ~= nil)
end
bread.on("test.trigger", function()
bread.emit("test.empty_perms_result", {
fs_present = bread.fs ~= nil,
state_present = bread.state ~= nil,
})
end)
return M
"#;
let harness = TestHarness::spawn_with_module("empty-perms-test", Some(manifest), module_lua)?;
harness.wait_until_ready().await?;
harness.wait_for_module_loaded("empty-perms-test").await?;
let modules = harness
.send_request("state.get", json!({"key": "modules"}))
@ -617,15 +650,16 @@ return M
.ok_or_else(|| anyhow!("empty-perms-test module not found in modules state; dump: {modules}"))?;
assert_eq!(entry.get("status").and_then(Value::as_str), Some("loaded"));
let store = entry.get("store").unwrap();
assert_eq!(store.get("fs_present"), Some(&json!(false)));
assert_eq!(store.get("state_present"), Some(&json!(false)));
assert_eq!(
entry.get("ungated"),
Some(&json!(false)),
"an explicit empty permissions list is a deliberate declaration, not 'undeclared'"
);
let store = harness.trigger_and_await_result("test.empty_perms_result").await?;
assert_eq!(store.get("fs_present"), Some(&json!(false)));
assert_eq!(store.get("state_present"), Some(&json!(false)));
harness.shutdown();
Ok(())
}
@ -1556,7 +1590,94 @@ enabled = false
Ok(parsed.get("result").cloned().unwrap_or_else(|| json!({})))
}
fn shutdown(mut self) {
/// Poll `modules.list`/`state.get "modules"`-equivalent status until
/// `name` reaches `Loaded` (or `LoadError`, which is treated as a test
/// failure). Out-of-process modules (Workstream G:
/// `decl.permissions.is_some()`, see `breadd/src/lua/mod.rs`) report
/// their load outcome asynchronously — a passing `wait_until_ready`
/// only proves the daemon's IPC socket itself is up, not that any
/// particular module has finished spawning/connecting/authenticating/
/// running its `init.lua` yet.
async fn wait_for_module_loaded(&self, name: &str) -> Result<()> {
// Comfortably exceeds module_host::READY_TIMEOUT (breadd's own
// spawn-side wait) so this test-side poll doesn't give up before
// breadd itself would.
let deadline = Instant::now() + Duration::from_secs(55);
while Instant::now() < deadline {
let modules = self.send_request("state.get", json!({"key": "modules"})).await?;
if let Some(arr) = modules.as_array() {
for m in arr {
if m.get("name").and_then(Value::as_str) == Some(name) {
match m.get("status").and_then(Value::as_str) {
Some("loaded") => return Ok(()),
Some("load_error") => {
return Err(anyhow!("module '{name}' failed to load: {m}"))
}
_ => {}
}
}
}
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
Err(anyhow!("module '{name}' did not reach Loaded within timeout"))
}
/// Subscribe to `result_event`, send a `test.trigger` manual emit to
/// kick off whatever Lua handler is waiting on it, and return the
/// triggered event's `data`. See `breadd/tests/module_host_sandbox.rs`'s
/// module doc comment for why this trigger-based pattern exists at all:
/// a module reporting its result from `on_load` directly would race the
/// daemon's own startup sequence, since `tokio::sync::broadcast` (what
/// `events.subscribe` reads from) never replays history to a subscriber
/// that joins after a send already happened.
async fn trigger_and_await_result(&self, result_event: &str) -> Result<Value> {
let stream = UnixStream::connect(self.socket_path()).await?;
let (read_half, mut write_half) = stream.into_split();
let subscribe = json!({
"id": "sub-1",
"method": "events.subscribe",
"params": { "filter": result_event },
});
write_half
.write_all(format!("{}\n", serde_json::to_string(&subscribe)?).as_bytes())
.await?;
let mut reader = BufReader::new(read_half).lines();
let _ack = reader.next_line().await?;
self.send_request("emit", json!({ "event": "test.trigger", "data": {} }))
.await?;
let line = timeout(Duration::from_secs(10), reader.next_line())
.await
.map_err(|_| anyhow!("timed out waiting for {result_event}"))??
.ok_or_else(|| anyhow!("connection closed before {result_event} arrived"))?;
let event: Value = serde_json::from_str(&line)?;
event
.get("data")
.cloned()
.ok_or_else(|| anyhow!("{result_event} missing data"))
}
fn shutdown(self) {
// Drop (below) does the actual killing.
drop(self);
}
}
impl Drop for TestHarness {
/// A test that fails partway through (an `?`-propagated error, a
/// failed `assert!` unwinding) must not leak a live `breadd` process —
/// worse, since Workstream G, a leaked `breadd` can itself have spawned
/// `bread-module-host` children under a real Landlock sandbox, which
/// don't exit on their own once the parent socket's other end goes
/// away instantly (they notice on their next read and exit, but that's
/// not instant). Without this, a single failing test in this file
/// leaves orphaned processes for every *other* concurrently-running
/// test to contend with for CPU/scheduler time — turning one flaky
/// failure into cascading slowdowns/timeouts across the whole suite
/// (observed directly while developing Workstream G's tests).
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}

View file

@ -0,0 +1,537 @@
//! Workstream G acceptance tests: the real, end-to-end version of the two
//! things this workstream exists to prove, going through a real spawned
//! `breadd` + a real spawned `bread-module-host` child + a real IPC
//! handshake — not the in-isolation Landlock-mechanism unit tests in
//! `breadd/src/module_host.rs` (`landlock_denies_reads_outside_granted_path`,
//! `no_exec_permission_means_binary_cannot_be_executed_at_all`), which only
//! exercise `apply_sandbox` directly against a plain `sh`/`cat`.
//!
//! 1. [`os_execute_and_io_open_are_denied_at_the_kernel_level_outside_granted_scope`] —
//! a module granted `fs.read` for exactly one directory (and nothing
//! else) runs real Lua that calls `io.open`/`os.execute` directly,
//! bypassing the RPC bridge entirely and going straight for the
//! `os`/`io` escape hatch Workstream D's in-process scoping admittedly
//! leaves open (see `breadd/src/lua/mod.rs`'s `build_scoped_env` doc
//! comment). This is the single most important test in the whole
//! workstream: proving the denial is a *kernel* permission error, not a
//! Lua-level check that a well-behaved module merely chooses to respect.
//! 2. [`killing_a_module_host_child_does_not_take_down_breadd_or_other_modules`] —
//! `kill -9` on a running module-host child's PID, confirming `breadd`
//! itself and a second, unrelated module both keep responding, and that
//! `breadd` detects the death and reports it via `bread.module.crashed`.
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
use anyhow::{anyhow, Result};
use serde_json::{json, Value};
use tempfile::TempDir;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::UnixStream;
use tokio::time::{sleep, timeout};
// NOTE: these tests need `target/{debug,release}/bread-module-host` to
// already exist — `breadd::module_host::resolve_module_host_binary` looks
// for it as a sibling of `breadd`'s own executable. `bread-module-host` is
// a bin-only crate (no `[lib]` target — deliberately, see its Cargo.toml),
// so it can't be pulled in as a `[dev-dependencies]` entry to force cargo
// to build it via `env!("CARGO_BIN_EXE_...")`, the usual trick for this.
// Running via `cargo test --workspace` (this repo's documented/required
// verification command — see Documentation.md) builds every workspace
// member, including `bread-module-host`, before any test runs, so this
// isn't a problem in practice; running `cargo test -p breadd` in isolation
// without a prior `cargo build --workspace` would need one first.
struct TestHarness {
_temp: TempDir,
child: Child,
socket_path: PathBuf,
#[allow(dead_code)]
home: PathBuf,
}
impl TestHarness {
/// Spawns a real `breadd` with `[modules] builtin = false` and one
/// directory-based module per `(name, manifest_toml, init_lua)` entry —
/// the same on-disk shape `bread modules install` produces
/// (`<modules_dir>/<name>/{bread.module.toml,init.lua}`).
fn spawn_with_modules(modules: &[(&str, &str, &str)]) -> Result<Self> {
let temp = tempfile::tempdir()?;
let runtime_dir = temp.path().join("runtime");
let config_home = temp.path().join("config");
let home = temp.path().join("home");
fs::create_dir_all(&runtime_dir)?;
fs::create_dir_all(&config_home)?;
fs::create_dir_all(&home)?;
let bread_cfg = config_home.join("bread");
fs::create_dir_all(bread_cfg.join("modules"))?;
fs::write(
bread_cfg.join("init.lua"),
"bread.on('bread.system.startup', function() end)\n",
)?;
for (name, manifest_toml, init_lua) in modules {
let module_dir = bread_cfg.join("modules").join(name);
fs::create_dir_all(&module_dir)?;
if !manifest_toml.is_empty() {
fs::write(module_dir.join("bread.module.toml"), manifest_toml)?;
}
fs::write(module_dir.join("init.lua"), init_lua)?;
}
fs::write(
bread_cfg.join("breadd.toml"),
r#"
[daemon]
log_level = "error"
[lua]
entry_point = "~/.config/bread/init.lua"
module_path = "~/.config/bread/modules"
[modules]
builtin = false
[adapters.hyprland]
enabled = false
[adapters.udev]
enabled = false
[adapters.power]
enabled = false
[adapters.network]
enabled = false
"#,
)?;
let socket_path = runtime_dir.join("bread").join("breadd.sock");
let child = Command::new(env!("CARGO_BIN_EXE_breadd"))
.env("XDG_RUNTIME_DIR", &runtime_dir)
.env("XDG_CONFIG_HOME", &config_home)
.env("HOME", &home)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()?;
Ok(Self {
_temp: temp,
child,
socket_path,
home,
})
}
fn socket_path(&self) -> &Path {
&self.socket_path
}
async fn wait_until_ready(&self) -> Result<()> {
let deadline = Instant::now() + Duration::from_secs(8);
while Instant::now() < deadline {
if self.socket_path.exists() {
if self.send_request("ping", json!({})).await.is_ok() {
return Ok(());
}
}
sleep(Duration::from_millis(100)).await;
}
Err(anyhow!("daemon did not become ready in time"))
}
/// Poll `modules.list` until `name` shows up `Loaded` — out-of-process
/// modules report their load outcome asynchronously (see
/// `breadd/src/lua/mod.rs`'s `load_out_of_process_module`), so a plain
/// `wait_until_ready` (which only proves the daemon's IPC socket is up)
/// isn't enough to know a specific module has finished spawning,
/// connecting, authenticating, and running its `init.lua`.
async fn wait_for_module_loaded(&self, name: &str) -> Result<()> {
// Comfortably exceeds module_host::READY_TIMEOUT (breadd's own
// spawn-side wait) so this test-side poll doesn't give up before
// breadd itself would.
let deadline = Instant::now() + Duration::from_secs(55);
while Instant::now() < deadline {
let modules = self.send_request("modules.list", json!({})).await?;
if let Some(arr) = modules.as_array() {
for m in arr {
if m.get("name").and_then(Value::as_str) == Some(name) {
if m.get("status").and_then(Value::as_str) == Some("loaded") {
return Ok(());
}
if m.get("status").and_then(Value::as_str) == Some("load_error") {
return Err(anyhow!(
"module '{name}' failed to load: {:?}",
m.get("last_error")
));
}
}
}
}
sleep(Duration::from_millis(100)).await;
}
Err(anyhow!("module '{name}' did not reach Loaded within timeout"))
}
async fn send_request(&self, method: &str, params: Value) -> Result<Value> {
let stream = UnixStream::connect(self.socket_path()).await?;
let (read_half, mut write_half) = stream.into_split();
let req = json!({ "id": "1", "method": method, "params": params });
write_half
.write_all(format!("{}\n", serde_json::to_string(&req)?).as_bytes())
.await?;
let mut lines = BufReader::new(read_half).lines();
let line = lines
.next_line()
.await?
.ok_or_else(|| anyhow!("missing ipc response"))?;
let parsed: Value = serde_json::from_str(&line)?;
if let Some(err) = parsed.get("error").and_then(Value::as_str) {
return Err(anyhow!(err.to_string()));
}
Ok(parsed.get("result").cloned().unwrap_or_else(|| json!({})))
}
/// Find the PID of a `bread-module-host` child spawned for this
/// harness's `breadd` by scanning `/proc/*/environ` for
/// `BREAD_MODULE_NAME=<module_name>` — the module-host binary never
/// puts its identity in argv (see its own doc comment on why:
/// `/proc/*/cmdline` is visible to any process), so this is the same
/// kind of environment-based lookup, just from the test side instead
/// of breadd's.
fn find_module_host_pid(&self, module_name: &str) -> Result<u32> {
let deadline = Instant::now() + Duration::from_secs(10);
loop {
for entry in fs::read_dir("/proc")?.flatten() {
let file_name = entry.file_name();
let Some(pid_str) = file_name.to_str() else {
continue;
};
let Ok(pid) = pid_str.parse::<u32>() else {
continue;
};
let environ_path = entry.path().join("environ");
let Ok(environ) = fs::read(&environ_path) else {
continue;
};
let wanted = format!("BREAD_MODULE_NAME={module_name}");
if environ
.split(|b| *b == 0)
.any(|var| var == wanted.as_bytes())
{
return Ok(pid);
}
}
if Instant::now() > deadline {
return Err(anyhow!(
"no bread-module-host process found for module '{module_name}'"
));
}
std::thread::sleep(Duration::from_millis(100));
}
}
fn shutdown(self) {
// Drop does the actual killing (see below) — this method exists so
// call sites can be explicit about "done with this harness" without
// caring exactly how cleanup happens.
drop(self);
}
}
impl Drop for TestHarness {
/// Any `?`-propagated failure partway through a test (a timed-out
/// event, a failed assertion via `anyhow!` — though assertion panics
/// unwind rather than `?`-return, they still run `Drop`) must not leak
/// a live `breadd` (and, transitively, any `bread-module-host`
/// children it spawned) — `kill` here, not just on the happy path via
/// `shutdown()`, is what keeps a failed test run from leaving orphaned
/// sandboxed processes behind for the next run to trip over.
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
/// The P0 acceptance test: verified at the OS level, not asserted. See this
/// file's module doc comment.
#[tokio::test]
async fn os_execute_and_io_open_are_denied_at_the_kernel_level_outside_granted_scope() -> Result<()>
{
let allowed_dir = tempfile::tempdir()?;
let allowed_file = allowed_dir.path().join("allowed.txt");
fs::write(&allowed_file, "allowed-content")?;
// Deliberately NOT under $HOME/anything the manifest grants, and
// deliberately world-readable-by-this-user (normal DAC permissions
// alone would NOT deny this) so a pass here can only be explained by
// Landlock, not by an unrelated ordinary permission error — the same
// reasoning as the `deny_dir`/`secret.txt` split in
// `breadd/src/module_host.rs`'s unit tests, just end-to-end this time.
let deny_dir = tempfile::tempdir()?;
let deny_file = deny_dir.path().join("secret.txt");
fs::write(&deny_file, "top-secret-content")?;
let manifest = format!(
r#"
name = "escape-hatch-test"
[[permissions]]
type = "fs.read"
path = "{}"
"#,
allowed_dir.path().display()
);
// No `exec` permission granted at all, so `os.execute` should fail
// outright (can't even launch `/bin/sh` under Landlock) — and
// `io.open`, which doesn't need a subprocess at all, directly tests
// the FsRead scoping. Results are reported back over `bread.emit`
// (baseline, always available) since this module runs in a separate
// process we can't otherwise introspect from the test.
//
// The checks run on a `bread.on("test.trigger", ...)` handler, NOT in
// `on_load` — a module loads (and, if it ran in `on_load`, would emit
// its result) as part of daemon startup, which races the test's own
// `events.subscribe` connection. `tokio::sync::broadcast` (what
// `events.subscribe` reads from) does not replay history to a
// subscriber that joins after a send already happened — a late
// subscription just misses it, no error, no buffering — so without
// this explicit trigger the test would be racing the daemon's own
// startup sequence rather than reliably observing anything.
let init_lua = format!(
r#"
local M = bread.module({{ name = "escape-hatch-test", version = "1.0.0" }})
bread.on("test.trigger", function(trigger_event)
local allowed_result = "ALLOWED_READ_FAILED"
local fh = io.open("{allowed}", "r")
if fh then
local content = fh:read("*a")
fh:close()
allowed_result = "ALLOWED_READ_OK:" .. content
end
local denied_result = "DENIED_READ_UNEXPECTEDLY_SUCCEEDED"
local deny_fh = io.open("{denied}", "r")
if deny_fh then
local content = deny_fh:read("*a")
deny_fh:close()
denied_result = "DENIED_READ_UNEXPECTEDLY_SUCCEEDED:" .. content
else
denied_result = "io.open denied"
end
local exec_ok = os.execute("cat {denied} > /dev/null 2>&1")
local exec_result
if exec_ok == true then
exec_result = "EXEC_UNEXPECTEDLY_SUCCEEDED"
else
exec_result = "exec denied or failed"
end
bread.emit("test.escape_hatch_result", {{
allowed_result = allowed_result,
denied_result = denied_result,
exec_result = exec_result,
}})
end)
return M
"#,
allowed = allowed_file.display(),
denied = deny_file.display(),
);
let harness = TestHarness::spawn_with_modules(&[("escape-hatch-test", &manifest, &init_lua)])?;
harness.wait_until_ready().await?;
// Guarantees the module's `bread.on("test.trigger", ...)` subscription
// is already registered server-side before the trigger below is sent —
// "loaded" status is only reported (via `module_host.status`) after the
// module's whole init.lua chunk, including that top-level `bread.on`
// call, has finished executing. See this test's other race-avoidance
// comment above for why this matters.
harness.wait_for_module_loaded("escape-hatch-test").await?;
let stream = UnixStream::connect(harness.socket_path()).await?;
let (read_half, mut write_half) = stream.into_split();
let subscribe = json!({
"id": "sub-1",
"method": "events.subscribe",
"params": { "filter": "test.escape_hatch_result" },
});
write_half
.write_all(format!("{}\n", serde_json::to_string(&subscribe)?).as_bytes())
.await?;
let mut reader = BufReader::new(read_half).lines();
let _ack = reader.next_line().await?;
harness
.send_request("emit", json!({ "event": "test.trigger", "data": {} }))
.await?;
let line = timeout(Duration::from_secs(15), reader.next_line())
.await
.map_err(|_| anyhow!("timed out waiting for test.escape_hatch_result event"))??
.ok_or_else(|| anyhow!("connection closed before event arrived"))?;
let event: Value = serde_json::from_str(&line)?;
let data = event
.get("data")
.ok_or_else(|| anyhow!("event missing data"))?;
let allowed_result = data.get("allowed_result").and_then(Value::as_str).unwrap_or("");
let denied_result = data.get("denied_result").and_then(Value::as_str).unwrap_or("");
let exec_result = data.get("exec_result").and_then(Value::as_str).unwrap_or("");
assert!(
allowed_result.starts_with("ALLOWED_READ_OK"),
"the granted fs.read directory should remain readable via direct io.open; got {allowed_result:?}"
);
assert!(
!denied_result.contains("UNEXPECTEDLY_SUCCEEDED"),
"io.open on a path OUTSIDE the granted fs.read scope must be denied at the kernel level (Landlock), not merely un-offered by an RPC binding — got {denied_result:?}"
);
assert!(
!exec_result.contains("UNEXPECTEDLY_SUCCEEDED"),
"os.execute with no `exec` permission granted must not be able to run anything at all — got {exec_result:?}"
);
harness.shutdown();
Ok(())
}
/// P0 item 5: killing a module-host child must not take `breadd` (or any
/// other module) down with it, and `breadd` must notice and report it.
#[tokio::test]
async fn killing_a_module_host_child_does_not_take_down_breadd_or_other_modules() -> Result<()> {
// An explicit, empty `permissions = []` — not "no manifest at all" — is
// what opts a module into the out-of-process sandboxed path with zero
// grants (see `ModuleDecl::permissions`'s doc comment in
// `breadd/src/lua/mod.rs`: `None` means "no manifest", which keeps
// today's in-process, ungated legacy behavior; `Some(vec![])` means
// "deliberately baseline-only" and IS routed out-of-process).
let victim_manifest = "name = \"victim\"\npermissions = []\n";
let victim_init = r#"
local M = bread.module({ name = "victim", version = "1.0.0" })
function M.on_load() end
return M
"#;
// The "control" module stays in-process (no manifest at all — the
// legacy/backward-compat path) specifically so this test also proves
// an out-of-process module's crash doesn't disturb an *in-process*
// module either, not just breadd's own IPC responsiveness.
let control_init = r#"
local M = bread.module({ name = "control", version = "1.0.0" })
bread.on("bread.custom.ping_control", function(event)
bread.emit("bread.custom.pong_control", {})
end)
return M
"#;
let harness = TestHarness::spawn_with_modules(&[
("victim", victim_manifest, victim_init),
("control", "", control_init),
])?;
harness.wait_until_ready().await?;
harness.wait_for_module_loaded("victim").await?;
// Subscribe to bread.module.crashed BEFORE killing, so we can't miss it.
let crash_stream = UnixStream::connect(harness.socket_path()).await?;
let (crash_read, mut crash_write) = crash_stream.into_split();
crash_write
.write_all(
format!(
"{}\n",
serde_json::to_string(&json!({
"id": "crash-sub",
"method": "events.subscribe",
"params": { "filter": "bread.module.crashed" },
}))?
)
.as_bytes(),
)
.await?;
let mut crash_reader = BufReader::new(crash_read).lines();
let _ack = crash_reader.next_line().await?;
let victim_pid = harness.find_module_host_pid("victim")?;
let kill_status = Command::new("kill").args(["-9", &victim_pid.to_string()]).status()?;
assert!(kill_status.success(), "failed to send SIGKILL to victim module-host");
// breadd itself must keep responding.
let ping = harness.send_request("ping", json!({})).await?;
assert_eq!(ping.get("ok").and_then(Value::as_bool), Some(true));
// The unrelated in-process "control" module must keep dispatching
// events normally.
let control_stream = UnixStream::connect(harness.socket_path()).await?;
let (control_read, mut control_write) = control_stream.into_split();
control_write
.write_all(
format!(
"{}\n",
serde_json::to_string(&json!({
"id": "pong-sub",
"method": "events.subscribe",
"params": { "filter": "bread.custom.pong_control" },
}))?
)
.as_bytes(),
)
.await?;
let mut control_reader = BufReader::new(control_read).lines();
let _ack = control_reader.next_line().await?;
harness
.send_request(
"emit",
json!({ "event": "bread.custom.ping_control", "data": {} }),
)
.await?;
let pong_line = timeout(Duration::from_secs(10), control_reader.next_line())
.await
.map_err(|_| anyhow!("control module did not respond after victim was killed"))??
.ok_or_else(|| anyhow!("control connection closed unexpectedly"))?;
let pong: Value = serde_json::from_str(&pong_line)?;
assert_eq!(
pong.get("event").and_then(Value::as_str),
Some("bread.custom.pong_control"),
"control module should still be alive and responsive after the victim module-host was killed"
);
// breadd must have detected the death and reported it.
let crash_line = timeout(Duration::from_secs(10), crash_reader.next_line())
.await
.map_err(|_| anyhow!("bread.module.crashed was not emitted after kill -9"))??
.ok_or_else(|| anyhow!("crash subscription connection closed unexpectedly"))?;
let crash_event: Value = serde_json::from_str(&crash_line)?;
assert_eq!(
crash_event
.get("data")
.and_then(|d| d.get("module"))
.and_then(Value::as_str),
Some("victim"),
"bread.module.crashed should identify the module whose host process died"
);
assert_eq!(
crash_event
.get("data")
.and_then(|d| d.get("signal"))
.and_then(Value::as_i64),
Some(9),
"the crash report should reflect that the process was killed by SIGKILL"
);
harness.shutdown();
Ok(())
}