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
|
|
@ -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`].
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue