Docs already said any module or app could publish bread.command.<app>.<verb>,
but command is reserved so both unsourced bread-emit and sourced App emit
rejected the whole namespace. Keep command unclaimable as an app id; accept
bread.command.<known-app>.<verb> (and let an app command another known app).
Also ship bread-emit and bread-module-host, and give udev enumerate the same
classification fields as a live add so boot-time devices are not all unknown.
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.
ModuleManifest gains a structured [[permissions]] field (bread-shared's
new ModulePermission/PermissionKind, shared between bread-cli and breadd
so the two never drift on what a permission "type" string means).
breadd now gives every third-party module's Lua chunk a scoped _ENV
instead of the shared global table: load_scoped_lua_file builds a fresh
`bread` table containing only baseline bindings (event subscription,
timers, json, module/store, logging, and the pure-Lua sugar built on top
of those) plus whatever the manifest's permissions grant, with a
metatable __index falling back to the real globals for everything else
(stdlib, require/package - so require("bread.devices") keeps working,
since builtins load unscoped and their closures capture that environment
lexically regardless of the caller's). _G is explicitly rebound to the
scoped table itself to close the obvious escape hatch. A module with no
manifest, or a manifest with no permissions key, keeps full ambient
access unchanged (today's behavior) but is now tracked as `ungated` in
module status and surfaced by `bread doctor`. An explicit `permissions =
[]` is scoped for real but not flagged, since that's a deliberate
declaration.
Adds `bread modules audit <name>`: a best-effort text scan of a module's
.lua files suggesting a [[permissions]] block to paste into its manifest.
Converts examples/modules/cpu-temp-widget.lua into a directory module
with a worked bread.module.toml (fs.read + widget) as the reference
example. Documentation.md gets a new "Capability-scoped modules" section
covering the taxonomy, the require()/closure mechanism, and an explicit
note that path/bin scoping is recorded but not yet enforced per-call -
that's the out-of-process module sandboxing workstream this manifest
schema is laid down for. API_VERSION bumped 1.4.0 -> 1.5.0.
Every BreadEvent now gets a unique id at construction (via
BreadEvent::new/with_timestamp, and the normalizer's struct-literal call
sites, which all now assign one explicitly). A new caused_by field is
populated only when a Lua bread.emit() call runs synchronously inside a
bread.on subscriber's handler: LuaEngine tracks the currently-dispatching
event's id (set/restored around each handle_event invocation, single-
threaded so a plain Mutex-guarded slot suffices) and bread.emit()'s Rust
binding reads it when constructing the outgoing event. This lets chains of
Lua modules that react to each other's events be reconstructed instead of
timestamp-guessed from a live `bread events` log.
Also:
- bread events --tree renders the caused_by chain as an indented,
live-streaming-friendly tree instead of a flat line-per-event stream.
- API_VERSION bumped 1.4.0 -> 1.5.0 (additive-only change).
- Documentation.md's event envelope, bread.emit, and debugging-tips
sections updated with Since: v1.5 markers.
- New end-to-end regression test spawning the real daemon with 3 chained
Lua handlers (A emits X on trigger, B emits Y on X, C emits Z on Y) and
asserting caused_by threads correctly through all three hops.
The IPC "emit" method's no-source path took a bare event+data and sent it
straight to emit_tx tagged AdapterSource::System with zero validation of
the event name. Any same-UID process on the socket could send e.g.
{"event":"bread.power.ac.connected",...} and have it delivered to every
Lua subscriber indistinguishable from a real adapter event, since System
is the same tag the daemon uses for its own trusted, Rust-originated
sends (bread.system.startup, bread.profile.activated).
Fix, scoped to the actual threat model (same-UID Unix socket trust means
there's no way to cryptographically distinguish "the real bread-cli
binary" from any other local process, so a generic connection-identity
handshake would be theater):
- New AdapterSource::Manual tag for the no-source emit path. System is
now reserved for daemon-internal, Rust-code-originated sends only and
can never again be produced from data that arrived over the wire.
- The event name is rejected if its top-level dotted segment is one of
the reserved, adapter-owned domains (RESERVED_DOMAINS in
bread-shared/src/apps.rs) -- extended with bluetooth/workspace/window/
monitor, event families the Hyprland and Bluetooth adapters already
publish under but that were missing from that list. Freely-named
custom/test event names are untouched, so `bread emit <name>` and
bread-emit's fire-and-forget single-line-write design keep working
exactly as documented.
Bumped API_VERSION to 1.5.0 and updated Documentation.md's IPC emit
section and Namespaces reserved-domains list accordingly.
Also fixed a subscribe/emit race that surfaced while adding regression
tests for this: events.subscribe's ack is written to the client before
the server task actually registers on the broadcast channel, so a test
that emits immediately after reading the ack can race the registration.
Added a settle delay plus an explicit timeout (instead of an unbounded
read loop) so a future regression fails the test instead of hanging the
whole binary.
- modules_mgmt.rs: reject module names containing path separators, `..`,
or absolute paths before joining onto modules_dir (install_from_local,
remove_module, read_module_manifest); adds canonicalized containment
check as defense in depth. Manifest-supplied names and CLI args were
previously joined unsanitized, allowing path traversal on install/remove.
- breadd/src/lua/mod.rs: bread.exec now runs via `sh -c` instead of
`sh -lc`; no documented reason was found for login-shell semantics.
- Unify the two independently hand-written glob matchers (subscription
dispatch in breadd/src/core/subscriptions.rs vs. the CLI --filter path
in breadd/src/ipc/mod.rs) into one implementation in
bread-shared/src/glob.rs, used by both call sites.
- Remove the dead bread-sync/ tree (already excluded from the workspace
and fully unreferenced) and its stale PKGBUILD deps (libgit2, git
optdepend) and packaging docs mention.
- Correct the version-number transposition bug ("6.2.0" instead of
"0.6.2"/"0.6.6") across bread-shared, breadd, and bread-cli Cargo.toml,
and fix PKGBUILD's stale pkgver, so Cargo.toml/doctor/PKGBUILD all agree
with the latest git tag (v0.6.6).
P1-A: normalizer derives `online` from rtnetlink event kind (link.up/down,
route.default.changed, address.added/removed) so bread.network.connected
fires correctly on all systems using rtnetlink.
P1-B: stream_events consumes the subscribe ack before the event loop so the
first line is not printed as garbage.
P1-C: UPowerAdapter::probe() validates D-Bus synchronously before committing;
the sysfs fallback now actually triggers when D-Bus is unavailable.
P2-A: profile.list returns the full profile state (active + history) instead
of the always-empty profiles map.
P2-B: profile history capped at 50 entries in both StateCommand and
apply_event_to_state to prevent unbounded growth.
P2-C: RtnetlinkAdapter::new() no longer spawns an orphaned tokio task;
it validates availability by constructing and immediately dropping the
connection tuple.
P2-D: Lua-side hyprland_request_socket() logs a warn when multiple
Hyprland instances are found, matching the adapter-side behaviour.
P2-E: Malformed JSON from an IPC client returns an error response and
continues rather than closing the entire connection.
P3-A: Remove the `ends_with(".*")` prefix-match shortcut from both the
subscription table and the IPC event filter. `bread.*` now means
one segment (matching documented API semantics: `* = one segment`).
Tests updated accordingly.
P4-A: Remove unused `git2` and `glob` workspace dependencies (left over
from bread-sync extraction).
P4-B: breadd dev-dependency `tempfile` declared via workspace = true.
P4-C: Remove unreachable XDG_CONFIG_HOME branch in modules_dir(); dirs
already reads that var internally before returning None.
P4-D: Delete duplicate send_request_with_stream(); print_doctor() now
uses socket.exists() + send_request() directly.
P5-A: release.yml drops `--lib` from cargo test so integration tests run
in the release gate.
P6-A: bluetooth_spawn / bluetooth_query replace expect() on tokio runtime
construction with error logging / error propagation.
P6-B: Spin loops in lua/mod.rs add std:🧵:yield_now() after the
PAUSE hint to reduce CPU burn under sustained RwLock contention.
P6-C: All Mutex::lock().expect("... poisoned") in lua/mod.rs replaced with
unwrap_or_else(|e| e.into_inner()) for poison recovery.
P7-B: bread.system.startup event moved from main.rs into ipc::Server::serve()
so it fires after the socket is bound (smaller race window for early
subscribers).
Security:
- Remove `bread modules install github:…`. Remote fetch pulled unreviewed
third-party Lua and ran it with full bread.exec() privileges in an
unsandboxed runtime. Module install is now local-only; parse_source
rejects github:/git: with an explicit message.
bread-sync extracted from the workspace (parked for its own project):
- Removed from workspace members (now excluded); see bread-sync/EXTRACTION.md
- Removed the entire `bread sync` CLI surface and now-unused deps
(bread-sync, reqwest, tar, flate2; tempfile demoted to dev-dependency)
- Removed the sync.status IPC method from breadd plus its integration tests
- Moved the generic `expand_path` helper into bread-shared (with unit tests)
CI now actually runs and gates quality:
- Trigger on master/dev (was `main` — CI had never run, not once)
- Added `cargo fmt --check` and `clippy -D warnings`; fixed 4 clippy warnings
- Dropped the macOS matrix entry (breadd is Linux-only: udev/rtnetlink);
added the libudev-dev system dependency the Linux build needs
Hardening / honesty:
- New ipc test: daemon survives repeated reloads and the event pipeline
resumes (the prior suite only had a single happy-path reload check)
- Docs scrubbed of sync across README/Documentation/Overview/DAEMON
- "production-ready" and "compositor-agnostic" claims reworded to match
reality rather than aspiration
Note: bread-sync/src/export.rs held pre-existing local WIP authored outside
this change set and is intentionally excluded from this commit.