diff --git a/Cargo.lock b/Cargo.lock index b6cfdaa..f81d76a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -317,6 +317,22 @@ dependencies = [ "serde_json", ] +[[package]] +name = "bread-module-host" +version = "0.7.0" +dependencies = [ + "anyhow", + "bread-shared", + "mlua", + "serde", + "serde_json", + "tempfile", + "tokio", + "tracing", + "tracing-subscriber", + "uuid", +] + [[package]] name = "bread-shared" version = "0.7.0" @@ -336,6 +352,7 @@ dependencies = [ "async-trait", "bread-shared", "futures-util", + "landlock", "libc", "mlua", "netlink-packet-core", @@ -350,6 +367,7 @@ dependencies = [ "tracing", "tracing-subscriber", "udev", + "uuid", "zbus", ] @@ -981,6 +999,17 @@ dependencies = [ "libc", ] +[[package]] +name = "landlock" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cca98e95f35b29d469dade6724c6f96cec9236640f745a0e99b0334ec320ab1" +dependencies = [ + "enumflags2", + "libc", + "thiserror 2.0.19", +] + [[package]] name = "lazy_static" version = "1.5.0" diff --git a/Cargo.toml b/Cargo.toml index 5e67e67..ed704e8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "breadd", "bread-cli", "bread-emit", + "bread-module-host", "xtask", ] resolver = "2" @@ -18,3 +19,14 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } dirs = "6.0" chrono = { version = "0.4", features = ["serde"] } tempfile = "3" +# Pure-Rust bindings to the Linux Landlock LSM syscalls (landlock_create_ruleset/ +# landlock_restrict_self) — kernel 5.13+, no external bwrap/firejail binary +# dependency. See Workstream G (bread-module-host / breadd's module_host +# spawner) — the actual OS-level sandboxing mechanism for out-of-process +# module execution. Verified against this repo's dev kernel (6.18) with a +# real pre_exec()-restricted child process before adoption: filesystem reads +# outside the granted rule set are denied at the kernel level +# (RulesetStatus::FullyEnforced / PartiallyEnforced, EACCES on the denied +# path), not merely a Lua-level check. +landlock = "0.4" +uuid = { version = "1", features = ["v4"] } diff --git a/Documentation.md b/Documentation.md index a793e75..bed1e8b 100644 --- a/Documentation.md +++ b/Documentation.md @@ -9,6 +9,7 @@ - [Run, reload, and watch](#run-reload-and-watch) - [Modules: install and manage](#modules-install-and-manage) - [Capability-scoped modules](#capability-scoped-modules-since-v15) +- [Out-of-process module sandboxing](#out-of-process-module-sandboxing-since-v16) - [Debugging tips](#debugging-tips) - [Dictionary: Lua API](#dictionary-lua-api) - [Workflows](#workflows-since-v12) @@ -230,6 +231,13 @@ Anything not granted is genuinely **absent** — `bread.fs == nil`, not defensively (`if bread.fs then ... end`) degrades exactly the way it would if, say, Bluetooth hardware weren't present. +*Since: v1.6* — declaring `[[permissions]]` at all (even an empty list) +also determines **where** the module runs: see [Out-of-process module +sandboxing](#out-of-process-module-sandboxing-since-v16) below. The +`bread` table shape described in this section is what such a module sees +either way; what changed is what backs it and what happens if the module +ignores it entirely and reaches for `os`/`io` directly. + ### Baseline (always available, no manifest entry needed) Event subscription and timers are how a module does anything at all, so @@ -285,25 +293,26 @@ module down for real but is *not* flagged by `bread doctor`, since the author made a conscious choice rather than just not knowing about this feature yet. -### `path`/`bin` are not enforced yet — by design +### `path`/`bin` enforcement depends on where the module runs -The scoping mechanism above only gates *presence* of a `bread.*` binding. -The `path`/`bin` fields on each permission are recorded in the manifest but -not checked against the actual arguments a module passes at runtime — a -module with `fs.read` scoped to `~/Wallpapers` can currently call -`bread.fs.read("/etc/shadow")` and it will attempt the read (and fail or -succeed based on normal OS permissions, same as today). Real per-call -argument enforcement needs a hard security boundary this in-process Lua -mechanism can't provide on its own — `os.execute`/`io.open`/`debug.*` -remain reachable from Lua's standard library regardless of what a module's -`bread` table contains, so a deliberately malicious script can already -route around `bread.exec`/`bread.fs` entirely. Closing that gap for real is -the planned **out-of-process module sandboxing** workstream, which this -manifest schema exists to feed: recording `path`/`bin` now means modules -declared today won't need a second migration once that lands. Until then, -treat this mechanism as making accidental over-reach visible and giving -well-behaved modules a way to advertise (and be held to) a minimal surface -— not as a hard boundary against hostile code. +This section describes the **in-process** scoping mechanism +(`build_scoped_env` in `breadd/src/lua/mod.rs`), which only ever gated +*presence* of a `bread.*` binding — the `path`/`bin` fields on each +permission were recorded in the manifest but never checked against the +actual arguments a module passed at runtime, and `os.execute`/`io.open`/ +`debug.*` remained fully reachable from Lua's standard library regardless +of what a module's `bread` table contained. That's still exactly true for +a module with **no manifest at all** (the legacy/backward-compat path, +`ungated: true` in `modules.list`) — see [Out-of-process module +sandboxing](#out-of-process-module-sandboxing-since-v16) below. + +*Since: v1.6* — a module that declares `[[permissions]]` (any, including +an explicit empty list) no longer runs in-process at all. It's spawned as +a separate, OS-sandboxed `bread-module-host` process instead, and for that +process `path`/`bin` *are* enforced for real, at the kernel level, via a +Landlock ruleset — independent of whether the module even uses the +documented `bread.*` API or goes straight for `os.execute`/`io.open`. See +the linked section for exactly what's covered and what's still deferred. ### `require("bread.devices")` still works from a scoped module @@ -339,6 +348,293 @@ negatives on a plain `bread.exec("...")`-style call site should be rare, but dynamic/computed call sites (`bread[method_name](...)`) won't be detected. +## Out-of-process module sandboxing *(Since: v1.6)* + +### The gap this closes + +Capability-scoped modules (above) gate the *documented* `bread.*` API +surface — a module without `fs.read` sees `bread.fs == nil`. They never +gated Lua's own standard library: `os.execute`, `io.open`, `debug.*` +remained fully reachable from a scoped module's chunk regardless of what +its `bread` table contained, because that chunk still ran as ordinary Lua +code inside `breadd`'s own OS process, sharing its real filesystem/exec +access at the kernel level. A well-behaved module degrades correctly when +a permission is missing; a deliberately adversarial one just calls +`os.execute("cat /etc/shadow")` directly and the in-process mechanism has +nothing left to say about it. + +This workstream closes that gap for any module that declares +`[[permissions]]` in `bread.module.toml` — including an explicit empty +list — by running it in a **separate OS process**, sandboxed at the kernel +level via [Landlock](https://docs.kernel.org/userspace-api/landlock.html), +instead of inside `breadd`'s own process. + +### What still runs in-process + +A module with **no manifest at all** (no `bread.module.toml`, or one with +no `permissions` key) keeps today's pre-v1.6 behavior unchanged: loaded +in-process, full ungated `bread` table, `os`/`io`/`debug` reachable — +surfaced as `"ungated": true` in `modules.list`/`state.get "modules"`, +which is exactly what `bread doctor` reads to warn about it. This is a +deliberate scope decision, not an oversight: Landlock needs concrete rules +to build a ruleset from, and "no manifest at all" carries no information +to build one. A module author who wants real OS-level isolation writes a +manifest — that's the whole point of the capability system this reuses. +Built-in modules (`bread.devices`/`monitors`/`workspaces`/`binds`) are +completely unaffected either way; they never go through manifest-based +scoping. + +### Architecture + +``` +breadd (trusted) bread-module-host (sandboxed, per module) + │ │ + ├─ spawns child, applies a Landlock ──► │ (restriction applied by the + │ ruleset via Command::pre_exec │ PARENT before the child's + │ BEFORE execve() │ own main() ever runs) + │ │ + ├─ hands it a one-time token via │ + │ $BREAD_MODULE_TOKEN (env, not argv) │ + │ │ + │◄── connects to breadd's existing ──────┤ + │ IPC socket, presents the token │ + │ via module_host.hello │ + │ │ + ├─ looks up which module/permissions ──► │ learns its own identity + + │ the token was issued for, replies │ granted permissions from + │ │ breadd's answer (never + │ │ trusted from self-assertion) + │ │ + │◄── module_host.on/off/emit/after/ ─────┤ loads init.lua into a fresh + │ every/cancel/fs_read/fs_write/ │ Lua VM; bread.* functions + │ exec/exec_capture/state_get/status │ are RPC-backed proxies, not + │ (RPC bridge, belt) │ direct bindings + │ │ + │ Landlock ruleset (suspenders, │ os.execute/io.open/debug.* + │ enforced by the kernel independent │ still exist in this Lua VM + │ of whether the RPC bridge is used) ──►│ but are bounded by the + │ kernel regardless +``` + +One `bread-module-host` process per out-of-process module. Its own +dependency footprint is deliberately minimal (`mlua`, `tokio`, +`serde_json`, `bread-shared`) — it's reviewable attack surface in its own +right, running one module's untrusted Lua. + +### The token/identity handshake + +Workstream A deliberately did not build a generic IPC connection-identity +system — it closed a narrower spoofing gap instead — so there was no +`module:` identity concept to reuse. `breadd` generates a random +one-time token (a v4 UUID) when spawning a module-host child and passes it +via the `$BREAD_MODULE_TOKEN` **environment variable**, not argv — argv is +visible to any process on the system via `/proc//cmdline`, env vars +are not without `/proc//environ` and matching privileges. The child's +first message on the IPC socket, `module_host.hello {token}`, presents +that token; `breadd` looks up which module name/permission set the token +was issued for (`ModuleHostRegistry::take_pending`, a one-time, +consume-on-read lookup) and replies with that identity. The child never +asserts its own name and has that trusted — an adversarial process holding +a *stolen or guessed* token still can't claim to be a different module +than the one `breadd` actually spawned that token for, and a token is +consumed on first use so it can't be replayed. + +Other env vars passed to the child: `$BREAD_MODULE_ENTRY` (absolute path +to the module's `init.lua`) and `$BREAD_MODULE_SOCKET` (breadd's socket +path, for test harnesses that override it — production defaults to the +same `bread_shared::resolve_socket_path()` every other client uses). +`$BREAD_MODULE_NAME` is also passed, but purely informational (early log +lines before the hello handshake completes) — never trusted for identity +or permission lookup. + +### The Landlock sandbox + +[Landlock](https://docs.kernel.org/userspace-api/landlock.html) (Linux +5.13+) was chosen over wrapping every spawn in `bubblewrap`/`firejail`: +it's a pure-Rust crate calling the LSM's syscalls directly +(`landlock_create_ruleset`/`landlock_restrict_self`), unprivileged (no +setuid helper, no `CAP_SYS_ADMIN`), and fits this workspace's existing +preference for native Rust crates over shelling out to external tools +(same reasoning as `udev`/`zbus`/`rtnetlink` instead of CLI wrappers). +`bubblewrap`-wrapping remains a documented fallback for a target kernel +that lacks Landlock (pre-5.13, or compiled out) — not implemented, since +Landlock covers this project's actual target. + +The ruleset is built in `breadd` (the parent) and applied via +`Command::pre_exec` — the closure runs in the forked child, after +`fork()` but before `execve()`, so the restriction covers the module-host +binary's own startup, not just the Lua that runs after. Because of that, +`bread-module-host` itself needs **zero** Landlock-related code or +dependency — by the time its `main()` runs, the restriction is already +active and inherited across the `execve()` that started it. + +What the ruleset grants, from `breadd/src/module_host.rs`'s +`apply_sandbox`: + +| Grant | Access | Why | +|-------|--------|-----| +| System library directories (`/usr/lib`, `/lib`, ...) + `/etc/ld.so.cache`/`.preload` | Read + **Execute** | The dynamic linker needs this to start *any* dynamically-linked binary at all — see the note below on why `Execute` is required here, not just `Read`. | +| The `bread-module-host` binary's own resolved path | Read + Execute | The one `execve()` this process is expected to have already performed. | +| The module's own directory (`init.lua`'s parent) | Read | So the bootstrap process can load the module's Lua at all — distinct from any `fs.read` grant, which governs the module's *own* runtime file I/O, not breadd's ability to hand it its own source. | +| `fs.read` with a `path` hint | Read, scoped to that (`~`-expanded) path prefix | Direct mapping from the manifest. | +| `fs.write` with a `path` hint | Read + Write + create, scoped to that path prefix | Matches `bread.fs.write`'s own `create_dir_all` + `write` behavior. | +| `exec` with a `bin` hint | Read + Execute, scoped to that binary's resolved path | Absolute paths used as-is; bare names resolved via a `$PATH` search, `which`-style. | + +**No `fs.read`/`fs.write`/`exec` granted at all means no corresponding +Landlock rule exists, full stop** — the sandboxed process cannot read, +write, or execute anything outside the fixed baseline above, regardless +of what it tries via `os`/`io` directly. + +**A note on `Execute` and shared libraries**: an earlier version of this +mechanism assumed Landlock's `Execute` right only gates `execve()`, and +that plain `Read` would be enough for the dynamic linker's `mmap(..., +PROT_EXEC, ...)` of `.so` files. That assumption was wrong — verified +empirically (not just reasoned about) by spawning a real sandboxed child: +with library directories restricted to `Read`-only, even `/bin/sh -c +"true"` failed to start at all (`EACCES` on `execve` before a single line +of script ran); granting `Execute` on those directories too fixed it. The +practical consequence: a module-host child's direct `os.execute`/`io.open` +escape hatch, if it names a path under a system library directory +specifically, is not denied the way an arbitrary path elsewhere is — the +baseline necessarily grants real `Execute` there. This is a materially +smaller exposure than no sandbox at all (bounded to files already shipped +in the system's own library directories, not the whole filesystem), but +it's a real, known trade-off, not swept under the rug. See +`breadd/src/module_host.rs`'s `apply_sandbox` doc comment for the full +reasoning, including why a fully static (`x86_64-unknown-linux-musl`) +build of `bread-module-host` — confirmed available on this project's dev +machine — would remove the need for this baseline entirely, and why that +wasn't attempted in this pass (a build/packaging change, not a sandbox +logic change). + +**fs.read/fs.write with no `path` hint**: the RPC bridge's own +belt-and-suspenders permission check still applies, but no Landlock rule +is added — Landlock scoping needs a concrete path, and a hint-less grant +carries none. A module author who wants the direct `os`/`io` escape hatch +mediated at the kernel level too needs to declare a `path`. + +**Network access is explicitly out of scope for this pass** (P2). Landlock +gained TCP bind/connect mediation in ABI v4+ (kernel 6.7+), but wiring a +`network` permission kind through the manifest schema and the sandbox +builder wasn't attempted here. + +### RPC bridge coverage + +`bread-module-host`'s `bread` table is built entirely from RPC-backed +proxies to `breadd` (`breadd/src/ipc/module_host_bridge.rs`), not direct +in-process bindings. Covered: + +- **Baseline**, always present: `bread.on`/`.once`/`.off`/`.emit`, + `bread.after`/`.every`/`.cancel`, `bread.json.decode`, `bread.module` + (with a process-local `.store` — see the note below), `bread.log`/ + `.warn`/`.error`. Also `bread.spawn`/`bread.wait` — the same pure-Lua + coroutine sugar `breadd`'s own `install_wait_helper` uses, since it's + built entirely on top of `on`/`once`/`after`/`cancel`, all of which are + bridged; the source is currently duplicated between `breadd` and + `bread-module-host` rather than extracted to `bread-shared` (flagged as + follow-up below). +- **Gated**, mirroring the permission table above: `bread.fs.read`/ + `.write` (`fs.read`/`fs.write`), `bread.exec`/`.exec_capture` (`exec`), + `bread.state.get` (`state.read`). + +Events/timers are delivered as unsolicited, tagged push messages +interleaved with ordinary request/response lines on the same connection +(`bread_shared::module_host_ipc::ModuleHostPush`) — a subscription +registered via `module_host.on`/`.once` is matched server-side against the +same event broadcast every other IPC subscriber reads from. + +**Not yet bridged** (P1/P2 — see below): `bread.state.monitors`/ +`.active_workspace`/`.active_window`/`.devices`/`.power`/`.network`/ +`.profile` shorthands, `bread.state.watch`, `bread.fs.exists`/`.readlink`/ +`.expand`, `bread.profile.activate`, `bread.notify`, `bread.machine.*`, +`bread.hyprland.*`, `bread.widget.*`, `bread.bluetooth.*`, +`bread.wait_any`/`.wait_all`/`bread.workflow.*`. These namespaces are +simply absent (`nil`) from an out-of-process module's `bread` table +regardless of what the manifest grants — a real coverage gap versus the +in-process mechanism, not a permission-check bug. + +**`bread.module().store` is process-local**, not synced back to `breadd`'s +`RuntimeState` — a real, known limitation versus the in-process mechanism +(where `M.store.set`/`.get` persists in daemon state and is visible to +`bread modules info`/other tooling). Fine for a module's own private +scratch state; not fine yet for anything expecting cross-process +visibility. Modules that need to report results/state externally should +use `bread.emit(...)` instead, which does cross the process boundary. + +### Crash isolation + +Each spawned `bread-module-host` child is reaped by a dedicated thread in +`breadd` (`std::process::Child::wait()`, blocking on that thread only — +never blocking the IPC server or the Lua engine). On exit for any reason — +clean shutdown, a Lua panic, `kill -9` — `breadd` emits +`bread.module.crashed` with `{ module, pid, reason, exit_code, signal }` +and updates that module's status. Verified end-to-end +(`breadd/tests/module_host_sandbox.rs`): killing a module-host child with +`SIGKILL` leaves `breadd` itself and every other module (in-process or +out-of-process) fully responsive, and the crash event fires with the +correct module name and `signal: 9`. + +This is deliberately **detection and reporting**, not a restart/backoff +policy — a crashed module-host stays down until the next `bread reload` +(or daemon restart) respawns it. Richer supervision (auto-restart, +backoff, a circuit breaker) is flagged as follow-up work, not attempted +here. + +### New IPC methods + +*Since: v1.6 — `API_VERSION` bumped from `1.5.0` to `1.6.0` in +`breadd/src/ipc/mod.rs` for this addition.* All new methods live under the +`module_host.*` prefix and are only meaningful on a connection that has +completed the `module_host.hello` handshake (see the token/identity +section above) — see [Dictionary: IPC protocol](#dictionary-ipc-protocol) +for the full list alongside the pre-existing methods. + +### What's implemented vs. deferred + +**Landed (P0)**: +- The `bread-module-host` binary, spawn + token-based identity handshake. +- Real Landlock sandboxing built from a module's `ModulePermission` list, + independently verified at the OS level (`breadd/src/module_host.rs`'s + `landlock_denies_reads_outside_granted_path`/ + `no_exec_permission_means_binary_cannot_be_executed_at_all` unit tests + against a real spawned child; `breadd/tests/module_host_sandbox.rs`'s + `os_execute_and_io_open_are_denied_at_the_kernel_level_outside_granted_scope` + end-to-end, going through a real IPC handshake and real Lua calling + `os.execute`/`io.open` directly). +- RPC bridge for the baseline set plus `fs.read`/`fs.write`/`exec`/ + `exec_capture`/`state.read` (`state.get` only). +- Crash isolation: kill-9 of a module-host child doesn't take `breadd` or + any other module down, and is reported via `bread.module.crashed` + (`breadd/tests/module_host_sandbox.rs`'s + `killing_a_module_host_child_does_not_take_down_breadd_or_other_modules`). + +**Landed beyond the minimum (still P0-adjacent)**: +- `bread.spawn`/`bread.wait` (pure-Lua coroutine sugar) work out-of-process + too, since they're built entirely on already-bridged primitives. +- `bread.state.get` (not originally required for the P0 minimum, added + because a pre-existing capability-manifest test exercised it). + +**Deferred (P1 — do next if this workstream continues)**: +- `trust = "in-process"` manifest escape hatch for latency-sensitive + modules that want to opt back into today's D-mechanism deliberately. +- Extracting `bread.spawn`/`bread.wait`'s embedded Lua source (currently + duplicated between `breadd` and `bread-module-host`) into a shared + `bread-shared` module so the two copies can't drift. +- The remaining `bread.*` namespaces over RPC: `bread.state.watch` and the + `.monitors`/`.active_workspace`/etc. shorthands, `bread.fs.exists`/ + `.readlink`/`.expand`, `bread.profile.activate`, `bread.notify`, + `bread.machine.*`, `bread.hyprland.*`, `bread.widget.*`, + `bread.bluetooth.*`, `bread.wait_any`/`.wait_all`/`bread.workflow.*` — + mechanically the same pattern as the ones already bridged. + +**Deferred (P2 — explicitly out of scope for this pass)**: +- Network sandboxing / a `network` permission kind. +- `bread modules info` showing the resolved sandbox profile. +- Full restart/backoff supervision policy for crashed module-hosts. +- A fully static (musl) build of `bread-module-host`, which would remove + the library-directory `Execute` baseline grant entirely. +- 100% RPC coverage of every remaining namespace. + ## Debugging tips - Run `bread events` to see live normalized events. @@ -1108,6 +1404,7 @@ Events are delivered as a `BreadEvent`: | Event | Data | |-------|------| | `bread.system.startup` | `{}` | +| `bread.module.crashed` *(Since: v1.6)* | `{ module, pid, reason, exit_code, signal }` — an out-of-process `bread-module-host` child exited (crash, panic, `kill -9`, ...). `exit_code`/`signal` are mutually exclusive (whichever applies); see [Out-of-process module sandboxing](#out-of-process-module-sandboxing-since-v16). | #### Devices (udev / Bluetooth) @@ -1433,6 +1730,34 @@ Available methods: | `workflows.list` | — | List running/completed workflow instances and their step/status *(Since: v1.2)* | | `widgets.list` | — | List all registered widgets across every module *(Since: v1.3)* | +*Since: v1.6* — `module_host.*`: the RPC bridge an out-of-process +`bread-module-host` child uses in place of direct in-process `bread.*` +bindings (see [Out-of-process module +sandboxing](#out-of-process-module-sandboxing-since-v16)). Meaningful only +on a connection that has completed the handshake below; not intended for +direct use by other clients. + +| Method | Params | Description | +|--------|--------|-------------| +| `module_host.hello` | `token` | One-time handshake. Consumes the token, replies with `{ module, permissions, api_version }` or an error for an unknown/expired token. Takes over the rest of the connection's lifetime as a bidirectional RPC bridge, same as `events.subscribe` does for a plain event stream. | +| `module_host.on` / `.once` | `pattern` | Subscribe; replies `{ subscription_id }`. Matches are pushed asynchronously as `{"push":"event", subscription_id, event}` lines interleaved with ordinary responses. | +| `module_host.off` | `id` | Cancel a subscription. | +| `module_host.after` / `.every` | `delay_ms` / `interval_ms` | Server-managed timer; replies `{ timer_id }`. Fires are pushed as `{"push":"timer", timer_id}`. | +| `module_host.cancel` | `id` | Cancel a timer. | +| `module_host.emit` | `event`, `data` | Same manual-emit semantics (and reserved-domain guard) as the top-level `emit` method. | +| `module_host.log` / `.warn` / `.error` | `message` | Forwarded to `breadd`'s own tracing log, prefixed with the module name. | +| `module_host.fs_read` | `path` | Requires `fs.read` granted; path-prefix-checked against the manifest's `path` hint if one was declared. Replies `{ content }` (`null` if unreadable). | +| `module_host.fs_write` | `path`, `content` | Requires `fs.write`, same scoping check. | +| `module_host.exec` | `cmd` | Requires `exec`; `bin`-hint-checked (by leading command word) if declared. Fire-and-forget, matching `bread.exec`'s own semantics. | +| `module_host.exec_capture` | `cmd`, `timeout_ms` | Requires `exec`. Replies `{ ok, stdout }`. | +| `module_host.state_get` | `key` | Requires `state.read`. Replies `{ value }`. | +| `module_host.status` | `state` (`"loaded"`\|`"load_error"`), `error` | The module-host reports its own load outcome after running `init.lua`; updates `modules.list` status and unblocks `breadd`'s spawn-side wait. | + +Every gated method above checks the module's granted `PermissionKind`s +(learned at hello-time) before attempting the call — belt-and-suspenders +alongside the Landlock sandbox enforced at the OS level on the +module-host process itself, not a replacement for it. + The `health` response's `api_version` field lets a client — the CLI, a Lua module via `bread.exec`, or a `bread-client`-linked sibling app — assert compatibility with this document's versioned schema at connect time (see [API Stability & Versioning](#api-stability--versioning)). *Since: v1.5 — `emit` without `source` closed a spoofing gap: previously any event name was accepted with zero validation and tagged `System`, the same tag the daemon uses internally for events it originates itself in Rust code (`bread.system.startup`, `bread.profile.activated`, ...). That made a manually-injected event indistinguishable from a trusted, daemon-originated one. Now:* diff --git a/bread-module-host/Cargo.toml b/bread-module-host/Cargo.toml new file mode 100644 index 0000000..95592a3 --- /dev/null +++ b/bread-module-host/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "bread-module-host" +version = "0.7.0" +edition = "2021" + +[[bin]] +name = "bread-module-host" +path = "src/main.rs" + +# Deliberately minimal dependency footprint (Workstream G): this binary is +# itself reviewable attack surface running third-party Lua under an +# OS-level sandbox constructed by breadd's parent process (see +# breadd/src/module_host.rs) — it does not depend on landlock itself, since +# the Landlock ruleset is applied by breadd via Command::pre_exec() *before* +# this binary's own main() ever runs (landlock_restrict_self() applies to +# the calling process across the subsequent execve()). +[dependencies] +bread-shared = { path = "../bread-shared" } +serde.workspace = true +serde_json.workspace = true +tokio = { version = "1.40", features = ["net", "io-util", "rt", "rt-multi-thread", "time", "macros", "sync"] } +anyhow.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true +mlua = { version = "0.9", features = ["lua54", "vendored", "serialize"] } +uuid.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/bread-module-host/src/io.rs b/bread-module-host/src/io.rs new file mode 100644 index 0000000..7014a16 --- /dev/null +++ b/bread-module-host/src/io.rs @@ -0,0 +1,266 @@ +//! The async half of `bread-module-host`: owns the Unix socket connection +//! back to `breadd` and speaks the newline-delimited-JSON IPC protocol +//! (`breadd/src/ipc/mod.rs`), extended with `module_host.*` methods (see +//! `breadd/src/module_host.rs` for the server side of this bridge). +//! +//! Runs on its own dedicated OS thread with its own single-threaded Tokio +//! runtime — mirroring `breadd`'s own `spawn_runtime` split between an async +//! IPC/adapters world and a synchronous, single-threaded Lua world (see +//! `breadd/src/lua/mod.rs`'s `spawn_runtime`). The Lua-driving thread in +//! `main.rs` talks to this thread over two plain `std::sync::mpsc` channels +//! (`IoCommand` out, `HostMessage` in) rather than sharing an async runtime, +//! since `mlua::Lua` values are not `Send` and Lua callbacks need to make +//! synchronous (blocking, from Lua's point of view) RPC calls. + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::{mpsc, Arc, Mutex}; +use std::time::Duration; + +use bread_shared::{ModuleHostHello, ModuleHostPush}; +use serde_json::{json, Value}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::UnixStream; +use tracing::warn; + +/// A request the Lua-driving thread wants sent to `breadd`, with a reply +/// channel for the (blocking, from Lua's perspective) response. +pub enum IoCommand { + Request { + method: String, + params: Value, + reply: mpsc::Sender>, + }, +} + +/// Something the IO thread has for the Lua-driving thread: either an +/// unsolicited push (a subscribed event fired, a timer fired) or "the +/// connection is gone" (breadd exited, socket closed, etc). +pub enum HostMessage { + Push(ModuleHostPush), + Closed, +} + +#[derive(serde::Deserialize)] +struct RpcResponse { + #[allow(dead_code)] + id: String, + #[serde(default)] + result: Option, + #[serde(default)] + error: Option, +} + +/// Connect, perform the `module_host.hello` handshake, and — on success — +/// run the steady-state request/response + push-forwarding loop until the +/// connection closes. `hello_tx` is always sent to exactly once, before +/// anything else; the caller blocks on it to learn the module's granted +/// identity (or why the handshake failed) before doing anything else. +pub fn run( + socket_path: PathBuf, + token: String, + cmd_rx: mpsc::Receiver, + host_tx: mpsc::Sender, + hello_tx: mpsc::Sender>, +) { + let rt = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(rt) => rt, + Err(e) => { + let _ = hello_tx.send(Err(format!("failed to start io runtime: {e}"))); + return; + } + }; + + rt.block_on(async move { + let stream = match UnixStream::connect(&socket_path).await { + Ok(s) => s, + Err(e) => { + let _ = hello_tx.send(Err(format!( + "failed to connect to {}: {e}", + socket_path.display() + ))); + return; + } + }; + let (read_half, mut write_half) = stream.into_split(); + let mut lines = BufReader::new(read_half).lines(); + + let hello_req = json!({ + "id": "hello", + "method": "module_host.hello", + "params": { "token": token }, + }); + let Ok(hello_line) = serde_json::to_string(&hello_req) else { + let _ = hello_tx.send(Err("failed to encode hello request".to_string())); + return; + }; + if write_half + .write_all(format!("{hello_line}\n").as_bytes()) + .await + .is_err() + { + let _ = hello_tx.send(Err("failed to write hello request".to_string())); + return; + } + + let response_line = match lines.next_line().await { + Ok(Some(line)) => line, + Ok(None) => { + let _ = hello_tx.send(Err( + "connection closed before hello response".to_string(), + )); + return; + } + Err(e) => { + let _ = hello_tx.send(Err(format!("read error awaiting hello: {e}"))); + return; + } + }; + let resp: RpcResponse = match serde_json::from_str(&response_line) { + Ok(r) => r, + Err(e) => { + let _ = hello_tx.send(Err(format!("malformed hello response: {e}"))); + return; + } + }; + if let Some(err) = resp.error { + let _ = hello_tx.send(Err(err)); + return; + } + let hello: ModuleHostHello = match resp + .result + .and_then(|v| serde_json::from_value(v).ok()) + { + Some(h) => h, + None => { + let _ = hello_tx.send(Err("hello response missing result".to_string())); + return; + } + }; + if hello_tx.send(Ok(hello)).is_err() { + return; + } + + // Steady state. `pending` routes response lines back to whichever + // Lua-side call is blocked waiting for them; a dedicated thread + // bridges the synchronous `cmd_rx` (fed from the Lua thread) onto an + // async channel this task can select on. + let pending: Arc>>>> = + Arc::new(Mutex::new(HashMap::new())); + + let (async_cmd_tx, mut async_cmd_rx) = tokio::sync::mpsc::unbounded_channel::(); + std::thread::spawn(move || { + while let Ok(cmd) = cmd_rx.recv() { + if async_cmd_tx.send(cmd).is_err() { + break; + } + } + }); + + let pending_for_writer = pending.clone(); + let write_task = tokio::spawn(async move { + let mut next_id: u64 = 1; + while let Some(IoCommand::Request { + method, + params, + reply, + }) = async_cmd_rx.recv().await + { + let id = format!("m{next_id}"); + next_id += 1; + let req = json!({ "id": id, "method": method, "params": params }); + let line = match serde_json::to_string(&req) { + Ok(l) => l, + Err(e) => { + let _ = reply.send(Err(e.to_string())); + continue; + } + }; + pending_for_writer.lock().unwrap().insert(id.clone(), reply); + if write_half + .write_all(format!("{line}\n").as_bytes()) + .await + .is_err() + { + if let Some(tx) = pending_for_writer.lock().unwrap().remove(&id) { + let _ = tx.send(Err("write failed; connection lost".to_string())); + } + break; + } + } + }); + + loop { + let line = match lines.next_line().await { + Ok(Some(l)) => l, + Ok(None) => break, + Err(e) => { + warn!(error = %e, "module-host: connection read error"); + break; + } + }; + if line.trim().is_empty() { + continue; + } + let value: Value = match serde_json::from_str(&line) { + Ok(v) => v, + Err(e) => { + warn!(error = %e, "module-host: malformed line from breadd"); + continue; + } + }; + if value.get("push").is_some() { + match serde_json::from_value::(value) { + Ok(push) => { + if host_tx.send(HostMessage::Push(push)).is_err() { + break; + } + } + Err(e) => warn!(error = %e, "module-host: malformed push message"), + } + } else if let Ok(resp) = serde_json::from_value::(value) { + if let Some(tx) = pending.lock().unwrap().remove(&resp.id) { + let result = match resp.error { + Some(e) => Err(e), + None => Ok(resp.result.unwrap_or(Value::Null)), + }; + let _ = tx.send(result); + } + } + } + + write_task.abort(); + // Any calls still blocked waiting for a reply need to be unblocked + // rather than hanging forever now that the connection is gone. + for (_, tx) in pending.lock().unwrap().drain() { + let _ = tx.send(Err("connection closed".to_string())); + } + let _ = host_tx.send(HostMessage::Closed); + }); +} + +/// Blocking helper used from Lua callback closures (which run on the +/// Lua-driving thread, not the async IO thread): send a request and wait — +/// with a timeout, so a wedged connection can't hang a Lua callback forever +/// — for its response. +pub fn call( + cmd_tx: &mpsc::Sender, + method: &str, + params: Value, + timeout: Duration, +) -> Result { + let (reply_tx, reply_rx) = mpsc::channel(); + cmd_tx + .send(IoCommand::Request { + method: method.to_string(), + params, + reply: reply_tx, + }) + .map_err(|_| "io thread gone".to_string())?; + reply_rx + .recv_timeout(timeout) + .map_err(|_| format!("{method} timed out"))? +} diff --git a/bread-module-host/src/lua_env.rs b/bread-module-host/src/lua_env.rs new file mode 100644 index 0000000..3439f61 --- /dev/null +++ b/bread-module-host/src/lua_env.rs @@ -0,0 +1,531 @@ +//! The Lua half of `bread-module-host`: a `bread` table whose functions are +//! RPC-backed proxies to `breadd` instead of directly touching daemon state, +//! plus a dispatch loop that turns `ModuleHostPush` messages (from +//! `crate::io`) into Lua callback invocations. +//! +//! Structurally a slimmed-down sibling of `breadd/src/lua/mod.rs`'s +//! `LuaEngine`/`spawn_runtime`: one dedicated thread runs Lua synchronously +//! and reacts to messages from a channel (`HostMessage` here, `LuaMessage` +//! there); a separate thread/task owns the actual async I/O. Only ONE +//! module is ever loaded per `bread-module-host` process, so there's no +//! module registry, load ordering, or `after` dependency resolution here — +//! `breadd` already resolved all of that before deciding this module needed +//! its own process. +//! +//! `bread.module()`'s `store` is process-local (an in-memory table, not +//! synced back to `breadd`) — a documented gap vs. the in-process +//! implementation's `bread.module().store`, which persists in +//! `RuntimeState` and is visible to `bread modules info`/other modules. +//! Fine for a single module's own private scratch state; not fine yet for +//! anything that expects cross-module visibility. See `Documentation.md`. + +use std::cell::RefCell; +use std::collections::{HashMap, HashSet}; +use std::rc::Rc; +use std::sync::mpsc; +use std::time::Duration; + +use anyhow::{anyhow, Result}; +use bread_shared::{BreadEvent, ModulePermission, PermissionKind}; +use mlua::{Error as LuaError, Function, Lua, LuaSerdeExt, RegistryKey, Table, Value as LuaValue}; +use serde_json::{json, Value as JsonValue}; +use tracing::error; + +use crate::io::{call, IoCommand}; + +/// Timeout for a single RPC round trip to `breadd`. Generous relative to a +/// same-host Unix socket hop — this exists to fail loudly if the connection +/// wedges rather than to accommodate genuinely slow calls. +const RPC_TIMEOUT: Duration = Duration::from_secs(10); + +/// Pure-Lua `bread.spawn`/`bread.wait` sugar, copied verbatim from +/// `breadd/src/lua/mod.rs`'s `install_wait_helper`. It only depends on +/// `coroutine` plus `bread.once`/`bread.on`/`bread.after`/`bread.cancel`, +/// all of which this module provides as RPC-backed bindings above, so the +/// suspension mechanism works unmodified against a remote event source. +/// +/// Deliberately duplicated rather than shared: extracting this into +/// `bread-shared` (so `breadd` and `bread-module-host` load the same +/// constant instead of two hand-kept-in-sync copies) is flagged as +/// follow-up work in `Documentation.md` — doing it here would also require +/// making `breadd`'s currently-private `const BUILTIN_*`/wait-helper +/// strings public, which is a larger refactor than this workstream's time +/// budget covers. +const WAIT_HELPER: &str = r#" +bread.spawn = function(fn) + local co = coroutine.create(fn) + local ok, err = coroutine.resume(co) + if not ok then + error(err) + end +end + +bread.wait = function(pattern, opts) + if type(pattern) ~= "string" then + error("bread.wait requires a pattern string") + end + opts = opts or {} + local co = coroutine.running() + if not co then + error("bread.wait must be called inside a coroutine") + end + local id + local timer + id = bread.once(pattern, function(event) + if timer then + bread.cancel(timer) + end + coroutine.resume(co, event) + end) + if opts.timeout then + timer = bread.after(opts.timeout, function() + bread.off(id) + coroutine.resume(co, nil) + end) + end + return coroutine.yield() +end +"#; + +fn json_to_lua<'lua>(lua: &'lua Lua, value: &JsonValue) -> mlua::Result> { + Ok(match value { + JsonValue::Null => LuaValue::Nil, + JsonValue::Bool(b) => LuaValue::Boolean(*b), + JsonValue::Number(n) => { + if let Some(i) = n.as_i64() { + LuaValue::Integer(i as i64) + } else { + LuaValue::Number(n.as_f64().unwrap_or(0.0)) + } + } + JsonValue::String(s) => LuaValue::String(lua.create_string(s)?), + JsonValue::Array(arr) => { + let tbl = lua.create_table()?; + for (i, v) in arr.iter().enumerate() { + tbl.set(i + 1, json_to_lua(lua, v)?)?; + } + LuaValue::Table(tbl) + } + JsonValue::Object(obj) => { + let tbl = lua.create_table()?; + for (k, v) in obj.iter() { + tbl.set(k.clone(), json_to_lua(lua, v)?)?; + } + LuaValue::Table(tbl) + } + }) +} + +/// The Lua VM plus the bookkeeping needed to route `ModuleHostPush` +/// messages to the right registered callback. Lives entirely on one thread +/// (`mlua::Lua` is `!Send`) — see `main.rs`. +pub struct ModuleHostLua { + lua: Lua, + /// subscription_id or timer_id -> the Lua callback registered for it. + handlers: Rc>>, + registered: Rc>, + module_table_key: Rc>>, + module_name: String, +} + +impl ModuleHostLua { + pub fn new( + cmd_tx: mpsc::Sender, + module_name: String, + permissions: Vec, + ) -> Result { + let lua = Lua::new(); + let bread = lua.create_table()?; + let handlers: Rc>> = Rc::new(RefCell::new(HashMap::new())); + let registered = Rc::new(RefCell::new(false)); + let module_table_key: Rc>> = Rc::new(RefCell::new(None)); + + Self::install_module_fn( + &lua, + &bread, + module_name.clone(), + registered.clone(), + module_table_key.clone(), + )?; + Self::install_logging(&lua, &bread, cmd_tx.clone())?; + Self::install_json(&lua, &bread)?; + Self::install_events(&lua, &bread, cmd_tx.clone(), handlers.clone())?; + Self::install_timers(&lua, &bread, cmd_tx.clone(), handlers.clone())?; + Self::install_emit(&lua, &bread, cmd_tx.clone())?; + + let granted: HashSet = permissions.iter().map(|p| p.kind).collect(); + Self::install_fs(&lua, &bread, cmd_tx.clone(), &granted)?; + Self::install_exec(&lua, &bread, cmd_tx.clone(), &granted)?; + Self::install_state(&lua, &bread, cmd_tx, &granted)?; + + lua.globals().set("bread", bread)?; + lua.load(WAIT_HELPER).set_name("").exec()?; + + Ok(Self { + lua, + handlers, + registered, + module_table_key, + module_name, + }) + } + + fn install_module_fn( + lua: &Lua, + bread: &Table, + expected_name: String, + registered: Rc>, + module_table_key: Rc>>, + ) -> Result<()> { + let store: Rc>> = Rc::new(RefCell::new(HashMap::new())); + let module_fn = lua.create_function(move |lua, table: Table| -> mlua::Result { + let name: String = table.get("name")?; + if name != expected_name { + return Err(LuaError::RuntimeError(format!( + "bread.module({{name = \"{name}\"}}) does not match the module breadd spawned this process for (\"{expected_name}\")" + ))); + } + let version: Option = table.get("version").ok(); + + let module_tbl = lua.create_table()?; + module_tbl.set("name", name.clone())?; + if let Some(v) = version { + module_tbl.set("version", v)?; + } + + let store_tbl = lua.create_table()?; + let store_get = store.clone(); + let get_fn = lua.create_function(move |lua, key: String| { + match store_get.borrow().get(&key) { + Some(v) => json_to_lua(lua, v), + None => Ok(LuaValue::Nil), + } + })?; + store_tbl.set("get", get_fn)?; + + let store_set = store.clone(); + let set_fn = lua.create_function(move |lua, (key, value): (String, LuaValue)| { + let json: JsonValue = lua.from_value(value).unwrap_or(JsonValue::Null); + store_set.borrow_mut().insert(key, json); + Ok(()) + })?; + store_tbl.set("set", set_fn)?; + module_tbl.set("store", store_tbl)?; + + *registered.borrow_mut() = true; + let key = lua.create_registry_value(module_tbl.clone())?; + *module_table_key.borrow_mut() = Some(key); + + Ok(module_tbl) + })?; + bread.set("module", module_fn)?; + Ok(()) + } + + fn install_logging(lua: &Lua, bread: &Table, cmd_tx: mpsc::Sender) -> Result<()> { + for (name, method) in [ + ("log", "module_host.log"), + ("warn", "module_host.warn"), + ("error", "module_host.error"), + ] { + let cmd_tx = cmd_tx.clone(); + let f = lua.create_function(move |_, message: String| { + let _ = call(&cmd_tx, method, json!({ "message": message }), RPC_TIMEOUT); + Ok(()) + })?; + bread.set(name, f)?; + } + Ok(()) + } + + fn install_json(lua: &Lua, bread: &Table) -> Result<()> { + let json_tbl = lua.create_table()?; + let decode_fn = lua.create_function(|lua, s: String| { + match serde_json::from_str::(&s) { + Ok(v) => Ok((json_to_lua(lua, &v)?, LuaValue::Nil)), + Err(e) => Ok((LuaValue::Nil, LuaValue::String(lua.create_string(&e.to_string())?))), + } + })?; + json_tbl.set("decode", decode_fn)?; + bread.set("json", json_tbl)?; + Ok(()) + } + + fn install_events( + lua: &Lua, + bread: &Table, + cmd_tx: mpsc::Sender, + handlers: Rc>>, + ) -> Result<()> { + for (name, once) in [("on", false), ("once", true)] { + let cmd_tx = cmd_tx.clone(); + let handlers = handlers.clone(); + let method = if once { "module_host.once" } else { "module_host.on" }; + let f = lua.create_function(move |lua, (pattern, callback): (String, Function)| { + let result = call(&cmd_tx, method, json!({ "pattern": pattern }), RPC_TIMEOUT) + .map_err(LuaError::external)?; + let id = result + .get("subscription_id") + .and_then(|v| v.as_str()) + .ok_or_else(|| LuaError::external("module_host.on: missing subscription_id"))? + .to_string(); + let key = lua.create_registry_value(callback)?; + handlers.borrow_mut().insert(id.clone(), key); + Ok(id) + })?; + bread.set(name, f)?; + } + + let cmd_tx_off = cmd_tx.clone(); + let handlers_off = handlers.clone(); + let off_fn = lua.create_function(move |_, id: String| { + let _ = call(&cmd_tx_off, "module_host.off", json!({ "id": id }), RPC_TIMEOUT); + handlers_off.borrow_mut().remove(&id); + Ok(()) + })?; + bread.set("off", off_fn)?; + Ok(()) + } + + fn install_timers( + lua: &Lua, + bread: &Table, + cmd_tx: mpsc::Sender, + handlers: Rc>>, + ) -> Result<()> { + for (name, method, param_key) in [ + ("after", "module_host.after", "delay_ms"), + ("every", "module_host.every", "interval_ms"), + ] { + let cmd_tx = cmd_tx.clone(); + let handlers = handlers.clone(); + let f = lua.create_function(move |lua, (delay_ms, callback): (u64, Function)| { + let result = call(&cmd_tx, method, json!({ param_key: delay_ms }), RPC_TIMEOUT) + .map_err(LuaError::external)?; + let id = result + .get("timer_id") + .and_then(|v| v.as_str()) + .ok_or_else(|| LuaError::external(format!("{method}: missing timer_id")))? + .to_string(); + let key = lua.create_registry_value(callback)?; + handlers.borrow_mut().insert(id.clone(), key); + Ok(id) + })?; + bread.set(name, f)?; + } + + let cmd_tx_cancel = cmd_tx.clone(); + let handlers_cancel = handlers.clone(); + let cancel_fn = lua.create_function(move |_, id: String| { + let _ = call(&cmd_tx_cancel, "module_host.cancel", json!({ "id": id }), RPC_TIMEOUT); + handlers_cancel.borrow_mut().remove(&id); + Ok(()) + })?; + bread.set("cancel", cancel_fn)?; + Ok(()) + } + + fn install_emit(lua: &Lua, bread: &Table, cmd_tx: mpsc::Sender) -> Result<()> { + let emit_fn = lua.create_function(move |lua, (event, data): (String, Option)| { + let data_json: JsonValue = match data { + Some(v) => lua.from_value(v).unwrap_or(JsonValue::Null), + None => json!({}), + }; + call( + &cmd_tx, + "module_host.emit", + json!({ "event": event, "data": data_json }), + RPC_TIMEOUT, + ) + .map(|_| ()) + .map_err(LuaError::external) + })?; + bread.set("emit", emit_fn)?; + Ok(()) + } + + /// `bread.state.get(path)`, gated on `state.read`. Only the `get` + /// shorthand is bridged here — `.monitors()`/`.active_workspace()`/etc. + /// convenience wrappers and `state.watch` (a standing subscription, a + /// materially different capability — see `PermissionKind::StateWatch`'s + /// doc comment in `bread-shared`) are deferred; see `Documentation.md`'s + /// Workstream G section for the full list of what's bridged vs. not. + fn install_state( + lua: &Lua, + bread: &Table, + cmd_tx: mpsc::Sender, + granted: &HashSet, + ) -> Result<()> { + if !granted.contains(&PermissionKind::StateRead) { + return Ok(()); + } + let state_tbl = lua.create_table()?; + let get_fn = lua.create_function(move |lua, key: String| { + let result = call(&cmd_tx, "module_host.state_get", json!({ "key": key }), RPC_TIMEOUT) + .map_err(LuaError::external)?; + match result.get("value") { + Some(v) => json_to_lua(lua, v), + None => Ok(LuaValue::Nil), + } + })?; + state_tbl.set("get", get_fn)?; + bread.set("state", state_tbl)?; + Ok(()) + } + + fn install_fs( + lua: &Lua, + bread: &Table, + cmd_tx: mpsc::Sender, + granted: &HashSet, + ) -> Result<()> { + if !granted.contains(&PermissionKind::FsRead) && !granted.contains(&PermissionKind::FsWrite) { + return Ok(()); + } + let fs_tbl = lua.create_table()?; + if granted.contains(&PermissionKind::FsRead) { + let cmd_tx = cmd_tx.clone(); + let read_fn = lua.create_function(move |_, path: String| { + let result = call(&cmd_tx, "module_host.fs_read", json!({ "path": path }), RPC_TIMEOUT) + .map_err(LuaError::external)?; + Ok(result + .get("content") + .and_then(|v| v.as_str()) + .map(|s| s.to_string())) + })?; + fs_tbl.set("read", read_fn)?; + } + if granted.contains(&PermissionKind::FsWrite) { + let cmd_tx = cmd_tx.clone(); + let write_fn = lua.create_function(move |_, (path, content): (String, String)| { + call( + &cmd_tx, + "module_host.fs_write", + json!({ "path": path, "content": content }), + RPC_TIMEOUT, + ) + .map(|_| ()) + .map_err(LuaError::external) + })?; + fs_tbl.set("write", write_fn)?; + } + bread.set("fs", fs_tbl)?; + Ok(()) + } + + fn install_exec( + lua: &Lua, + bread: &Table, + cmd_tx: mpsc::Sender, + granted: &HashSet, + ) -> Result<()> { + if !granted.contains(&PermissionKind::Exec) { + return Ok(()); + } + let cmd_tx_exec = cmd_tx.clone(); + let exec_fn = lua.create_function(move |_, cmd: String| { + call(&cmd_tx_exec, "module_host.exec", json!({ "cmd": cmd }), RPC_TIMEOUT) + .map(|_| ()) + .map_err(LuaError::external) + })?; + bread.set("exec", exec_fn)?; + + let exec_capture_fn = lua.create_function(move |_, (cmd, opts): (String, Option
)| { + let timeout_ms: u64 = opts + .as_ref() + .and_then(|o| o.get("timeout_ms").ok()) + .unwrap_or(2000); + let call_timeout = RPC_TIMEOUT + Duration::from_millis(timeout_ms); + let result = call( + &cmd_tx, + "module_host.exec_capture", + json!({ "cmd": cmd, "timeout_ms": timeout_ms }), + call_timeout, + ) + .map_err(LuaError::external)?; + let ok = result.get("ok").and_then(|v| v.as_bool()).unwrap_or(false); + let stdout = result + .get("stdout") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + Ok((ok, stdout)) + })?; + bread.set("exec_capture", exec_capture_fn)?; + Ok(()) + } + + /// Load and execute the module's `init.lua`, then verify it actually + /// called `bread.module(...)` — mirrors `breadd`'s own + /// `load_module`/`load_scoped_lua_file` contract exactly (see + /// `breadd/src/lua/mod.rs`). + pub fn load_entry(&self, entry_path: &std::path::Path) -> Result<()> { + let src = std::fs::read_to_string(entry_path) + .map_err(|e| anyhow!("failed to read {}: {e}", entry_path.display()))?; + self.lua + .load(&src) + .set_name(entry_path.to_string_lossy().as_ref()) + .exec() + .map_err(|e| anyhow!(e.to_string()))?; + + if !*self.registered.borrow() { + return Err(anyhow!("module did not call bread.module(...)")); + } + self.run_on_load() + } + + fn run_on_load(&self) -> Result<()> { + let key_ref = self.module_table_key.borrow(); + let Some(key) = key_ref.as_ref() else { + return Ok(()); + }; + let module_tbl: Table = self + .lua + .registry_value(key) + .map_err(|e| anyhow!(e.to_string()))?; + let hook: Option = module_tbl.get("on_load").ok(); + drop(key_ref); + if let Some(hook) = hook { + hook.call::<_, ()>(()) + .map_err(|e| anyhow!("{} on_load failed: {e}", self.module_name))?; + } + Ok(()) + } + + pub fn dispatch_event(&self, subscription_id: &str, event: &BreadEvent) { + let func = self.lookup(subscription_id); + if let Some(func) = func { + if let Err(e) = self.call_event_handler(&func, event) { + error!(subscription_id, error = %e, "module-host: event handler error"); + } + } + } + + pub fn dispatch_timer(&self, timer_id: &str) { + let func = self.lookup(timer_id); + if let Some(func) = func { + if let Err(e) = func.call::<_, ()>(()) { + error!(timer_id, error = %e, "module-host: timer handler error"); + } + } + } + + fn lookup(&self, id: &str) -> Option> { + let handlers = self.handlers.borrow(); + let key = handlers.get(id)?; + self.lua.registry_value::(key).ok() + } + + fn call_event_handler(&self, func: &Function, event: &BreadEvent) -> mlua::Result<()> { + let data = json_to_lua(&self.lua, &event.data)?; + let evt_tbl = self.lua.create_table()?; + evt_tbl.set("event", event.event.clone())?; + evt_tbl.set("data", data)?; + evt_tbl.set("timestamp", event.timestamp)?; + evt_tbl.set("id", event.id.clone())?; + if let Some(caused_by) = &event.caused_by { + evt_tbl.set("caused_by", caused_by.clone())?; + } + func.call::<_, ()>(evt_tbl) + } +} diff --git a/bread-module-host/src/main.rs b/bread-module-host/src/main.rs new file mode 100644 index 0000000..cfc1a03 --- /dev/null +++ b/bread-module-host/src/main.rs @@ -0,0 +1,164 @@ +//! `bread-module-host` — the out-of-process runtime for a single third-party +//! Bread module (Workstream G). +//! +//! `breadd` spawns one of these per out-of-process module (see +//! `breadd/src/module_host.rs`), sandboxed at the OS level via a Landlock +//! ruleset applied by the parent *before* this binary's own `main()` ever +//! runs (through `Command::pre_exec` — see that module's doc comment for +//! why this binary itself has no Landlock dependency at all). This process +//! then: +//! +//! 1. Connects to `breadd`'s existing IPC socket +//! (`$XDG_RUNTIME_DIR/bread/breadd.sock` by default). +//! 2. Presents the one-time token `breadd` gave it (via `$BREAD_MODULE_TOKEN`, +//! an env var rather than argv, which is visible to any process via +//! `/proc/*/cmdline`) via `module_host.hello` and learns its own identity +//! (module name + granted permissions) from `breadd`'s answer — it never +//! asserts its own name and have that trusted. +//! 3. Loads exactly one module's `init.lua` (`$BREAD_MODULE_ENTRY`) into a +//! fresh Lua VM whose `bread` table is built entirely from RPC-backed +//! proxies (see `lua_env`) instead of direct in-process bindings. +//! 4. Reports load success/failure back to `breadd` (`module_host.status`), +//! then dispatches subscribed events/timers pushed down the same +//! connection until it closes. +//! +//! Env vars, all required except `BREAD_MODULE_SOCKET` and +//! `BREAD_MODULE_NAME`: +//! - `BREAD_MODULE_TOKEN` — one-time handshake token. +//! - `BREAD_MODULE_ENTRY` — absolute path to the module's entry `.lua` file. +//! - `BREAD_MODULE_SOCKET` — override for breadd's socket path (defaults to +//! `bread_shared::resolve_socket_path()`, the same resolution breadd's own +//! `Config::socket_path` uses). +//! - `BREAD_MODULE_NAME` — informational only (early log lines before the +//! hello response arrives); never trusted for permission lookup. + +mod io; +mod lua_env; + +use std::path::PathBuf; +use std::sync::mpsc; +use std::time::Duration; + +use bread_shared::{ModuleHostHello, ModuleHostPush}; +use tracing::{error, info, warn}; + +use io::{HostMessage, IoCommand}; +use lua_env::ModuleHostLua; + +fn main() { + tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .init(); + + let module_name_hint = std::env::var("BREAD_MODULE_NAME").unwrap_or_else(|_| "?".to_string()); + + let token = match std::env::var("BREAD_MODULE_TOKEN") { + Ok(t) => t, + Err(_) => { + eprintln!("bread-module-host: missing BREAD_MODULE_TOKEN env var"); + std::process::exit(1); + } + }; + let entry = match std::env::var("BREAD_MODULE_ENTRY") { + Ok(e) => PathBuf::from(e), + Err(_) => { + eprintln!("bread-module-host: missing BREAD_MODULE_ENTRY env var"); + std::process::exit(1); + } + }; + let socket_path = match std::env::var("BREAD_MODULE_SOCKET") { + Ok(s) => PathBuf::from(s), + Err(_) => bread_shared::resolve_socket_path(), + }; + + info!( + module_hint = %module_name_hint, + entry = %entry.display(), + socket = %socket_path.display(), + "bread-module-host starting" + ); + + let (cmd_tx, cmd_rx) = mpsc::channel::(); + let (host_tx, host_rx) = mpsc::channel::(); + let (hello_tx, hello_rx) = mpsc::channel::>(); + + if std::thread::Builder::new() + .name("module-host-io".to_string()) + .spawn(move || io::run(socket_path, token, cmd_rx, host_tx, hello_tx)) + .is_err() + { + eprintln!("bread-module-host: failed to spawn io thread"); + std::process::exit(1); + } + + let hello = match hello_rx.recv_timeout(Duration::from_secs(15)) { + Ok(Ok(h)) => h, + Ok(Err(e)) => { + error!(error = %e, "bread-module-host: hello handshake failed"); + std::process::exit(1); + } + Err(_) => { + error!("bread-module-host: timed out waiting for hello handshake"); + std::process::exit(1); + } + }; + + info!( + module = %hello.module, + permissions = ?hello.permissions, + api_version = %hello.api_version, + "bread-module-host: identity established by breadd" + ); + + let engine = match ModuleHostLua::new(cmd_tx.clone(), hello.module.clone(), hello.permissions.clone()) { + Ok(e) => e, + Err(e) => { + error!(error = %e, "bread-module-host: failed to build lua environment"); + report_status(&cmd_tx, false, Some(e.to_string())); + std::process::exit(1); + } + }; + + match engine.load_entry(&entry) { + Ok(()) => { + info!(module = %hello.module, "bread-module-host: module loaded successfully"); + report_status(&cmd_tx, true, None); + } + Err(e) => { + error!(module = %hello.module, error = %e, "bread-module-host: module load failed"); + report_status(&cmd_tx, false, Some(e.to_string())); + std::process::exit(1); + } + } + + // Steady state: dispatch pushed events/timers until the connection to + // breadd drops (breadd exited, socket closed, or we were killed and + // this line never runs at all — see breadd/src/module_host.rs's + // child-reap thread for the other half of that crash-isolation story). + loop { + match host_rx.recv() { + Ok(HostMessage::Push(ModuleHostPush::Event { + subscription_id, + event, + })) => { + engine.dispatch_event(&subscription_id, &event); + } + Ok(HostMessage::Push(ModuleHostPush::Timer { timer_id })) => { + engine.dispatch_timer(&timer_id); + } + Ok(HostMessage::Closed) | Err(_) => { + warn!("bread-module-host: connection to breadd closed, exiting"); + break; + } + } + } +} + +fn report_status(cmd_tx: &mpsc::Sender, ok: bool, error: Option) { + let params = if ok { + serde_json::json!({ "state": "loaded" }) + } else { + serde_json::json!({ "state": "load_error", "error": error }) + }; + let _ = io::call(cmd_tx, "module_host.status", params, Duration::from_secs(5)); +} diff --git a/bread-shared/src/lib.rs b/bread-shared/src/lib.rs index dadce1b..411e8e4 100644 --- a/bread-shared/src/lib.rs +++ b/bread-shared/src/lib.rs @@ -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. diff --git a/bread-shared/src/module_host_ipc.rs b/bread-shared/src/module_host_ipc.rs new file mode 100644 index 0000000..653cb7f --- /dev/null +++ b/bread-shared/src/module_host_ipc.rs @@ -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, + 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"), + } + } +} diff --git a/breadd/Cargo.toml b/breadd/Cargo.toml index 599e0dd..281def0 100644 --- a/breadd/Cargo.toml +++ b/breadd/Cargo.toml @@ -22,6 +22,8 @@ netlink-packet-route = "0.11" netlink-packet-core = "0.4" libc = "0.2" notify = "6.1" +landlock.workspace = true +uuid.workspace = true [dev-dependencies] tempfile.workspace = true diff --git a/breadd/src/ipc/mod.rs b/breadd/src/ipc/mod.rs index a4769c2..73a19ae 100644 --- a/breadd/src/ipc/mod.rs +++ b/breadd/src/ipc/mod.rs @@ -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>>, 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>>, subscription_count: Arc, event_buffer: Arc>>, + 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 { + 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, diff --git a/breadd/src/ipc/module_host_bridge.rs b/breadd/src/ipc/module_host_bridge.rs new file mode 100644 index 0000000..164aeb5 --- /dev/null +++ b/breadd/src/ipc/module_host_bridge.rs @@ -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>, + 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::(); + 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> = HashMap::new(); + let mut timers: HashMap> = 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, + subs: &mut HashMap>, + timers: &mut HashMap>, + outcome_tx: &mut Option>, + ) -> std::result::Result { + 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() + }) +} diff --git a/breadd/src/lua/mod.rs b/breadd/src/lua/mod.rs index 9a698f7..e4bbdf5 100644 --- a/breadd/src/lua/mod.rs +++ b/breadd/src/lua/mod.rs @@ -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, + module_host_registry: ModuleHostRegistry, ) -> Result { 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>>, + /// 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, lua_tx: mpsc::UnboundedSender, recent_errors: Arc>>, + module_host_registry: ModuleHostRegistry, ) -> Result { + 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`]. diff --git a/breadd/src/main.rs b/breadd/src/main.rs index 14b4c4b..b856630 100644 --- a/breadd/src/main.rs +++ b/breadd/src/main.rs @@ -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(()) } diff --git a/breadd/src/module_host.rs b/breadd/src/module_host.rs new file mode 100644 index 0000000..539ef46 --- /dev/null +++ b/breadd/src/module_host.rs @@ -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:` 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//cmdline`, env vars are not +//! without `/proc//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, + pub outcome_tx: std::sync::mpsc::Sender, +} + +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>, +} + +#[derive(Default)] +struct Inner { + pending: HashMap, + active: HashMap, +} + +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 { + 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 = { + 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, +) -> Result { + 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, +) -> (String, Option, Option) { + 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 { + 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 = 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"); + } +} diff --git a/breadd/tests/ipc_integration.rs b/breadd/tests/ipc_integration.rs index 7b884ca..b324639 100644 --- a/breadd/tests/ipc_integration.rs +++ b/breadd/tests/ipc_integration.rs @@ -438,6 +438,21 @@ async fn modules_list_returns_array() -> Result<()> { /// `bread.state.get(...)`, but `bread.fs` and `bread.exec` must be /// genuinely *absent* from the `bread` table it sees — `nil`, not merely /// permission-denied when called. +/// +/// *Since Workstream G*: a module that declares `[[permissions]]` (any, +/// including an explicit empty list — see +/// `explicit_empty_permissions_is_scoped_but_not_flagged_ungated` below) +/// now runs out-of-process in a real `bread-module-host` child instead of +/// in-process with a scoped Lua `_ENV` (see `breadd/src/lua/mod.rs`'s +/// `load_module`) — the presence/absence check this test exists for still +/// holds, just enforced by what `bread-module-host`'s own `ModuleHostLua` +/// constructs the `bread` table from (see `bread-module-host/src/ +/// lua_env.rs`) instead of `build_scoped_env`. `M.store.set(...)` no +/// longer works as the result-reporting channel here, since an +/// out-of-process module's `bread.module().store` is process-local (not +/// synced back to `breadd`'s `RuntimeState` — a documented gap, see +/// `Documentation.md`'s Workstream G section) — `bread.emit(...)` is used +/// instead, which *does* cross the process boundary via the RPC bridge. #[tokio::test] async fn scoped_module_sees_only_granted_state_read_permission() -> Result<()> { let manifest = r#" @@ -455,23 +470,26 @@ path = "monitors" let module_lua = r#" local M = bread.module({ name = "scoped-test", version = "1.0.0" }) -function M.on_load() +bread.on("test.trigger", function() local ok = pcall(bread.state.get, "monitors") - M.store.set("state_get_ok", ok) - M.store.set("fs_present", bread.fs ~= nil) - M.store.set("exec_present", bread.exec ~= nil) - M.store.set("exec_capture_present", bread.exec_capture ~= nil) - M.store.set("bluetooth_present", bread.bluetooth ~= nil) - -- Baseline must still work from inside a scoped module. - M.store.set("json_present", bread.json ~= nil) - M.store.set("log_present", bread.log ~= nil) -end + bread.emit("test.scoped_result", { + state_get_ok = ok, + fs_present = bread.fs ~= nil, + exec_present = bread.exec ~= nil, + exec_capture_present = bread.exec_capture ~= nil, + bluetooth_present = bread.bluetooth ~= nil, + -- Baseline must still work from inside a scoped module. + json_present = bread.json ~= nil, + log_present = bread.log ~= nil, + }) +end) return M "#; let harness = TestHarness::spawn_with_module("scoped-test", Some(manifest), module_lua)?; harness.wait_until_ready().await?; + harness.wait_for_module_loaded("scoped-test").await?; let modules = harness .send_request("state.get", json!({"key": "modules"})) @@ -487,10 +505,13 @@ return M Some("loaded"), "module failed to load: {entry}" ); + assert_eq!( + entry.get("ungated"), + Some(&json!(false)), + "a module with a manifest that declares permissions must not be flagged ungated" + ); - let store = entry - .get("store") - .ok_or_else(|| anyhow!("no store on module status: {entry}"))?; + let store = harness.trigger_and_await_result("test.scoped_result").await?; assert_eq!(store.get("state_get_ok"), Some(&json!(true))); assert_eq!( store.get("fs_present"), @@ -507,12 +528,6 @@ return M assert_eq!(store.get("json_present"), Some(&json!(true)), "baseline bread.json must still be present"); assert_eq!(store.get("log_present"), Some(&json!(true)), "baseline bread.log must still be present"); - assert_eq!( - entry.get("ungated"), - Some(&json!(false)), - "a module with a manifest that declares permissions must not be flagged ungated" - ); - harness.shutdown(); Ok(()) } @@ -538,6 +553,15 @@ return M let harness = TestHarness::spawn_with_module("legacy-test", None, module_lua)?; harness.wait_until_ready().await?; + // `wait_until_ready` only proves the IPC socket is accepting + // connections — module loading runs concurrently on the Lua engine's + // own thread (see `lua::spawn_runtime`), so without this the check + // below races module load completion even for an in-process module. + // Usually fast enough not to matter, but flaky under this suite's + // heavier concurrent process load (see Workstream G's + // `module_host_sandbox.rs` tests, which run real sandboxed child + // processes alongside this one). + harness.wait_for_module_loaded("legacy-test").await?; let modules = harness .send_request("state.get", json!({"key": "modules"})) @@ -579,6 +603,12 @@ return M /// "baseline only" declaration, distinct from no manifest at all: it must /// scope the module down for real (no fs/exec/etc.) but must *not* trip the /// `ungated` doctor warning, since the author made a conscious choice. +/// +/// *Since Workstream G*: `Some(vec![])` also opts this module into the +/// out-of-process sandboxed path (same as any other declared +/// `[[permissions]]`), and — as in the test above — results come back via +/// `bread.emit` on a `test.trigger` handler rather than `M.store`. See that +/// test's doc comment for the full explanation. #[tokio::test] async fn explicit_empty_permissions_is_scoped_but_not_flagged_ungated() -> Result<()> { let manifest = r#" @@ -593,16 +623,19 @@ permissions = [] let module_lua = r#" local M = bread.module({ name = "empty-perms-test", version = "1.0.0" }) -function M.on_load() - M.store.set("fs_present", bread.fs ~= nil) - M.store.set("state_present", bread.state ~= nil) -end +bread.on("test.trigger", function() + bread.emit("test.empty_perms_result", { + fs_present = bread.fs ~= nil, + state_present = bread.state ~= nil, + }) +end) return M "#; let harness = TestHarness::spawn_with_module("empty-perms-test", Some(manifest), module_lua)?; harness.wait_until_ready().await?; + harness.wait_for_module_loaded("empty-perms-test").await?; let modules = harness .send_request("state.get", json!({"key": "modules"})) @@ -617,15 +650,16 @@ return M .ok_or_else(|| anyhow!("empty-perms-test module not found in modules state; dump: {modules}"))?; assert_eq!(entry.get("status").and_then(Value::as_str), Some("loaded")); - let store = entry.get("store").unwrap(); - assert_eq!(store.get("fs_present"), Some(&json!(false))); - assert_eq!(store.get("state_present"), Some(&json!(false))); assert_eq!( entry.get("ungated"), Some(&json!(false)), "an explicit empty permissions list is a deliberate declaration, not 'undeclared'" ); + let store = harness.trigger_and_await_result("test.empty_perms_result").await?; + assert_eq!(store.get("fs_present"), Some(&json!(false))); + assert_eq!(store.get("state_present"), Some(&json!(false))); + harness.shutdown(); Ok(()) } @@ -1556,7 +1590,94 @@ enabled = false Ok(parsed.get("result").cloned().unwrap_or_else(|| json!({}))) } - fn shutdown(mut self) { + /// Poll `modules.list`/`state.get "modules"`-equivalent status until + /// `name` reaches `Loaded` (or `LoadError`, which is treated as a test + /// failure). Out-of-process modules (Workstream G: + /// `decl.permissions.is_some()`, see `breadd/src/lua/mod.rs`) report + /// their load outcome asynchronously — a passing `wait_until_ready` + /// only proves the daemon's IPC socket itself is up, not that any + /// particular module has finished spawning/connecting/authenticating/ + /// running its `init.lua` yet. + async fn wait_for_module_loaded(&self, name: &str) -> Result<()> { + // Comfortably exceeds module_host::READY_TIMEOUT (breadd's own + // spawn-side wait) so this test-side poll doesn't give up before + // breadd itself would. + let deadline = Instant::now() + Duration::from_secs(55); + while Instant::now() < deadline { + let modules = self.send_request("state.get", json!({"key": "modules"})).await?; + if let Some(arr) = modules.as_array() { + for m in arr { + if m.get("name").and_then(Value::as_str) == Some(name) { + match m.get("status").and_then(Value::as_str) { + Some("loaded") => return Ok(()), + Some("load_error") => { + return Err(anyhow!("module '{name}' failed to load: {m}")) + } + _ => {} + } + } + } + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + Err(anyhow!("module '{name}' did not reach Loaded within timeout")) + } + + /// Subscribe to `result_event`, send a `test.trigger` manual emit to + /// kick off whatever Lua handler is waiting on it, and return the + /// triggered event's `data`. See `breadd/tests/module_host_sandbox.rs`'s + /// module doc comment for why this trigger-based pattern exists at all: + /// a module reporting its result from `on_load` directly would race the + /// daemon's own startup sequence, since `tokio::sync::broadcast` (what + /// `events.subscribe` reads from) never replays history to a subscriber + /// that joins after a send already happened. + async fn trigger_and_await_result(&self, result_event: &str) -> Result { + let stream = UnixStream::connect(self.socket_path()).await?; + let (read_half, mut write_half) = stream.into_split(); + let subscribe = json!({ + "id": "sub-1", + "method": "events.subscribe", + "params": { "filter": result_event }, + }); + write_half + .write_all(format!("{}\n", serde_json::to_string(&subscribe)?).as_bytes()) + .await?; + let mut reader = BufReader::new(read_half).lines(); + let _ack = reader.next_line().await?; + + self.send_request("emit", json!({ "event": "test.trigger", "data": {} })) + .await?; + + let line = timeout(Duration::from_secs(10), reader.next_line()) + .await + .map_err(|_| anyhow!("timed out waiting for {result_event}"))?? + .ok_or_else(|| anyhow!("connection closed before {result_event} arrived"))?; + let event: Value = serde_json::from_str(&line)?; + event + .get("data") + .cloned() + .ok_or_else(|| anyhow!("{result_event} missing data")) + } + + fn shutdown(self) { + // Drop (below) does the actual killing. + drop(self); + } +} + +impl Drop for TestHarness { + /// A test that fails partway through (an `?`-propagated error, a + /// failed `assert!` unwinding) must not leak a live `breadd` process — + /// worse, since Workstream G, a leaked `breadd` can itself have spawned + /// `bread-module-host` children under a real Landlock sandbox, which + /// don't exit on their own once the parent socket's other end goes + /// away instantly (they notice on their next read and exit, but that's + /// not instant). Without this, a single failing test in this file + /// leaves orphaned processes for every *other* concurrently-running + /// test to contend with for CPU/scheduler time — turning one flaky + /// failure into cascading slowdowns/timeouts across the whole suite + /// (observed directly while developing Workstream G's tests). + fn drop(&mut self) { let _ = self.child.kill(); let _ = self.child.wait(); } diff --git a/breadd/tests/module_host_sandbox.rs b/breadd/tests/module_host_sandbox.rs new file mode 100644 index 0000000..5d67df2 --- /dev/null +++ b/breadd/tests/module_host_sandbox.rs @@ -0,0 +1,537 @@ +//! Workstream G acceptance tests: the real, end-to-end version of the two +//! things this workstream exists to prove, going through a real spawned +//! `breadd` + a real spawned `bread-module-host` child + a real IPC +//! handshake — not the in-isolation Landlock-mechanism unit tests in +//! `breadd/src/module_host.rs` (`landlock_denies_reads_outside_granted_path`, +//! `no_exec_permission_means_binary_cannot_be_executed_at_all`), which only +//! exercise `apply_sandbox` directly against a plain `sh`/`cat`. +//! +//! 1. [`os_execute_and_io_open_are_denied_at_the_kernel_level_outside_granted_scope`] — +//! a module granted `fs.read` for exactly one directory (and nothing +//! else) runs real Lua that calls `io.open`/`os.execute` directly, +//! bypassing the RPC bridge entirely and going straight for the +//! `os`/`io` escape hatch Workstream D's in-process scoping admittedly +//! leaves open (see `breadd/src/lua/mod.rs`'s `build_scoped_env` doc +//! comment). This is the single most important test in the whole +//! workstream: proving the denial is a *kernel* permission error, not a +//! Lua-level check that a well-behaved module merely chooses to respect. +//! 2. [`killing_a_module_host_child_does_not_take_down_breadd_or_other_modules`] — +//! `kill -9` on a running module-host child's PID, confirming `breadd` +//! itself and a second, unrelated module both keep responding, and that +//! `breadd` detects the death and reports it via `bread.module.crashed`. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant}; + +use anyhow::{anyhow, Result}; +use serde_json::{json, Value}; +use tempfile::TempDir; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::UnixStream; +use tokio::time::{sleep, timeout}; + +// NOTE: these tests need `target/{debug,release}/bread-module-host` to +// already exist — `breadd::module_host::resolve_module_host_binary` looks +// for it as a sibling of `breadd`'s own executable. `bread-module-host` is +// a bin-only crate (no `[lib]` target — deliberately, see its Cargo.toml), +// so it can't be pulled in as a `[dev-dependencies]` entry to force cargo +// to build it via `env!("CARGO_BIN_EXE_...")`, the usual trick for this. +// Running via `cargo test --workspace` (this repo's documented/required +// verification command — see Documentation.md) builds every workspace +// member, including `bread-module-host`, before any test runs, so this +// isn't a problem in practice; running `cargo test -p breadd` in isolation +// without a prior `cargo build --workspace` would need one first. + +struct TestHarness { + _temp: TempDir, + child: Child, + socket_path: PathBuf, + #[allow(dead_code)] + home: PathBuf, +} + +impl TestHarness { + /// Spawns a real `breadd` with `[modules] builtin = false` and one + /// directory-based module per `(name, manifest_toml, init_lua)` entry — + /// the same on-disk shape `bread modules install` produces + /// (`//{bread.module.toml,init.lua}`). + fn spawn_with_modules(modules: &[(&str, &str, &str)]) -> Result { + let temp = tempfile::tempdir()?; + let runtime_dir = temp.path().join("runtime"); + let config_home = temp.path().join("config"); + let home = temp.path().join("home"); + fs::create_dir_all(&runtime_dir)?; + fs::create_dir_all(&config_home)?; + fs::create_dir_all(&home)?; + + let bread_cfg = config_home.join("bread"); + fs::create_dir_all(bread_cfg.join("modules"))?; + fs::write( + bread_cfg.join("init.lua"), + "bread.on('bread.system.startup', function() end)\n", + )?; + + for (name, manifest_toml, init_lua) in modules { + let module_dir = bread_cfg.join("modules").join(name); + fs::create_dir_all(&module_dir)?; + if !manifest_toml.is_empty() { + fs::write(module_dir.join("bread.module.toml"), manifest_toml)?; + } + fs::write(module_dir.join("init.lua"), init_lua)?; + } + + fs::write( + bread_cfg.join("breadd.toml"), + r#" +[daemon] +log_level = "error" + +[lua] +entry_point = "~/.config/bread/init.lua" +module_path = "~/.config/bread/modules" + +[modules] +builtin = false + +[adapters.hyprland] +enabled = false + +[adapters.udev] +enabled = false + +[adapters.power] +enabled = false + +[adapters.network] +enabled = false +"#, + )?; + + let socket_path = runtime_dir.join("bread").join("breadd.sock"); + let child = Command::new(env!("CARGO_BIN_EXE_breadd")) + .env("XDG_RUNTIME_DIR", &runtime_dir) + .env("XDG_CONFIG_HOME", &config_home) + .env("HOME", &home) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn()?; + + Ok(Self { + _temp: temp, + child, + socket_path, + home, + }) + } + + fn socket_path(&self) -> &Path { + &self.socket_path + } + + async fn wait_until_ready(&self) -> Result<()> { + let deadline = Instant::now() + Duration::from_secs(8); + while Instant::now() < deadline { + if self.socket_path.exists() { + if self.send_request("ping", json!({})).await.is_ok() { + return Ok(()); + } + } + sleep(Duration::from_millis(100)).await; + } + Err(anyhow!("daemon did not become ready in time")) + } + + /// Poll `modules.list` until `name` shows up `Loaded` — out-of-process + /// modules report their load outcome asynchronously (see + /// `breadd/src/lua/mod.rs`'s `load_out_of_process_module`), so a plain + /// `wait_until_ready` (which only proves the daemon's IPC socket is up) + /// isn't enough to know a specific module has finished spawning, + /// connecting, authenticating, and running its `init.lua`. + async fn wait_for_module_loaded(&self, name: &str) -> Result<()> { + // Comfortably exceeds module_host::READY_TIMEOUT (breadd's own + // spawn-side wait) so this test-side poll doesn't give up before + // breadd itself would. + let deadline = Instant::now() + Duration::from_secs(55); + while Instant::now() < deadline { + let modules = self.send_request("modules.list", json!({})).await?; + if let Some(arr) = modules.as_array() { + for m in arr { + if m.get("name").and_then(Value::as_str) == Some(name) { + if m.get("status").and_then(Value::as_str) == Some("loaded") { + return Ok(()); + } + if m.get("status").and_then(Value::as_str) == Some("load_error") { + return Err(anyhow!( + "module '{name}' failed to load: {:?}", + m.get("last_error") + )); + } + } + } + } + sleep(Duration::from_millis(100)).await; + } + Err(anyhow!("module '{name}' did not reach Loaded within timeout")) + } + + async fn send_request(&self, method: &str, params: Value) -> Result { + let stream = UnixStream::connect(self.socket_path()).await?; + let (read_half, mut write_half) = stream.into_split(); + + let req = json!({ "id": "1", "method": method, "params": params }); + write_half + .write_all(format!("{}\n", serde_json::to_string(&req)?).as_bytes()) + .await?; + + let mut lines = BufReader::new(read_half).lines(); + let line = lines + .next_line() + .await? + .ok_or_else(|| anyhow!("missing ipc response"))?; + let parsed: Value = serde_json::from_str(&line)?; + + if let Some(err) = parsed.get("error").and_then(Value::as_str) { + return Err(anyhow!(err.to_string())); + } + Ok(parsed.get("result").cloned().unwrap_or_else(|| json!({}))) + } + + /// Find the PID of a `bread-module-host` child spawned for this + /// harness's `breadd` by scanning `/proc/*/environ` for + /// `BREAD_MODULE_NAME=` — the module-host binary never + /// puts its identity in argv (see its own doc comment on why: + /// `/proc/*/cmdline` is visible to any process), so this is the same + /// kind of environment-based lookup, just from the test side instead + /// of breadd's. + fn find_module_host_pid(&self, module_name: &str) -> Result { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + for entry in fs::read_dir("/proc")?.flatten() { + let file_name = entry.file_name(); + let Some(pid_str) = file_name.to_str() else { + continue; + }; + let Ok(pid) = pid_str.parse::() else { + continue; + }; + let environ_path = entry.path().join("environ"); + let Ok(environ) = fs::read(&environ_path) else { + continue; + }; + let wanted = format!("BREAD_MODULE_NAME={module_name}"); + if environ + .split(|b| *b == 0) + .any(|var| var == wanted.as_bytes()) + { + return Ok(pid); + } + } + if Instant::now() > deadline { + return Err(anyhow!( + "no bread-module-host process found for module '{module_name}'" + )); + } + std::thread::sleep(Duration::from_millis(100)); + } + } + + fn shutdown(self) { + // Drop does the actual killing (see below) — this method exists so + // call sites can be explicit about "done with this harness" without + // caring exactly how cleanup happens. + drop(self); + } +} + +impl Drop for TestHarness { + /// Any `?`-propagated failure partway through a test (a timed-out + /// event, a failed assertion via `anyhow!` — though assertion panics + /// unwind rather than `?`-return, they still run `Drop`) must not leak + /// a live `breadd` (and, transitively, any `bread-module-host` + /// children it spawned) — `kill` here, not just on the happy path via + /// `shutdown()`, is what keeps a failed test run from leaving orphaned + /// sandboxed processes behind for the next run to trip over. + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +/// The P0 acceptance test: verified at the OS level, not asserted. See this +/// file's module doc comment. +#[tokio::test] +async fn os_execute_and_io_open_are_denied_at_the_kernel_level_outside_granted_scope() -> Result<()> +{ + let allowed_dir = tempfile::tempdir()?; + let allowed_file = allowed_dir.path().join("allowed.txt"); + fs::write(&allowed_file, "allowed-content")?; + + // Deliberately NOT under $HOME/anything the manifest grants, and + // deliberately world-readable-by-this-user (normal DAC permissions + // alone would NOT deny this) so a pass here can only be explained by + // Landlock, not by an unrelated ordinary permission error — the same + // reasoning as the `deny_dir`/`secret.txt` split in + // `breadd/src/module_host.rs`'s unit tests, just end-to-end this time. + let deny_dir = tempfile::tempdir()?; + let deny_file = deny_dir.path().join("secret.txt"); + fs::write(&deny_file, "top-secret-content")?; + + let manifest = format!( + r#" +name = "escape-hatch-test" + +[[permissions]] +type = "fs.read" +path = "{}" +"#, + allowed_dir.path().display() + ); + + // No `exec` permission granted at all, so `os.execute` should fail + // outright (can't even launch `/bin/sh` under Landlock) — and + // `io.open`, which doesn't need a subprocess at all, directly tests + // the FsRead scoping. Results are reported back over `bread.emit` + // (baseline, always available) since this module runs in a separate + // process we can't otherwise introspect from the test. + // + // The checks run on a `bread.on("test.trigger", ...)` handler, NOT in + // `on_load` — a module loads (and, if it ran in `on_load`, would emit + // its result) as part of daemon startup, which races the test's own + // `events.subscribe` connection. `tokio::sync::broadcast` (what + // `events.subscribe` reads from) does not replay history to a + // subscriber that joins after a send already happened — a late + // subscription just misses it, no error, no buffering — so without + // this explicit trigger the test would be racing the daemon's own + // startup sequence rather than reliably observing anything. + let init_lua = format!( + r#" +local M = bread.module({{ name = "escape-hatch-test", version = "1.0.0" }}) + +bread.on("test.trigger", function(trigger_event) + local allowed_result = "ALLOWED_READ_FAILED" + local fh = io.open("{allowed}", "r") + if fh then + local content = fh:read("*a") + fh:close() + allowed_result = "ALLOWED_READ_OK:" .. content + end + + local denied_result = "DENIED_READ_UNEXPECTEDLY_SUCCEEDED" + local deny_fh = io.open("{denied}", "r") + if deny_fh then + local content = deny_fh:read("*a") + deny_fh:close() + denied_result = "DENIED_READ_UNEXPECTEDLY_SUCCEEDED:" .. content + else + denied_result = "io.open denied" + end + + local exec_ok = os.execute("cat {denied} > /dev/null 2>&1") + local exec_result + if exec_ok == true then + exec_result = "EXEC_UNEXPECTEDLY_SUCCEEDED" + else + exec_result = "exec denied or failed" + end + + bread.emit("test.escape_hatch_result", {{ + allowed_result = allowed_result, + denied_result = denied_result, + exec_result = exec_result, + }}) +end) + +return M +"#, + allowed = allowed_file.display(), + denied = deny_file.display(), + ); + + let harness = TestHarness::spawn_with_modules(&[("escape-hatch-test", &manifest, &init_lua)])?; + harness.wait_until_ready().await?; + // Guarantees the module's `bread.on("test.trigger", ...)` subscription + // is already registered server-side before the trigger below is sent — + // "loaded" status is only reported (via `module_host.status`) after the + // module's whole init.lua chunk, including that top-level `bread.on` + // call, has finished executing. See this test's other race-avoidance + // comment above for why this matters. + harness.wait_for_module_loaded("escape-hatch-test").await?; + + let stream = UnixStream::connect(harness.socket_path()).await?; + let (read_half, mut write_half) = stream.into_split(); + let subscribe = json!({ + "id": "sub-1", + "method": "events.subscribe", + "params": { "filter": "test.escape_hatch_result" }, + }); + write_half + .write_all(format!("{}\n", serde_json::to_string(&subscribe)?).as_bytes()) + .await?; + let mut reader = BufReader::new(read_half).lines(); + let _ack = reader.next_line().await?; + + harness + .send_request("emit", json!({ "event": "test.trigger", "data": {} })) + .await?; + + let line = timeout(Duration::from_secs(15), reader.next_line()) + .await + .map_err(|_| anyhow!("timed out waiting for test.escape_hatch_result event"))?? + .ok_or_else(|| anyhow!("connection closed before event arrived"))?; + let event: Value = serde_json::from_str(&line)?; + let data = event + .get("data") + .ok_or_else(|| anyhow!("event missing data"))?; + + let allowed_result = data.get("allowed_result").and_then(Value::as_str).unwrap_or(""); + let denied_result = data.get("denied_result").and_then(Value::as_str).unwrap_or(""); + let exec_result = data.get("exec_result").and_then(Value::as_str).unwrap_or(""); + + assert!( + allowed_result.starts_with("ALLOWED_READ_OK"), + "the granted fs.read directory should remain readable via direct io.open; got {allowed_result:?}" + ); + assert!( + !denied_result.contains("UNEXPECTEDLY_SUCCEEDED"), + "io.open on a path OUTSIDE the granted fs.read scope must be denied at the kernel level (Landlock), not merely un-offered by an RPC binding — got {denied_result:?}" + ); + assert!( + !exec_result.contains("UNEXPECTEDLY_SUCCEEDED"), + "os.execute with no `exec` permission granted must not be able to run anything at all — got {exec_result:?}" + ); + + harness.shutdown(); + Ok(()) +} + +/// P0 item 5: killing a module-host child must not take `breadd` (or any +/// other module) down with it, and `breadd` must notice and report it. +#[tokio::test] +async fn killing_a_module_host_child_does_not_take_down_breadd_or_other_modules() -> Result<()> { + // An explicit, empty `permissions = []` — not "no manifest at all" — is + // what opts a module into the out-of-process sandboxed path with zero + // grants (see `ModuleDecl::permissions`'s doc comment in + // `breadd/src/lua/mod.rs`: `None` means "no manifest", which keeps + // today's in-process, ungated legacy behavior; `Some(vec![])` means + // "deliberately baseline-only" and IS routed out-of-process). + let victim_manifest = "name = \"victim\"\npermissions = []\n"; + + let victim_init = r#" +local M = bread.module({ name = "victim", version = "1.0.0" }) +function M.on_load() end +return M +"#; + + // The "control" module stays in-process (no manifest at all — the + // legacy/backward-compat path) specifically so this test also proves + // an out-of-process module's crash doesn't disturb an *in-process* + // module either, not just breadd's own IPC responsiveness. + let control_init = r#" +local M = bread.module({ name = "control", version = "1.0.0" }) +bread.on("bread.custom.ping_control", function(event) + bread.emit("bread.custom.pong_control", {}) +end) +return M +"#; + + let harness = TestHarness::spawn_with_modules(&[ + ("victim", victim_manifest, victim_init), + ("control", "", control_init), + ])?; + harness.wait_until_ready().await?; + harness.wait_for_module_loaded("victim").await?; + + // Subscribe to bread.module.crashed BEFORE killing, so we can't miss it. + let crash_stream = UnixStream::connect(harness.socket_path()).await?; + let (crash_read, mut crash_write) = crash_stream.into_split(); + crash_write + .write_all( + format!( + "{}\n", + serde_json::to_string(&json!({ + "id": "crash-sub", + "method": "events.subscribe", + "params": { "filter": "bread.module.crashed" }, + }))? + ) + .as_bytes(), + ) + .await?; + let mut crash_reader = BufReader::new(crash_read).lines(); + let _ack = crash_reader.next_line().await?; + + let victim_pid = harness.find_module_host_pid("victim")?; + let kill_status = Command::new("kill").args(["-9", &victim_pid.to_string()]).status()?; + assert!(kill_status.success(), "failed to send SIGKILL to victim module-host"); + + // breadd itself must keep responding. + let ping = harness.send_request("ping", json!({})).await?; + assert_eq!(ping.get("ok").and_then(Value::as_bool), Some(true)); + + // The unrelated in-process "control" module must keep dispatching + // events normally. + let control_stream = UnixStream::connect(harness.socket_path()).await?; + let (control_read, mut control_write) = control_stream.into_split(); + control_write + .write_all( + format!( + "{}\n", + serde_json::to_string(&json!({ + "id": "pong-sub", + "method": "events.subscribe", + "params": { "filter": "bread.custom.pong_control" }, + }))? + ) + .as_bytes(), + ) + .await?; + let mut control_reader = BufReader::new(control_read).lines(); + let _ack = control_reader.next_line().await?; + + harness + .send_request( + "emit", + json!({ "event": "bread.custom.ping_control", "data": {} }), + ) + .await?; + + let pong_line = timeout(Duration::from_secs(10), control_reader.next_line()) + .await + .map_err(|_| anyhow!("control module did not respond after victim was killed"))?? + .ok_or_else(|| anyhow!("control connection closed unexpectedly"))?; + let pong: Value = serde_json::from_str(&pong_line)?; + assert_eq!( + pong.get("event").and_then(Value::as_str), + Some("bread.custom.pong_control"), + "control module should still be alive and responsive after the victim module-host was killed" + ); + + // breadd must have detected the death and reported it. + let crash_line = timeout(Duration::from_secs(10), crash_reader.next_line()) + .await + .map_err(|_| anyhow!("bread.module.crashed was not emitted after kill -9"))?? + .ok_or_else(|| anyhow!("crash subscription connection closed unexpectedly"))?; + let crash_event: Value = serde_json::from_str(&crash_line)?; + assert_eq!( + crash_event + .get("data") + .and_then(|d| d.get("module")) + .and_then(Value::as_str), + Some("victim"), + "bread.module.crashed should identify the module whose host process died" + ); + assert_eq!( + crash_event + .get("data") + .and_then(|d| d.get("signal")) + .and_then(Value::as_i64), + Some(9), + "the crash report should reflect that the process was killed by SIGKILL" + ); + + harness.shutdown(); + Ok(()) +}