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
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue