Add Workstream G: out-of-process, Landlock-sandboxed module runtime

Closes the gap Workstream D's in-process capability scoping left open:
build_scoped_env only gated presence of bread.* bindings, but os.execute/
io.open/debug.* remained fully reachable since a module's Lua still ran
inside breadd's own process. A module that declares [[permissions]] in
bread.module.toml (including an explicit empty list) is now spawned as a
separate bread-module-host process instead, restricted by a Landlock
ruleset breadd builds from that module's granted permissions and applies
via Command::pre_exec before the child executes any Lua at all. A module
with no manifest at all keeps today's in-process, ungated behavior for
backward compatibility.

- bread-module-host: new minimal binary (mlua + tokio + serde_json) that
  connects to breadd's existing IPC socket, presents a one-time spawn
  token, and proxies bread.* calls as RPC instead of direct bindings.
- breadd/src/module_host.rs: spawn + token registry + apply_sandbox
  (Landlock ruleset construction), with unit tests that spawn a real
  child and verify denial at the OS level, not a Lua-level check.
- breadd/src/ipc/module_host_bridge.rs: the module_host.* RPC bridge
  (on/once/off/emit/after/every/cancel, fs.read/write, exec/exec_capture,
  state.get, log/warn/error, status) plus the hello handshake. Bumped
  API_VERSION to 1.6.0.
- breadd/tests/module_host_sandbox.rs: end-to-end acceptance tests going
  through a real spawned breadd + bread-module-host + IPC handshake —
  os.execute/io.open denied outside a module's granted fs.read scope, and
  kill -9 on a module-host child leaving breadd and other modules intact
  while breadd reports bread.module.crashed.
- bread-shared/src/module_host_ipc.rs: shared wire types (hello result,
  tagged event/timer push envelope) so breadd and bread-module-host can't
  drift on the handshake/push shape.

Deferred (documented in Documentation.md's Workstream G section): the
trust="in-process" opt-out, remaining bread.* namespaces over RPC
(hyprland/widget/machine/bluetooth/notify/state.watch), network
sandboxing, and a fully static build that would remove the Execute grant
Landlock's dynamic-linker requirement forces on system library dirs.
This commit is contained in:
Breadway 2026-08-05 04:02:05 +08:00
parent 450454d164
commit 1e2817537b
17 changed files with 3585 additions and 70 deletions

View file

@ -10,9 +10,11 @@ use serde::{Deserialize, Serialize};
pub mod apps;
pub mod glob;
pub mod module_host_ipc;
pub mod permissions;
pub mod widget;
pub use module_host_ipc::{ModuleHostHello, ModuleHostPush};
pub use permissions::{ModulePermission, PermissionKind};
/// Identifies which adapter produced an event.

View file

@ -0,0 +1,108 @@
//! Wire types shared between `breadd`'s IPC server and the `bread-module-host`
//! client for the out-of-process module bridge (Workstream G).
//!
//! Living here (rather than duplicated as private structs in each crate)
//! means the two processes can't drift on what a `module_host.hello`
//! response or an async event/timer push looks like on the wire — the same
//! failure mode `ModulePermission`/`PermissionKind` already guard against
//! for the manifest schema (see `permissions.rs`).
//!
//! The request side (`{"id", "method", "params"}`) and the plain response
//! side (`{"id", "result"/"error"}`) are *not* duplicated here: they're
//! generic enough (a bare method+params envelope) that both ends already
//! define their own minimal local copy, and sharing a type for something
//! that's just "an id, a string, and a `Value`" buys little. What's shared
//! is the part that's easy to get subtly wrong across two independently
//! maintained crates: the exact shape of the one-time `hello` handshake
//! result and the tagged push-message envelope used for unsolicited
//! event/timer delivery on an otherwise request/response connection.
use serde::{Deserialize, Serialize};
use crate::permissions::ModulePermission;
use crate::BreadEvent;
/// The successful result of a `module_host.hello` call — what `breadd`
/// looked up for the presented one-time token, told back to the
/// `bread-module-host` process that presented it. Deliberately does not
/// trust anything the child process asserts about its own identity (see
/// `breadd/src/module_host.rs`'s token/registry doc comments) — this is
/// `breadd` telling the child who *it* has decided the child is, based on
/// which token was issued for which pending spawn.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModuleHostHello {
pub module: String,
pub permissions: Vec<ModulePermission>,
pub api_version: String,
}
/// An unsolicited message `breadd` pushes down an already-established
/// module-host connection, interleaved with ordinary request/response
/// lines. Distinguished on the wire by the `"push"` tag (internally-tagged
/// enum), which never collides with a plain `{"id", "result"/"error"}`
/// response envelope or an `{"id", "method", "params"}` request envelope —
/// neither of those ever carries a `"push"` key.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "push")]
pub enum ModuleHostPush {
/// A `bread.on`/`bread.once` subscription (registered via
/// `module_host.on`/`module_host.once`) matched an event.
#[serde(rename = "event")]
Event {
subscription_id: String,
event: BreadEvent,
},
/// A `bread.after`/`bread.every` timer (registered via
/// `module_host.after`/`module_host.every`) fired.
#[serde(rename = "timer")]
Timer { timer_id: String },
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{AdapterSource, PermissionKind};
#[test]
fn hello_round_trips() {
let hello = ModuleHostHello {
module: "wallpaper".to_string(),
permissions: vec![ModulePermission {
kind: PermissionKind::FsRead,
path: Some("~/Wallpapers".to_string()),
bin: None,
}],
api_version: "1.6.0".to_string(),
};
let json = serde_json::to_string(&hello).unwrap();
let back: ModuleHostHello = serde_json::from_str(&json).unwrap();
assert_eq!(back.module, "wallpaper");
assert_eq!(back.permissions.len(), 1);
}
#[test]
fn push_event_tag_is_distinguishable_from_a_response_envelope() {
let push = ModuleHostPush::Event {
subscription_id: "sub-1".to_string(),
event: BreadEvent::new("bread.test.tick", AdapterSource::Manual, serde_json::json!({})),
};
let value = serde_json::to_value(&push).unwrap();
assert_eq!(value.get("push").and_then(|v| v.as_str()), Some("event"));
// A plain response envelope never has a "push" key — this is the
// disambiguator bread-module-host's read loop relies on.
assert!(value.get("id").is_none());
}
#[test]
fn push_timer_round_trips() {
let push = ModuleHostPush::Timer {
timer_id: "timer-1".to_string(),
};
let json = serde_json::to_string(&push).unwrap();
let back: ModuleHostPush = serde_json::from_str(&json).unwrap();
match back {
ModuleHostPush::Timer { timer_id } => assert_eq!(timer_id, "timer-1"),
_ => panic!("wrong variant"),
}
}
}