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:
parent
450454d164
commit
1e2817537b
17 changed files with 3585 additions and 70 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
552
breadd/src/ipc/module_host_bridge.rs
Normal file
552
breadd/src/ipc/module_host_bridge.rs
Normal 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()
|
||||
})
|
||||
}
|
||||
|
|
@ -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`].
|
||||
|
|
|
|||
|
|
@ -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
732
breadd/src/module_host.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
537
breadd/tests/module_host_sandbox.rs
Normal file
537
breadd/tests/module_host_sandbox.rs
Normal 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(())
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue