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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue