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

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

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

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

29
Cargo.lock generated
View file

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

View file

@ -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"] }

View file

@ -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:<name>` 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/<pid>/cmdline`, env vars
are not without `/proc/<pid>/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:*

View file

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

266
bread-module-host/src/io.rs Normal file
View file

@ -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<Result<Value, String>>,
},
}
/// 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<Value>,
#[serde(default)]
error: Option<String>,
}
/// 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<IoCommand>,
host_tx: mpsc::Sender<HostMessage>,
hello_tx: mpsc::Sender<Result<ModuleHostHello, String>>,
) {
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<Mutex<HashMap<String, mpsc::Sender<Result<Value, String>>>>> =
Arc::new(Mutex::new(HashMap::new()));
let (async_cmd_tx, mut async_cmd_rx) = tokio::sync::mpsc::unbounded_channel::<IoCommand>();
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::<ModuleHostPush>(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::<RpcResponse>(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<IoCommand>,
method: &str,
params: Value,
timeout: Duration,
) -> Result<Value, String> {
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"))?
}

View file

@ -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<LuaValue<'lua>> {
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<RefCell<HashMap<String, RegistryKey>>>,
registered: Rc<RefCell<bool>>,
module_table_key: Rc<RefCell<Option<RegistryKey>>>,
module_name: String,
}
impl ModuleHostLua {
pub fn new(
cmd_tx: mpsc::Sender<IoCommand>,
module_name: String,
permissions: Vec<ModulePermission>,
) -> Result<Self> {
let lua = Lua::new();
let bread = lua.create_table()?;
let handlers: Rc<RefCell<HashMap<String, RegistryKey>>> = Rc::new(RefCell::new(HashMap::new()));
let registered = Rc::new(RefCell::new(false));
let module_table_key: Rc<RefCell<Option<RegistryKey>>> = 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<PermissionKind> = 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("<bread-module-host wait helper>").exec()?;
Ok(Self {
lua,
handlers,
registered,
module_table_key,
module_name,
})
}
fn install_module_fn(
lua: &Lua,
bread: &Table,
expected_name: String,
registered: Rc<RefCell<bool>>,
module_table_key: Rc<RefCell<Option<RegistryKey>>>,
) -> Result<()> {
let store: Rc<RefCell<HashMap<String, JsonValue>>> = Rc::new(RefCell::new(HashMap::new()));
let module_fn = lua.create_function(move |lua, table: Table| -> mlua::Result<Table> {
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<String> = 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<IoCommand>) -> 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::<JsonValue>(&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<IoCommand>,
handlers: Rc<RefCell<HashMap<String, RegistryKey>>>,
) -> 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<IoCommand>,
handlers: Rc<RefCell<HashMap<String, RegistryKey>>>,
) -> 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<IoCommand>) -> Result<()> {
let emit_fn = lua.create_function(move |lua, (event, data): (String, Option<LuaValue>)| {
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<IoCommand>,
granted: &HashSet<PermissionKind>,
) -> 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<IoCommand>,
granted: &HashSet<PermissionKind>,
) -> 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<IoCommand>,
granted: &HashSet<PermissionKind>,
) -> 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<Table>)| {
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<Function> = 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<Function<'_>> {
let handlers = self.handlers.borrow();
let key = handlers.get(id)?;
self.lua.registry_value::<Function>(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)
}
}

View file

@ -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::<IoCommand>();
let (host_tx, host_rx) = mpsc::channel::<HostMessage>();
let (hello_tx, hello_rx) = mpsc::channel::<Result<ModuleHostHello, String>>();
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<IoCommand>, ok: bool, error: Option<String>) {
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));
}

View file

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

View file

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

View file

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

View file

@ -20,6 +20,9 @@ use tracing::{error, info, warn};
use crate::adapters::AdapterStatus;
use crate::core::state_engine::StateHandle;
use crate::lua::RuntimeHandle;
use crate::module_host::ModuleHostRegistry;
mod module_host_bridge;
/// The Bread Automation API version (Lua API surface + IPC methods + event
/// vocabulary + runtime-state schema), per `Documentation.md`'s "API
@ -27,7 +30,11 @@ use crate::lua::RuntimeHandle;
/// something new-but-additive (a binding, an event, an IPC param); bump the
/// major version only for a breaking change, which should not happen inside
/// this daemon's v1 lifetime per that section's stated policy.
const API_VERSION: &str = "1.5.0";
///
/// *Since 1.6.0* — Workstream G's `module_host.*` methods (hello handshake
/// plus the RPC bridge a `bread-module-host` child uses in place of direct
/// in-process `bread.*` bindings).
const API_VERSION: &str = "1.6.0";
#[derive(Clone)]
pub struct Server {
@ -42,6 +49,11 @@ pub struct Server {
event_buffer: Arc<std::sync::Mutex<VecDeque<BreadEvent>>>,
started_at: Instant,
pid: u32,
/// Workstream G: token/identity bookkeeping for out-of-process module
/// hosts, shared with the Lua engine (which spawns them). See
/// `crate::module_host` and `module_host_bridge` (this module's
/// `module_host.*` method handling).
module_host_registry: ModuleHostRegistry,
}
#[derive(Debug, Deserialize)]
@ -62,7 +74,7 @@ struct IpcResponse {
}
impl Server {
// Server::new legitimately requires all 8 fields; a builder pattern here would be
// Server::new legitimately requires all 10 fields; a builder pattern here would be
// over-engineering for a single-call-site constructor.
#[allow(clippy::too_many_arguments)]
pub fn new(
@ -75,6 +87,7 @@ impl Server {
adapter_status: Arc<RwLock<HashMap<String, AdapterStatus>>>,
subscription_count: Arc<AtomicU64>,
event_buffer: Arc<std::sync::Mutex<VecDeque<BreadEvent>>>,
module_host_registry: ModuleHostRegistry,
) -> Self {
Self {
socket_path,
@ -84,6 +97,7 @@ impl Server {
emit_tx,
raw_tx,
adapter_status,
module_host_registry,
subscription_count,
event_buffer,
started_at: Instant::now(),
@ -176,6 +190,20 @@ impl Server {
return Ok(());
}
// Workstream G: a `bread-module-host` child's very first message
// presents its one-time spawn token. From here on this
// connection is a dedicated, bidirectional module-host bridge
// (RPC requests interleaved with async event/timer pushes) —
// see `module_host_bridge::handle_module_host_connection` —
// rather than a one-shot request/response exchange, so it takes
// over the rest of this connection's lifetime exactly like
// `events.subscribe` above does for a plain event stream.
if req.method == "module_host.hello" {
return self
.handle_module_host_connection(req, lines, write_half)
.await;
}
let response = match self.handle_request(req).await {
Ok(res) => IpcResponse {
id: res.0,
@ -339,24 +367,7 @@ impl Server {
let Some(event) = req.params.get("event").and_then(Value::as_str) else {
return Err((id, "missing event name".to_string()));
};
if let Some(domain) = event_domain(event) {
if is_reserved_domain(domain) {
return Err((
id,
format!(
"event '{event}' claims the reserved '{domain}' domain — manual emit cannot impersonate an adapter-owned event; use a custom event name, or a sourced emit if this should go through the normalizer"
),
));
}
}
if self
.emit_tx
.send(BreadEvent::new(event, AdapterSource::Manual, data))
.is_err()
{
return Err((id, "emit channel closed".to_string()));
}
Ok(json!({ "emitted": true }))
self.manual_emit(event, data)
}
}
"health" => {
@ -409,6 +420,31 @@ impl Server {
}
}
/// Unsourced-emit logic, factored out of `handle_request`'s `"emit"`
/// case so `module_host_bridge`'s `module_host.emit` (Workstream G) can
/// share the exact same reserved-domain guard rather than re-deriving
/// it — see the original inline comment (still above the one call site
/// in `handle_request`) for why the guard exists: a same-UID socket
/// client (or, now, a module-host child) must not be able to
/// impersonate a real adapter-owned event namespace.
fn manual_emit(&self, event: &str, data: Value) -> std::result::Result<Value, String> {
if let Some(domain) = event_domain(event) {
if is_reserved_domain(domain) {
return Err(format!(
"event '{event}' claims the reserved '{domain}' domain — manual emit cannot impersonate an adapter-owned event; use a custom event name, or a sourced emit if this should go through the normalizer"
));
}
}
if self
.emit_tx
.send(BreadEvent::new(event, AdapterSource::Manual, data))
.is_err()
{
return Err("emit channel closed".to_string());
}
Ok(json!({ "emitted": true }))
}
async fn stream_events(
&self,
writer: &mut tokio::net::unix::OwnedWriteHalf,

View file

@ -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<BufReader<OwnedReadHalf>>,
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::<String>();
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<String, JoinHandle<()>> = HashMap::new();
let mut timers: HashMap<String, JoinHandle<()>> = 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<String>,
subs: &mut HashMap<String, JoinHandle<()>>,
timers: &mut HashMap<String, JoinHandle<()>>,
outcome_tx: &mut Option<std::sync::mpsc::Sender<ModuleHostOutcome>>,
) -> std::result::Result<Value, String> {
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()
})
}

View file

@ -25,6 +25,7 @@ use crate::core::subscriptions::SubscriptionId;
use crate::core::types::{
DeviceRule, MatchCondition, ModuleLoadState, RuntimeState, WorkflowState, WorkflowStatus,
};
use crate::module_host::{self, ModuleHostOutcome, ModuleHostRegistry};
use bread_shared::now_unix_ms;
pub enum LuaMessage {
@ -90,6 +91,7 @@ pub fn spawn_runtime(
config: Config,
state_handle: StateHandle,
emit_tx: mpsc::UnboundedSender<BreadEvent>,
module_host_registry: ModuleHostRegistry,
) -> Result<RuntimeHandle> {
let (tx, mut rx) = mpsc::unbounded_channel();
let recent_errors = Arc::new(Mutex::new(VecDeque::with_capacity(50)));
@ -114,6 +116,7 @@ pub fn spawn_runtime(
emit_tx,
thread_tx.clone(),
recent_errors,
module_host_registry,
) {
Ok(engine) => engine,
Err(err) => {
@ -246,6 +249,11 @@ struct LuaEngine {
modules_config: ModulesConfig,
notifications_config: NotificationsConfig,
recent_errors: Arc<Mutex<VecDeque<ErrorEntry>>>,
/// Workstream G: spawn/token bookkeeping for out-of-process module
/// hosts, shared with `ipc::Server` (which authenticates the spawned
/// children and serves their RPC calls). See `crate::module_host`.
module_host_registry: ModuleHostRegistry,
socket_path: PathBuf,
}
impl LuaEngine {
@ -255,7 +263,9 @@ impl LuaEngine {
emit_tx: mpsc::UnboundedSender<BreadEvent>,
lua_tx: mpsc::UnboundedSender<LuaMessage>,
recent_errors: Arc<Mutex<VecDeque<ErrorEntry>>>,
module_host_registry: ModuleHostRegistry,
) -> Result<Self> {
let socket_path = config.socket_path();
Ok(Self {
lua: Lua::new(),
handlers: Arc::new(Mutex::new(HashMap::new())),
@ -271,6 +281,8 @@ impl LuaEngine {
state_handle,
emit_tx,
lua_tx,
module_host_registry,
socket_path,
entry_point: config.lua_entry_point(),
module_path: config.lua_module_path(),
modules_config: config.modules.clone(),
@ -1525,15 +1537,37 @@ impl LuaEngine {
}
fn load_module(&self, decl: &ModuleDecl) -> Result<()> {
// Workstream G branch point: a third-party module that declared
// `[[permissions]]` (opted into the D capability-manifest system —
// `decl.permissions.is_some()`, including `Some(&[])`) gets spawned
// as a separate, OS-sandboxed `bread-module-host` process instead of
// being loaded into this Lua VM at all. Its Lua state, `bread.on`
// handlers, timers, etc. all live in that other process from here
// on — none of this engine's module-table/on_load bookkeeping below
// applies to it, hence the early return.
//
// `decl.permissions.is_none()` (no manifest, or a manifest with no
// `permissions` key) falls through to the unchanged in-process,
// unscoped path for backward compatibility — see
// `ModuleDecl::permissions`'s doc comment and `Documentation.md`'s
// "Workstream G" section for why this is a deliberate scope
// decision rather than an oversight: Landlock needs concrete rules
// to build from, and "no manifest at all" carries none.
if decl.source.is_none() {
if let Some(permissions) = decl.permissions.as_ref() {
return self.load_out_of_process_module(decl, permissions);
}
}
self.set_current_module(Some(decl.name.clone()));
let result = if let Some(source) = decl.source {
// Builtins (bread.monitors/devices/workspaces/binds) — embedded
// source, always the full ambient bread table, never scoped.
self.load_lua_source(source, &decl.name)
} else {
// Third-party, on-disk modules only. Capability-scoped per
// decl.permissions — see load_scoped_lua_file.
self.load_scoped_lua_file(&decl.path, &decl.name, decl.permissions.as_deref())
// Third-party, on-disk, no-manifest module: today's original
// behavior, unchanged (full ungated in-process access).
self.load_scoped_lua_file(&decl.path, &decl.name, None)
};
self.set_current_module(None);
result?;
@ -1545,6 +1579,33 @@ impl LuaEngine {
self.run_on_load(&decl.name)
}
/// Spawn (or respawn, on `bread reload`) a sandboxed `bread-module-host`
/// child for `decl` and block until it reports ready or fails — see
/// `crate::module_host::spawn_module_host`. Blocking here (rather than
/// making `load_module` async) keeps `load_module`'s existing
/// synchronous "a module either loaded or it didn't" contract intact
/// for callers like `load_init_and_modules` and the `modules.reload`
/// IPC method, which both expect to know Loaded-vs-LoadError before
/// they return.
fn load_out_of_process_module(
&self,
decl: &ModuleDecl,
permissions: &[ModulePermission],
) -> Result<()> {
let outcome = module_host::spawn_module_host(
&self.module_host_registry,
&decl.name,
&decl.path,
permissions,
&self.socket_path,
&self.emit_tx,
)?;
match outcome {
ModuleHostOutcome::Ready => Ok(()),
ModuleHostOutcome::LoadError(err) => Err(anyhow!(err)),
}
}
/// Load `init.lua` (the trusted entry point) or any other file that
/// should see the real, unscoped `bread` global exactly like today.
/// Not used for third-party modules — see [`load_scoped_lua_file`].

View file

@ -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(())
}

732
breadd/src/module_host.rs Normal file
View file

@ -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:<name>` 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/<pid>/cmdline`, env vars are not
//! without `/proc/<pid>/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<ModulePermission>,
pub outcome_tx: std::sync::mpsc::Sender<ModuleHostOutcome>,
}
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<Mutex<Inner>>,
}
#[derive(Default)]
struct Inner {
pending: HashMap<String, PendingModuleHost>,
active: HashMap<String, ActiveModuleHost>,
}
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<PendingModuleHost> {
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<u32> = {
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<BreadEvent>,
) -> Result<ModuleHostOutcome> {
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<std::process::ExitStatus>,
) -> (String, Option<i32>, Option<i32>) {
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<PathBuf> {
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<ModulePermission> = 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");
}
}

View file

@ -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<Value> {
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();
}

View file

@ -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
/// (`<modules_dir>/<name>/{bread.module.toml,init.lua}`).
fn spawn_with_modules(modules: &[(&str, &str, &str)]) -> Result<Self> {
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<Value> {
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=<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<u32> {
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::<u32>() 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(())
}