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

@ -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,