diff --git a/Documentation.md b/Documentation.md index 1520ab5..ce9cd14 100644 --- a/Documentation.md +++ b/Documentation.md @@ -8,6 +8,7 @@ - [Your first module](#your-first-module) - [Run, reload, and watch](#run-reload-and-watch) - [Modules: install and manage](#modules-install-and-manage) +- [Capability-scoped modules](#capability-scoped-modules-since-v15) - [Debugging tips](#debugging-tips) - [Dictionary: Lua API](#dictionary-lua-api) - [Workflows](#workflows-since-v12) @@ -100,6 +101,12 @@ Key rules: - Register subscriptions inside `M.on_load` so they are cleaned up properly on hot reload. - Use `bread.log` early to verify handlers are firing. +A flat file like `modules/hello.lua` with no manifest gets full, unscoped +`bread.*` access — exactly what you see above, unchanged. That's fine for a +personal one-off. Once you install a module properly (`bread modules +install`), it's worth declaring what it actually uses — see +[Capability-scoped modules](#capability-scoped-modules-since-v15). + ## Run, reload, and watch ```bash @@ -130,9 +137,13 @@ bread modules install ~/src/bread-wifi # List installed modules and their daemon status bread modules list -# Show full manifest for one module +# Show full manifest for one module (including its declared permissions) bread modules info bread-wifi +# Get a suggested [[permissions]] block from a static scan of the module's +# Lua source — see "Capability-scoped modules" below +bread modules audit bread-wifi + # Remove a module bread modules remove bread-wifi bread modules remove bread-wifi --yes # skip confirmation @@ -147,13 +158,148 @@ description = "WiFi management for Bread" author = "someuser" source = "/home/you/src/bread-wifi" installed_at = "2026-01-01T00:00:00Z" + +[[permissions]] +type = "exec" +bin = "nmcli" + +[[permissions]] +type = "notify" ``` +`permissions` is optional *(Since: v1.5)*. Omitting it entirely — every +manifest written before v1.5, and any manifest an author just hasn't gotten +around to annotating — means the module runs exactly like it always has: +full, unscoped `bread.*` access. See the next section for what declaring it +actually buys you and the full permission taxonomy. + +## Capability-scoped modules *(Since: v1.5)* + +By default every third-party module gets the full `bread` table — the same +one built-in modules and `init.lua` see. `[[permissions]]` in +`bread.module.toml` narrows that: a module only sees the `bread.*` bindings +it was granted, plus a fixed **baseline** every module gets regardless. +Anything not granted is genuinely **absent** — `bread.fs == nil`, not +`bread.fs.read()` throwing a permission error — so a module written +defensively (`if bread.fs then ... end`) degrades exactly the way it would +if, say, Bluetooth hardware weren't present. + +### Baseline (always available, no manifest entry needed) + +Event subscription and timers are how a module does anything at all, so +they're never gated: `bread.on`/`once`/`filter`/`off`/`emit`, +`bread.after`/`every`/`cancel`. Also baseline: `bread.json` (pure decode, +no I/O), `bread.module` (required just to register), `bread.log`/`warn`/ +`error` (diagnostics), and the pure-Lua sugar built entirely on top of the +above — `bread.debounce`, `bread.spawn`/`wait`/`wait_any`/`wait_all`, +`bread.workflow.*`. + +### Gated — requires a matching `[[permissions]]` entry + +| `type` | Grants | Notes | +|--------|--------|-------| +| `state.read` | `bread.state.get`/`.monitors`/`.active_workspace`/`.active_window`/`.devices`/`.power`/`.network`/`.profile` | Read-only snapshots of daemon state. `path` is an advisory scoping hint (e.g. `"monitors"`), not yet enforced per-call — see the note below. | +| `state.watch` | `bread.state.watch` | Split from `state.read`: a standing subscription is a more persistent capability than a one-off read. | +| `profile.activate` | `bread.profile.activate` | Switches the daemon's system-wide active profile — a real cross-module side effect. | +| `exec` | `bread.exec`, `bread.exec_capture` | Spawns an arbitrary shell command. `bin` is an advisory hint (e.g. `"hyprpaper"`). | +| `notify` | `bread.notify` | Desktop notifications. | +| `machine` | `bread.machine.name`/`.tags`/`.has_tag` | Reads hostname/tags, including an optional on-disk `sync.toml`. | +| `hyprland` | `bread.hyprland.*` | Compositor IPC — `dispatch`/`keyword`/`eval` control the session, `monitors`/`workspaces`/`clients`/`active_window`/`on_raw` observe it. Not split further; grant it for either. | +| `widget` | `bread.widget.register`/`.update`/`.remove`/`.list` | Registers UI in a sibling `bread*` app (breadbar). | +| `fs.read` | `bread.fs.read`/`.exists`/`.readlink`/`.expand` | Read-only filesystem access. `path` is an advisory scoping hint. | +| `fs.write` | `bread.fs.write` | Filesystem writes. Split from `fs.read` — a module that only reads shouldn't need to declare write access. | +| `bluetooth` | `bread.bluetooth.*` | BlueZ control — power/connect/disconnect/scan/devices. | + +Example — a module that switches wallpaper via `hyprpaper` based on the +current monitor layout, and reads images from one directory: + +```toml +[[permissions]] +type = "exec" +bin = "hyprpaper" + +[[permissions]] +type = "state.read" +path = "monitors" + +[[permissions]] +type = "fs.read" +path = "~/Wallpapers" +``` + +That module's `bread` table has `bread.exec`, `bread.state` (read +functions only — no `bread.state.watch`), and `bread.fs` (read functions +only — no `bread.fs.write`), plus the full baseline. `bread.hyprland`, +`bread.bluetooth`, `bread.notify`, `bread.machine`, and `bread.widget` are +all `nil`. + +An explicit empty list (`permissions = []`) is a deliberate "baseline only" +declaration — different from omitting the key entirely. It scopes the +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 + +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. + +### `require("bread.devices")` still works from a scoped module + +Builtin library modules (`bread.devices`, `bread.monitors`, `bread.workspaces`, +`bread.binds`) always load with the full ambient `bread` table — they're +never subject to manifest-based scoping, regardless of what any third-party +module that `require`s them declares. `require("bread.devices")` resolves +via Lua's real `package.loaded` table (already populated by the time any +third-party module loads, since builtins load first) — a real global, +reachable from a scoped module through a metatable fallback to the true +globals for everything that isn't `bread` itself (`pairs`, `string`, +`table`, `require`, `package`, ...). The returned module's own functions +(`devices.on()` etc.) were defined while `bread.devices` loaded unscoped, +so they close over the *real* `bread` table as a Lua upvalue — closures +capture their defining environment lexically, not the caller's — which is +exactly why calling `devices.on(...)` from inside a scoped module works +with no special-casing needed. + +### `bread modules audit ` + +Best-effort static scan of an installed module's `.lua` files (its entry +file plus any others in the same directory) for `bread.*` call-site +patterns, printing a suggested `[[permissions]]` block to review and paste +into `bread.module.toml`: + +```bash +bread modules audit bread-wifi +``` + +This is a text scan, not a Lua parser — false positives (suggesting a +permission the module doesn't strictly need) are expected and fine; false +negatives on a plain `bread.exec("...")`-style call site should be rare, +but dynamic/computed call sites (`bread[method_name](...)`) won't be +detected. + ## Debugging tips - Run `bread events` to see live normalized events. - Run `bread state` to see full runtime state as JSON. -- Run `bread doctor` to check adapter and module health. +- Run `bread doctor` to check adapter and module health, including modules + running with full, ungated `bread.*` access because they have no + `permissions` declared. - Log event payloads with `bread.log(tostring(event.data))`. - Use `RUST_LOG=debug breadd` for verbose daemon output. diff --git a/bread-cli/src/main.rs b/bread-cli/src/main.rs index 843e758..80e469c 100644 --- a/bread-cli/src/main.rs +++ b/bread-cli/src/main.rs @@ -125,6 +125,9 @@ enum ModulesCommand { List, /// Show full manifest details for a module Info { name: String }, + /// Statically scan an installed module's Lua source and suggest a + /// `[[permissions]]` block for its `bread.module.toml` manifest + Audit { name: String }, } #[tokio::main] @@ -303,6 +306,55 @@ async fn handle_modules_cmd(cmd: ModulesCommand, socket: &Path) -> Result<()> { println!("source: {}", m.source); println!("installed_at: {}", m.installed_at); println!("status: {}", status); + match &m.permissions { + None => println!( + "permissions: (none declared — full, ungated bread.* access; see 'bread doctor')" + ), + Some(perms) if perms.is_empty() => { + println!("permissions: (declared empty — baseline access only)") + } + Some(perms) => { + println!("permissions:"); + for p in perms { + let mut line = format!(" - {:?}", p.kind); + if let Some(path) = &p.path { + line.push_str(&format!(" path={path}")); + } + if let Some(bin) = &p.bin { + line.push_str(&format!(" bin={bin}")); + } + println!("{line}"); + } + } + } + } + + ModulesCommand::Audit { name } => { + let module_dir = mods_dir.join(&name); + if !module_dir.exists() { + eprintln!("bread: module '{}' is not installed", name); + std::process::exit(1); + } + let suggested = modules_mgmt::audit_module(&module_dir)?; + if suggested.is_empty() { + println!( + "bread: no capability-gated bread.* calls found in '{}' — \ + it appears to only use baseline APIs (events, timers, json, \ + logging). Declaring `permissions = []` in bread.module.toml \ + documents that intentionally and avoids the 'no permissions \ + declared' warning from `bread doctor`.", + name + ); + return Ok(()); + } + println!( + "bread: suggested permissions for '{}' (best-effort static scan — \ + review before pasting into bread.module.toml; false positives \ + are possible, missing an actually-needed permission should be rare \ + for direct bread.exec()-style call sites):\n", + name + ); + print!("{}", modules_mgmt::render_permissions_toml(&suggested)?); } } Ok(()) @@ -602,14 +654,35 @@ fn render_doctor(health: &Value) { if let Some(modules) = health.get("modules").and_then(Value::as_array) { println!(); println!("modules"); + let mut ungated_count = 0; for module in modules { let name = module.get("name").and_then(Value::as_str).unwrap_or("?"); let status = module.get("status").and_then(Value::as_str).unwrap_or("?"); let error = module.get("last_error").and_then(Value::as_str); + let ungated = module + .get("ungated") + .and_then(Value::as_bool) + .unwrap_or(false); println!(" {:30} {}", name, status); if let Some(error) = error { println!(" └ {error}"); } + if ungated { + ungated_count += 1; + println!( + " └ ⚠ running with full, ungated access — no permissions \ + manifest declared (add `[[permissions]]` to its \ + bread.module.toml, or run `bread modules audit {name}` \ + for a suggested block)" + ); + } + } + if ungated_count > 0 { + println!(); + println!( + " {ungated_count} module(s) running with full, ungated bread.* access — \ + see above" + ); } } diff --git a/bread-cli/src/modules_mgmt.rs b/bread-cli/src/modules_mgmt.rs index f0b92a9..2376093 100644 --- a/bread-cli/src/modules_mgmt.rs +++ b/bread-cli/src/modules_mgmt.rs @@ -1,4 +1,5 @@ use anyhow::{bail, Context, Result}; +use bread_shared::{ModulePermission, PermissionKind}; use chrono::Utc; use serde::{Deserialize, Serialize}; use std::fs; @@ -13,6 +14,16 @@ pub struct ModuleManifest { pub author: String, pub source: String, pub installed_at: String, + /// Declared `[[permissions]]` entries. `None` means the manifest has no + /// `permissions` key at all — either because it predates this field (an + /// already-installed module) or because the author simply didn't add + /// one. `breadd` treats that the same way: full, ungated `bread.*` + /// access, same as today, but `bread doctor` flags it so the gap is + /// visible instead of silently permanent. An explicit `permissions = []` + /// is different: it's a deliberate "baseline only" declaration and does + /// *not* get flagged. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub permissions: Option>, } /// Resolve a module source string to a local directory path. @@ -227,3 +238,235 @@ fn copy_dir(src: &Path, dst: &Path) -> Result<()> { } Ok(()) } + +// --------------------------------------------------------------------------- +// `bread modules audit` — best-effort static permission suggestion +// --------------------------------------------------------------------------- +// +// This is deliberately a text scan, not a Lua parser. `breadd`'s own +// scoping mechanism only cares whether a permission was declared at all +// (see `Documentation.md`'s "Capability-scoped modules" section), so the +// bar here is the same one the report set: false positives (suggesting a +// permission a module doesn't strictly need) are fine, false negatives on +// a plain `bread.exec("...")`-style call site should be rare. It is not +// expected to follow dynamic dispatch, string-built calls, or anything a +// real parser would be needed for. + +/// Statically scan every `.lua` file in `module_dir` (recursively — a +/// module may `require()` sibling files from its own directory) for +/// `bread.*` call-site patterns and return a suggested, deduplicated +/// permission list for the user to review. +pub fn audit_module(module_dir: &Path) -> Result> { + let mut found: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + let mut files = Vec::new(); + collect_lua_files(module_dir, &mut files)?; + for file in &files { + if let Ok(src) = fs::read_to_string(file) { + scan_lua_source(&src, &mut found); + } + } + Ok(found.into_values().collect()) +} + +/// Render a suggested permission list as a pastable `[[permissions]]` TOML +/// block, matching exactly what `bread.module.toml` expects. +pub fn render_permissions_toml(perms: &[ModulePermission]) -> Result { + #[derive(Serialize)] + struct PermissionsBlock<'a> { + permissions: &'a [ModulePermission], + } + toml::to_string_pretty(&PermissionsBlock { permissions: perms }) + .context("failed to render suggested permissions as TOML") +} + +fn collect_lua_files(dir: &Path, out: &mut Vec) -> Result<()> { + if !dir.exists() { + return Ok(()); + } + for entry in fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + collect_lua_files(&path, out)?; + } else if path.extension().and_then(|e| e.to_str()) == Some("lua") { + out.push(path); + } + } + Ok(()) +} + +/// Scan one file's source for `bread.[.]` occurrences and +/// classify each into a permission, accumulating into `found` (keyed by +/// kind, so repeated call sites for the same permission collapse to one +/// suggestion — first-seen scoping hint wins). +fn scan_lua_source(src: &str, found: &mut std::collections::BTreeMap) { + const NEEDLE: &str = "bread."; + let mut cursor = 0usize; + while let Some(rel) = src[cursor..].find(NEEDLE) { + let ident_start = cursor + rel + NEEDLE.len(); + cursor = ident_start; + let rest = &src[ident_start..]; + let ident_len = rest + .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '.')) + .unwrap_or(rest.len()); + let ident = rest[..ident_len].trim_end_matches('.'); + if ident.is_empty() { + continue; + } + classify_call_site(ident, &rest[ident_len..], found); + } +} + +fn classify_call_site( + ident: &str, + tail: &str, + found: &mut std::collections::BTreeMap, +) { + let (kind, bin, path): (PermissionKind, Option, Option) = match ident { + "fs.write" => (PermissionKind::FsWrite, None, extract_first_string_arg(tail)), + "fs.read" | "fs.exists" | "fs.readlink" | "fs.expand" => { + (PermissionKind::FsRead, None, extract_first_string_arg(tail)) + } + "exec" | "exec_capture" => { + let hint = extract_first_string_arg(tail) + .and_then(|s| s.split_whitespace().next().map(str::to_string)); + (PermissionKind::Exec, hint, None) + } + "notify" => (PermissionKind::Notify, None, None), + "profile.activate" => (PermissionKind::ProfileActivate, None, None), + "state.watch" => (PermissionKind::StateWatch, None, extract_first_string_arg(tail)), + other if other == "state" || other.starts_with("state.") => { + (PermissionKind::StateRead, None, extract_first_string_arg(tail)) + } + other if other == "machine" || other.starts_with("machine.") => { + (PermissionKind::Machine, None, None) + } + other if other == "hyprland" || other.starts_with("hyprland.") => { + (PermissionKind::Hyprland, None, None) + } + other if other == "widget" || other.starts_with("widget.") => { + (PermissionKind::Widget, None, None) + } + other if other == "bluetooth" || other.starts_with("bluetooth.") => { + (PermissionKind::Bluetooth, None, None) + } + // Everything else (on/once/filter/off/emit/after/every/cancel/json/ + // module/log/warn/error/debounce/spawn/wait/wait_any/wait_all/ + // workflow/__private) is baseline — always available, nothing to + // suggest. + _ => return, + }; + found + .entry(kind) + .or_insert(ModulePermission { kind, path, bin }); +} + +/// Best-effort extraction of the first quoted string literal appearing on +/// the same line right after a call-site's opening paren, e.g. +/// `bread.exec("hyprpaper --config foo")` -> `Some("hyprpaper --config foo")`. +/// Returns `None` for dynamic/variable arguments (`bread.fs.read(path)`) — +/// the permission is still suggested, just without a scoping hint. +fn extract_first_string_arg(tail: &str) -> Option { + let line_end = tail.find('\n').unwrap_or(tail.len()); + let window = &tail[..line_end]; + let quote_pos = window.find(['"', '\''])?; + let quote_char = window.as_bytes()[quote_pos] as char; + let after = &window[quote_pos + 1..]; + let quote_end = after.find(quote_char)?; + Some(after[..quote_end].to_string()) +} + +#[cfg(test)] +mod audit_tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn audit_detects_fs_read_and_widget_from_cpu_temp_widget_style_module() { + let dir = TempDir::new().unwrap(); + fs::write( + dir.path().join("init.lua"), + r#" + local M = bread.module({ name = "cpu-temp-widget", version = "1.0.0" }) + local function read_temp_c() + local raw = bread.fs.read("/sys/class/hwmon/hwmon6/temp1_input") + return raw + end + function M.on_load() + bread.widget.register({ id = "cpu-temp" }) + bread.every(5000, function() + bread.widget.update("cpu-temp", {}) + end) + end + return M + "#, + ) + .unwrap(); + + let perms = audit_module(dir.path()).unwrap(); + let kinds: Vec = perms.iter().map(|p| p.kind).collect(); + assert!(kinds.contains(&PermissionKind::FsRead)); + assert!(kinds.contains(&PermissionKind::Widget)); + assert!(!kinds.contains(&PermissionKind::Exec)); + assert!(!kinds.contains(&PermissionKind::Bluetooth)); + + let fs_perm = perms.iter().find(|p| p.kind == PermissionKind::FsRead).unwrap(); + assert_eq!(fs_perm.path.as_deref(), Some("/sys/class/hwmon/hwmon6/temp1_input")); + } + + #[test] + fn audit_extracts_exec_bin_hint_and_ignores_baseline_calls() { + let dir = TempDir::new().unwrap(); + fs::write( + dir.path().join("init.lua"), + r#" + local M = bread.module({ name = "wallpaper", version = "1.0.0" }) + function M.on_load() + bread.on("bread.monitor.connected", function() + bread.exec("hyprpaper --config /tmp/foo") + end) + bread.log("loaded") + end + return M + "#, + ) + .unwrap(); + + let perms = audit_module(dir.path()).unwrap(); + assert_eq!(perms.len(), 1); + assert_eq!(perms[0].kind, PermissionKind::Exec); + assert_eq!(perms[0].bin.as_deref(), Some("hyprpaper")); + } + + #[test] + fn audit_scans_required_sibling_files_in_module_directory() { + let dir = TempDir::new().unwrap(); + fs::write( + dir.path().join("init.lua"), + r#"local lib = require("./lib"); return bread.module({ name = "m", version = "1.0.0" })"#, + ) + .unwrap(); + fs::write( + dir.path().join("lib.lua"), + r#"return { go = function() bread.bluetooth.power(true) end }"#, + ) + .unwrap(); + + let perms = audit_module(dir.path()).unwrap(); + assert!(perms.iter().any(|p| p.kind == PermissionKind::Bluetooth)); + } + + #[test] + fn render_permissions_toml_produces_pastable_block() { + let perms = vec![ModulePermission { + kind: PermissionKind::Exec, + path: None, + bin: Some("hyprpaper".to_string()), + }]; + let rendered = render_permissions_toml(&perms).unwrap(); + assert!(rendered.contains("[[permissions]]")); + assert!(rendered.contains("type = \"exec\"")); + assert!(rendered.contains("bin = \"hyprpaper\"")); + } +} diff --git a/bread-shared/src/lib.rs b/bread-shared/src/lib.rs index 50a2f25..e6e2610 100644 --- a/bread-shared/src/lib.rs +++ b/bread-shared/src/lib.rs @@ -10,8 +10,11 @@ use serde::{Deserialize, Serialize}; pub mod apps; pub mod glob; +pub mod permissions; pub mod widget; +pub use permissions::{ModulePermission, PermissionKind}; + /// Identifies which adapter produced an event. /// /// The state engine uses this to choose a normalization strategy and the diff --git a/bread-shared/src/permissions.rs b/bread-shared/src/permissions.rs new file mode 100644 index 0000000..ba9ff08 --- /dev/null +++ b/bread-shared/src/permissions.rs @@ -0,0 +1,187 @@ +//! Structured module permission types for the capability-scoped module API. +//! +//! This is the `[[permissions]]` schema for `bread.module.toml`. It is shared +//! between `bread-cli` (which parses/writes the manifest on `bread modules +//! install`/`audit`) and `breadd` (which reads the same manifest to build a +//! capability-scoped Lua environment for third-party modules) so the two +//! never drift on what a permission "type" string means — see +//! `Documentation.md`'s "Capability-scoped modules" section for the full +//! baseline-vs-gated taxonomy this enum encodes. + +use serde::{Deserialize, Serialize}; + +/// One `[[permissions]]` entry in a module's `bread.module.toml`, e.g.: +/// +/// ```toml +/// [[permissions]] +/// type = "fs.read" +/// path = "~/Wallpapers" +/// ``` +/// +/// `path`/`bin` are optional scoping metadata (a filesystem path prefix, a +/// state-tree path, or a binary name). **They are not enforced by the +/// in-process Lua environment scoping `breadd` builds today** — that +/// mechanism only gates *presence* of a `bread.*` binding (a module without +/// `fs.read` sees `bread.fs == nil`, full stop). Recording the scoping +/// metadata now means manifests won't need a second migration when the +/// planned out-of-process module sandboxing workstream lands and actually +/// enforces path/bin matching per call — that enforcement is explicitly out +/// of scope here. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ModulePermission { + #[serde(rename = "type")] + pub kind: PermissionKind, + /// Scoping hint for `fs.read`/`fs.write` (a path prefix) or + /// `state.read`/`state.watch` (a dotted state-tree path, e.g. + /// `"monitors"`). Advisory only — see the struct-level doc. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path: Option, + /// Scoping hint for `exec` (the binary name the module intends to run, + /// e.g. `"hyprpaper"`). Advisory only — see the struct-level doc. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bin: Option, +} + +/// The permission taxonomy covering every capability-gated `bread.*` +/// binding. +/// +/// Not covered here because they're **baseline** (always available to every +/// module, gated or not — no real side effect, or a side effect a module +/// can't function at all without): `bread.on`/`once`/`filter`/`off`/`emit` +/// (event subscription is how a module does anything), `bread.after`/ +/// `every`/`cancel` (timers), `bread.json` (pure decode), `bread.module` +/// (required just to register), `bread.log`/`warn`/`error` (diagnostics), +/// `bread.debounce`/`spawn`/`wait`/`wait_any`/`wait_all`/`workflow` (pure +/// Lua sugar built entirely on top of the baseline primitives above). +/// +/// Gated because they touch the filesystem, spawn processes, control +/// hardware, or otherwise have a real side effect: +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub enum PermissionKind { + /// `bread.state.get`/`.monitors`/`.active_workspace`/`.active_window`/ + /// `.devices`/`.power`/`.network`/`.profile` — read-only snapshots of + /// daemon-maintained runtime state. + #[serde(rename = "state.read")] + StateRead, + /// `bread.state.watch` — a standing subscription to state changes, + /// gated separately from `state.read` since a long-lived watch is a + /// more persistent capability than a one-off read. + #[serde(rename = "state.watch")] + StateWatch, + /// `bread.profile.activate` — switches the daemon's system-wide active + /// profile, a real cross-module side effect. + #[serde(rename = "profile.activate")] + ProfileActivate, + /// `bread.exec` and `bread.exec_capture` — spawns an arbitrary shell + /// command. + #[serde(rename = "exec")] + Exec, + /// `bread.notify` — sends a desktop notification. + #[serde(rename = "notify")] + Notify, + /// `bread.machine.name`/`.tags`/`.has_tag` — reads hostname/tags, + /// including an optional on-disk `sync.toml`. + #[serde(rename = "machine")] + Machine, + /// `bread.hyprland.*` — compositor IPC (dispatch/keyword/eval read and + /// control the running Hyprland session). + #[serde(rename = "hyprland")] + Hyprland, + /// `bread.widget.*` — registers/updates/removes a rendered widget in a + /// sibling `bread*` app (breadbar). + #[serde(rename = "widget")] + Widget, + /// `bread.fs.read`/`.exists`/`.readlink`/`.expand` — read-only + /// filesystem access. + #[serde(rename = "fs.read")] + FsRead, + /// `bread.fs.write` — filesystem writes. + #[serde(rename = "fs.write")] + FsWrite, + /// `bread.bluetooth.*` — BlueZ control (power/connect/disconnect/scan). + #[serde(rename = "bluetooth")] + Bluetooth, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn permission_round_trips_through_toml_with_dotted_type_names() { + let toml_src = r#" + type = "fs.read" + path = "~/Wallpapers" + "#; + let perm: ModulePermission = toml::from_str(toml_src).unwrap(); + assert_eq!(perm.kind, PermissionKind::FsRead); + assert_eq!(perm.path.as_deref(), Some("~/Wallpapers")); + assert_eq!(perm.bin, None); + } + + #[test] + fn exec_permission_with_bin_round_trips() { + let toml_src = r#" + type = "exec" + bin = "hyprpaper" + "#; + let perm: ModulePermission = toml::from_str(toml_src).unwrap(); + assert_eq!(perm.kind, PermissionKind::Exec); + assert_eq!(perm.bin.as_deref(), Some("hyprpaper")); + } + + #[test] + fn permission_list_round_trips_as_array_of_tables() { + #[derive(Serialize, Deserialize)] + struct Wrapper { + #[serde(default)] + permissions: Option>, + } + + let w = Wrapper { + permissions: Some(vec![ + ModulePermission { + kind: PermissionKind::StateRead, + path: Some("monitors".to_string()), + bin: None, + }, + ModulePermission { + kind: PermissionKind::Exec, + path: None, + bin: Some("hyprpaper".to_string()), + }, + ]), + }; + let out = toml::to_string_pretty(&w).unwrap(); + assert!(out.contains("[[permissions]]")); + assert!(out.contains("type = \"state.read\"")); + assert!(out.contains("type = \"exec\"")); + + let back: Wrapper = toml::from_str(&out).unwrap(); + assert_eq!(back.permissions.unwrap().len(), 2); + } + + #[test] + fn missing_permissions_field_deserializes_to_none() { + #[derive(Serialize, Deserialize)] + struct Wrapper { + #[serde(default)] + permissions: Option>, + name: String, + } + let w: Wrapper = toml::from_str("name = \"x\"\n").unwrap(); + assert!(w.permissions.is_none()); + } + + #[test] + fn explicit_empty_permissions_deserializes_to_some_empty() { + #[derive(Serialize, Deserialize)] + struct Wrapper { + #[serde(default)] + permissions: Option>, + name: String, + } + let w: Wrapper = toml::from_str("name = \"x\"\npermissions = []\n").unwrap(); + assert_eq!(w.permissions, Some(vec![])); + } +} diff --git a/breadd/src/core/state_engine.rs b/breadd/src/core/state_engine.rs index a88fd70..5a45f48 100644 --- a/breadd/src/core/state_engine.rs +++ b/breadd/src/core/state_engine.rs @@ -44,6 +44,7 @@ pub enum StateCommand { status: ModuleLoadState, last_error: Option, builtin: bool, + ungated: bool, }, SetProfile { name: String, @@ -128,12 +129,31 @@ impl StateHandle { status: ModuleLoadState, last_error: Option, builtin: bool, + ) { + self.set_module_status_ex(name, status, last_error, builtin, false); + } + + /// Same as [`set_module_status`](Self::set_module_status) but also + /// records whether the module is running with full, ungated `bread.*` + /// access (no `permissions` declared in its manifest). Kept as a + /// separate method rather than changing `set_module_status`'s signature + /// everywhere so call sites that don't yet know the answer (load + /// errors, disabled modules, etc.) don't have to thread a meaningless + /// value through. + pub fn set_module_status_ex( + &self, + name: String, + status: ModuleLoadState, + last_error: Option, + builtin: bool, + ungated: bool, ) { let _ = self.command_tx.send(StateCommand::SetModuleStatus { name, status, last_error, builtin, + ungated, }); } @@ -303,18 +323,21 @@ async fn handle_command( status, last_error, builtin, + ungated, } => { let mut guard = state.write().await; if let Some(existing) = guard.modules.iter_mut().find(|m| m.name == name) { existing.status = status; existing.last_error = last_error; existing.builtin = builtin; + existing.ungated = ungated; } else { guard.modules.push(crate::core::types::ModuleStatus { name, status, last_error, builtin, + ungated, store: HashMap::new(), }); } diff --git a/breadd/src/core/types.rs b/breadd/src/core/types.rs index 93db94d..ae58223 100644 --- a/breadd/src/core/types.rs +++ b/breadd/src/core/types.rs @@ -123,6 +123,15 @@ pub struct ModuleStatus { pub builtin: bool, #[serde(default)] pub store: HashMap, + /// `true` when this is a third-party module running with full, ungated + /// `bread.*` access because its `bread.module.toml` declares no + /// `permissions` at all (or the module has no manifest on disk). Always + /// `false` for builtin modules, which are never subject to capability + /// scoping in the first place — see the "Capability-scoped modules" + /// section of `Documentation.md`. `bread doctor` surfaces this as a + /// warning so an ungated module doesn't stay invisible forever. + #[serde(default)] + pub ungated: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/breadd/src/ipc/mod.rs b/breadd/src/ipc/mod.rs index e132770..10cdfdf 100644 --- a/breadd/src/ipc/mod.rs +++ b/breadd/src/ipc/mod.rs @@ -27,7 +27,7 @@ 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.4.0"; +const API_VERSION: &str = "1.5.0"; #[derive(Clone)] pub struct Server { diff --git a/breadd/src/lua/mod.rs b/breadd/src/lua/mod.rs index edbf437..7b02975 100644 --- a/breadd/src/lua/mod.rs +++ b/breadd/src/lua/mod.rs @@ -9,7 +9,7 @@ use std::time::Duration; use anyhow::{anyhow, Result}; use bread_shared::widget::{WidgetNode, WidgetPlacement, WidgetSpec}; -use bread_shared::{AdapterSource, BreadEvent}; +use bread_shared::{AdapterSource, BreadEvent, ModulePermission, PermissionKind}; use mlua::{Error as LuaError, Function, Lua, LuaSerdeExt, RegistryKey, Table, Value}; use serde::{Deserialize, Serialize}; use serde_json::Value as JsonValue; @@ -197,6 +197,16 @@ struct ModuleDecl { path: PathBuf, source: Option<&'static str>, builtin: bool, + /// Declared `[[permissions]]` from this module's `bread.module.toml`, + /// read from disk alongside `path` for non-builtin modules (`None` for + /// builtins, which never go through manifest-based scoping at all). + /// + /// `None` here also covers the third-party, no-manifest-at-all and + /// manifest-with-no-permissions-key cases — both mean "not declared", + /// which `load_scoped_lua_file` treats as full, ungated access for + /// backward compatibility (see `Documentation.md`). `Some(vec![])` is a + /// deliberate "baseline only" declaration and is scoped down for real. + permissions: Option>, } struct ModuleInfo { @@ -1349,7 +1359,19 @@ impl LuaEngine { } match self.scan_module_decl(&path) { - Ok(decl) => decls.push(decl), + Ok(mut decl) => { + // Manifest lives beside the module's entry file + // (bread-cli's install_from_local always writes + // //{bread.module.toml,init.lua} — + // see bread-cli/src/modules_mgmt.rs). A hand-authored + // flat file with no sibling manifest (e.g. the + // "Your first module" walkthrough's modules/hello.lua) + // has no manifest to find at all, which read_module_permissions + // reports the same way as an existing-but-permissions-less + // one: None, i.e. full ungated backward-compat access. + decl.permissions = read_module_permissions(&path); + decls.push(decl); + } Err(err) => { self.state_handle.set_module_status( name, @@ -1378,21 +1400,28 @@ impl LuaEngine { let mut load_order = Vec::new(); for decl in ordered { load_order.push(decl.name.clone()); + // Static per-decl (not per-status-transition) property: whether + // this module is running with full, ungated bread.* access. Only + // ever true for a non-builtin module with no declared + // permissions — see ModuleDecl::permissions' doc comment. + let ungated = !decl.builtin && decl.permissions.is_none(); match self.load_module(&decl) { Ok(()) => { - self.state_handle.set_module_status( + self.state_handle.set_module_status_ex( decl.name.clone(), ModuleLoadState::Loaded, None, decl.builtin, + ungated, ); } Err(err) => { - self.state_handle.set_module_status( + self.state_handle.set_module_status_ex( decl.name.clone(), ModuleLoadState::LoadError, Some(err.to_string()), decl.builtin, + ungated, ); } } @@ -1406,9 +1435,13 @@ impl LuaEngine { fn load_module(&self, decl: &ModuleDecl) -> Result<()> { 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 { - self.load_lua_file(&decl.path, &decl.name, decl.builtin) + // 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()) }; self.set_current_module(None); result?; @@ -1420,6 +1453,9 @@ impl LuaEngine { self.run_on_load(&decl.name) } + /// 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`]. fn load_lua_file(&self, path: &Path, module_name: &str, builtin: bool) -> Result<()> { if !path.exists() { warn!(path = %path.display(), "lua file does not exist; skipping"); @@ -1440,6 +1476,207 @@ impl LuaEngine { Ok(()) } + /// Load a third-party module's `.lua` file, giving its chunk a + /// capability-scoped `_ENV` instead of the real shared globals. + /// + /// `permissions: None` means the module's manifest declared no + /// `permissions` at all (no manifest on disk, or a manifest predating + /// this field) — backward compat: full, ungated access, identical to + /// `load_lua_file`. `Some(perms)` (including `Some(&[])`) builds a fresh + /// `bread` table containing only the baseline bindings plus whatever + /// `perms` grants, and sets it as the chunk's environment. See + /// `build_scoped_env` for how `require`/stdlib stay reachable. + fn load_scoped_lua_file( + &self, + path: &Path, + module_name: &str, + permissions: Option<&[ModulePermission]>, + ) -> Result<()> { + if !path.exists() { + warn!(path = %path.display(), "lua file does not exist; skipping"); + self.state_handle.set_module_status( + module_name.to_string(), + ModuleLoadState::NotFound, + None, + false, + ); + return Ok(()); + } + + let src = fs::read_to_string(path)?; + let chunk = self.lua.load(&src).set_name(path.to_string_lossy().as_ref()); + + match permissions { + None => { + // No manifest / no permissions declared: today's behavior, + // unchanged. Do NOT call set_environment here at all (rather + // than passing globals() explicitly) so this stays + // byte-for-byte the same code path load_lua_file already + // uses and has always used. + chunk.exec()?; + } + Some(perms) => { + let env = self.build_scoped_env(perms)?; + chunk.set_environment(env).exec()?; + } + } + Ok(()) + } + + /// Build a fresh `_ENV` table for a capability-scoped module chunk. + /// + /// Shape: a table whose own `bread` key is a *new* table containing only + /// the baseline bindings (event subscription, timers, json, module/ + /// store, logging, and the pure-Lua sugar built on top of those — + /// debounce/spawn/wait*/workflow) plus whichever gated namespaces + /// `perms` grants (fs, exec, notify, machine, hyprland, widget, + /// bluetooth, state, profile — split at the granularity the manifest + /// schema exposes, e.g. `fs.read` without `fs.write` yields a `bread.fs` + /// table with `.read`/`.exists`/`.readlink`/`.expand` but no `.write`). + /// A permission that isn't granted means the corresponding key is + /// genuinely absent (`bread.fs == nil`), not present-but-erroring. + /// + /// Everything else — `pairs`, `string`, `table`, `math`, `pcall`, + /// `coroutine`, `require`, `package`, ... — is reached through a + /// metatable `__index` that falls back to the real global table, so + /// `require("bread.devices")` still works: `require` is the real global + /// function operating on the real global `package.loaded`, which + /// already contains `bread.devices`'s module table by the time any + /// third-party module loads (builtins load first). That module table's + /// own methods (`devices.on()` etc.) were themselves defined while + /// `bread.devices` was loaded unscoped, so they close over the *real* + /// `bread` table as an upvalue — Lua closures capture their defining + /// environment lexically, not the caller's — which is exactly why + /// `require("bread.devices")` keeps working from inside a scoped module + /// with no special-casing needed here. + /// + /// One deliberate hardening step beyond that: `_G` is explicitly + /// rebound to point at this same scoped table (self-referentially, the + /// same way stock Lua's base library self-references the real global + /// table under `_G`). Without that, `local G = _G; G.bread.fs...` would + /// walk straight past the whole mechanism, since `_G` is just an + /// ordinary global (not magic in Lua 5.2+) and would otherwise resolve + /// through the `__index` fallback to the *real* globals table. + /// + /// What this does **not** do: strip `os`/`io`/`debug` from the + /// fallback. Those remain reachable from a scoped module exactly as + /// they are from an unscoped one — `os.execute`/`io.open` bypass + /// `bread.exec`/`bread.fs` gating entirely if a module chooses to use + /// them directly. This mechanism gates the documented `bread.*` API + /// surface (so a well-behaved module naturally degrades, and an + /// accidental over-reach is caught), it is not a hard security boundary + /// against a deliberately malicious script — that's what the + /// out-of-process module sandboxing workstream this manifest schema + /// exists for is for. + fn build_scoped_env(&self, perms: &[ModulePermission]) -> Result> { + let globals = self.lua.globals(); + let real_bread: Table = globals.get("bread")?; + + let granted: HashSet = perms.iter().map(|p| p.kind).collect(); + + let scoped_bread = self.lua.create_table()?; + + // Baseline — always available, no manifest entry required. + const BASELINE_KEYS: &[&str] = &[ + "on", "once", "filter", "off", "emit", "after", "every", "cancel", "json", "module", + "log", "warn", "error", "debounce", "spawn", "wait", "wait_any", "wait_all", + "workflow", + ]; + for key in BASELINE_KEYS { + let v: Value = real_bread.get(*key)?; + if !matches!(v, Value::Nil) { + scoped_bread.set(*key, v)?; + } + } + + if granted.contains(&PermissionKind::StateRead) || granted.contains(&PermissionKind::StateWatch) + { + let real_state: Table = real_bread.get("state")?; + let scoped_state = self.lua.create_table()?; + if granted.contains(&PermissionKind::StateRead) { + for key in [ + "get", + "monitors", + "active_workspace", + "active_window", + "devices", + "power", + "network", + "profile", + ] { + let v: Value = real_state.get(key)?; + scoped_state.set(key, v)?; + } + } + if granted.contains(&PermissionKind::StateWatch) { + let v: Value = real_state.get("watch")?; + scoped_state.set("watch", v)?; + } + scoped_bread.set("state", scoped_state)?; + } + + if granted.contains(&PermissionKind::ProfileActivate) { + let v: Value = real_bread.get("profile")?; + scoped_bread.set("profile", v)?; + } + + if granted.contains(&PermissionKind::Exec) { + let exec: Value = real_bread.get("exec")?; + scoped_bread.set("exec", exec)?; + let exec_capture: Value = real_bread.get("exec_capture")?; + scoped_bread.set("exec_capture", exec_capture)?; + } + + if granted.contains(&PermissionKind::Notify) { + let v: Value = real_bread.get("notify")?; + scoped_bread.set("notify", v)?; + } + + if granted.contains(&PermissionKind::Machine) { + let v: Value = real_bread.get("machine")?; + scoped_bread.set("machine", v)?; + } + + if granted.contains(&PermissionKind::Hyprland) { + let v: Value = real_bread.get("hyprland")?; + scoped_bread.set("hyprland", v)?; + } + + if granted.contains(&PermissionKind::Widget) { + let v: Value = real_bread.get("widget")?; + scoped_bread.set("widget", v)?; + } + + if granted.contains(&PermissionKind::Bluetooth) { + let v: Value = real_bread.get("bluetooth")?; + scoped_bread.set("bluetooth", v)?; + } + + if granted.contains(&PermissionKind::FsRead) || granted.contains(&PermissionKind::FsWrite) { + let real_fs: Table = real_bread.get("fs")?; + let scoped_fs = self.lua.create_table()?; + if granted.contains(&PermissionKind::FsRead) { + for key in ["read", "exists", "readlink", "expand"] { + let v: Value = real_fs.get(key)?; + scoped_fs.set(key, v)?; + } + } + if granted.contains(&PermissionKind::FsWrite) { + let v: Value = real_fs.get("write")?; + scoped_fs.set("write", v)?; + } + scoped_bread.set("fs", scoped_fs)?; + } + + let env = self.lua.create_table()?; + let mt = self.lua.create_table()?; + mt.set("__index", self.lua.globals())?; + env.set_metatable(Some(mt)); + env.set("bread", scoped_bread)?; + env.set("_G", env.clone())?; + Ok(env) + } + fn load_lua_source(&self, source: &str, module_name: &str) -> Result<()> { self.lua .load(source) @@ -1575,11 +1812,12 @@ impl LuaEngine { if let Err(err) = result { error!(module = %name, error = %err, "module on_reload failed"); let builtin = self.module_is_builtin(&name); - self.state_handle.set_module_status( + self.state_handle.set_module_status_ex( name.to_string(), ModuleLoadState::Degraded, Some(err.to_string()), builtin, + self.module_ungated(&name), ); } } @@ -1600,11 +1838,12 @@ impl LuaEngine { if let Err(err) = result { error!(module = %name, error = %err, "module on_unload failed"); let builtin = self.module_is_builtin(&name); - self.state_handle.set_module_status( + self.state_handle.set_module_status_ex( name.to_string(), ModuleLoadState::Degraded, Some(err.to_string()), builtin, + self.module_ungated(&name), ); } } @@ -1624,11 +1863,12 @@ impl LuaEngine { message: err.to_string(), }); } - self.state_handle.set_module_status( + self.state_handle.set_module_status_ex( module.to_string(), ModuleLoadState::Degraded, Some(err.to_string()), builtin, + self.module_ungated(module), ); if let Some(hook) = self.get_module_hook(module, "on_error") { match hook.call::<_, bool>(err.to_string()) { @@ -1672,6 +1912,21 @@ impl LuaEngine { .unwrap_or(false) } + /// Whether `name` is a third-party module running with full, ungated + /// `bread.*` access (no `permissions` declared). See + /// `ModuleDecl::permissions`'s doc comment for exactly what "declared" + /// means. Always `false` for builtins and for unknown module names. + fn module_ungated(&self, name: &str) -> bool { + self.module_decls + .lock() + .ok() + .and_then(|map| { + map.get(name) + .map(|d| !d.builtin && d.permissions.is_none()) + }) + .unwrap_or(false) + } + fn set_current_module(&self, name: Option) { if let Ok(mut guard) = self.current_module.lock() { *guard = name; @@ -1796,6 +2051,10 @@ impl LuaEngine { path: module_path.clone(), source: None, builtin: false, + // Populated afterwards by the caller (load_init_and_modules), + // which reads bread.module.toml from disk — scan_module_decl + // only cares about the bread.module({...}) declaration itself. + permissions: None, }); Err(LuaError::RuntimeError(MODULE_DECL_ABORT.to_string())) })?; @@ -2494,6 +2753,47 @@ fn is_lib_path(module_root: &Path, path: &Path) -> bool { .unwrap_or(false) } +/// Read the `permissions` declared in the `bread.module.toml` manifest +/// sibling to a third-party module's entry file, if any. +/// +/// `bread-cli`'s `install_from_local` always lays a module out as +/// `//{bread.module.toml,init.lua,...}` (see +/// `bread-cli/src/modules_mgmt.rs`), so the manifest is always the entry +/// file's parent directory + `bread.module.toml`. Returns `None` — meaning +/// "not declared", handled as full ungated backward-compat access by +/// `load_scoped_lua_file` — whenever: the module has no directory-level +/// manifest at all (a hand-authored flat file, e.g. the "Your first +/// module" walkthrough's `modules/hello.lua`); the manifest exists but has +/// no `permissions` key; or the manifest fails to parse (logged, not +/// treated as a load error — a broken manifest shouldn't also break the +/// module load path it's unrelated to). +fn read_module_permissions(module_file: &Path) -> Option> { + #[derive(serde::Deserialize)] + struct PermissionsOnly { + #[serde(default)] + permissions: Option>, + } + + let manifest_path = module_file.parent()?.join("bread.module.toml"); + if !manifest_path.exists() { + return None; + } + let raw = match fs::read_to_string(&manifest_path) { + Ok(raw) => raw, + Err(err) => { + warn!(path = %manifest_path.display(), error = %err, "failed to read bread.module.toml"); + return None; + } + }; + match toml::from_str::(&raw) { + Ok(parsed) => parsed.permissions, + Err(err) => { + warn!(path = %manifest_path.display(), error = %err, "failed to parse bread.module.toml; treating as no permissions declared"); + None + } + } +} + /// `lua.to_value()`'s default `Options` map JSON null / Rust `Option::None` /// to a distinct `lua.null()` sentinel rather than real Lua `nil`, to /// preserve JSON round-trip fidelity — but bread never round-trips a Lua @@ -2585,6 +2885,10 @@ fn module_store_set( status: ModuleLoadState::Loaded, last_error: None, builtin: false, + // Placeholder until the real load-time status (with the correct + // ungated value) lands via set_module_status_ex; this fallback only + // fires if a module's own store is written before that happens. + ungated: false, store, }); } @@ -2916,6 +3220,10 @@ fn builtin_module_decls(disabled: &HashSet) -> Vec { path: PathBuf::from(format!("")), source: Some(source), builtin: true, + // Builtins never go through manifest-based scoping (or the + // "ungated" doctor warning) — they always get the full ambient + // bread table, by design. + permissions: None, }); } diff --git a/breadd/tests/ipc_integration.rs b/breadd/tests/ipc_integration.rs index 7e7129b..ac752d0 100644 --- a/breadd/tests/ipc_integration.rs +++ b/breadd/tests/ipc_integration.rs @@ -279,6 +279,207 @@ async fn modules_list_returns_array() -> Result<()> { Ok(()) } +// --------------------------------------------------------------------------- +// Capability-scoped module API (Workstream D) +// --------------------------------------------------------------------------- + +/// Core regression test from the capability-manifest report: a third-party +/// module whose manifest grants only `state.read` can call +/// `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. +#[tokio::test] +async fn scoped_module_sees_only_granted_state_read_permission() -> Result<()> { + let manifest = r#" +name = "scoped-test" +version = "1.0.0" +description = "test" +author = "test" +source = "test" +installed_at = "" + +[[permissions]] +type = "state.read" +path = "monitors" +"#; + let module_lua = r#" +local M = bread.module({ name = "scoped-test", version = "1.0.0" }) + +function M.on_load() + 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 + +return M +"#; + + let harness = TestHarness::spawn_with_module("scoped-test", Some(manifest), module_lua)?; + harness.wait_until_ready().await?; + + let modules = harness + .send_request("state.get", json!({"key": "modules"})) + .await?; + let entry = modules + .as_array() + .and_then(|arr| arr.iter().find(|m| m.get("name").and_then(Value::as_str) == Some("scoped-test"))) + .cloned() + .ok_or_else(|| anyhow!("scoped-test module not found in modules state; dump: {modules}"))?; + + assert_eq!( + entry.get("status").and_then(Value::as_str), + Some("loaded"), + "module failed to load: {entry}" + ); + + let store = entry + .get("store") + .ok_or_else(|| anyhow!("no store on module status: {entry}"))?; + assert_eq!(store.get("state_get_ok"), Some(&json!(true))); + assert_eq!( + store.get("fs_present"), + Some(&json!(false)), + "bread.fs must be absent (nil) without an fs.read/fs.write permission" + ); + assert_eq!( + store.get("exec_present"), + Some(&json!(false)), + "bread.exec must be absent (nil) without an exec permission" + ); + assert_eq!(store.get("exec_capture_present"), Some(&json!(false))); + assert_eq!(store.get("bluetooth_present"), Some(&json!(false))); + 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(()) +} + +/// A third-party module installed with no `bread.module.toml` manifest at +/// all (the pre-existing/legacy case) keeps full, unscoped `bread.*` +/// access — but is surfaced as `ungated` in module status, which is exactly +/// what `bread doctor` reads to print its "no permissions declared" +/// warning. +#[tokio::test] +async fn module_with_no_manifest_keeps_full_access_but_is_flagged_ungated() -> Result<()> { + let module_lua = r#" +local M = bread.module({ name = "legacy-test", version = "1.0.0" }) + +function M.on_load() + M.store.set("fs_present", bread.fs ~= nil) + M.store.set("exec_present", bread.exec ~= nil) + M.store.set("bluetooth_present", bread.bluetooth ~= nil) +end + +return M +"#; + + let harness = TestHarness::spawn_with_module("legacy-test", None, module_lua)?; + harness.wait_until_ready().await?; + + let modules = harness + .send_request("state.get", json!({"key": "modules"})) + .await?; + let entry = modules + .as_array() + .and_then(|arr| arr.iter().find(|m| m.get("name").and_then(Value::as_str) == Some("legacy-test"))) + .cloned() + .ok_or_else(|| anyhow!("legacy-test module not found in modules state; dump: {modules}"))?; + + assert_eq!( + entry.get("status").and_then(Value::as_str), + Some("loaded"), + "module failed to load: {entry}" + ); + + let store = entry + .get("store") + .ok_or_else(|| anyhow!("no store on module status: {entry}"))?; + assert_eq!( + store.get("fs_present"), + Some(&json!(true)), + "no manifest declared -> backward compat full access, bread.fs must be present" + ); + assert_eq!(store.get("exec_present"), Some(&json!(true))); + assert_eq!(store.get("bluetooth_present"), Some(&json!(true))); + + assert_eq!( + entry.get("ungated"), + Some(&json!(true)), + "a module with no permissions manifest must be flagged ungated for `bread doctor`" + ); + + harness.shutdown(); + Ok(()) +} + +/// An explicit `permissions = []` (present but empty) is a deliberate +/// "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. +#[tokio::test] +async fn explicit_empty_permissions_is_scoped_but_not_flagged_ungated() -> Result<()> { + let manifest = r#" +name = "empty-perms-test" +version = "1.0.0" +description = "test" +author = "test" +source = "test" +installed_at = "" +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 + +return M +"#; + + let harness = TestHarness::spawn_with_module("empty-perms-test", Some(manifest), module_lua)?; + harness.wait_until_ready().await?; + + let modules = harness + .send_request("state.get", json!({"key": "modules"})) + .await?; + let entry = modules + .as_array() + .and_then(|arr| { + arr.iter() + .find(|m| m.get("name").and_then(Value::as_str) == Some("empty-perms-test")) + }) + .cloned() + .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'" + ); + + harness.shutdown(); + Ok(()) +} + #[tokio::test] async fn modules_reload_succeeds() -> Result<()> { let harness = TestHarness::spawn()?; @@ -688,6 +889,82 @@ 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, + }) + } + + /// Like `spawn_with_init`, but also installs one third-party, + /// directory-based module (`//{bread.module.toml, + /// init.lua}`) — the same on-disk shape `bread modules install` + /// produces — before starting the daemon. `manifest_toml` is written + /// verbatim as `bread.module.toml`; pass `None` to install the module + /// with no manifest file at all (the legacy/backward-compat case). + fn spawn_with_module( + module_name: &str, + manifest_toml: Option<&str>, + module_init_lua: &str, + ) -> Result { + let temp = tempfile::tempdir()?; + let runtime_dir = temp.path().join("runtime"); + let config_home = temp.path().join("config"); + let home = temp.path().join("home"); + fs::create_dir_all(&runtime_dir)?; + fs::create_dir_all(&config_home)?; + fs::create_dir_all(&home)?; + + let bread_cfg = config_home.join("bread"); + let module_dir = bread_cfg.join("modules").join(module_name); + fs::create_dir_all(&module_dir)?; + + fs::write( + bread_cfg.join("init.lua"), + "bread.on('bread.system.startup', function() end)\n", + )?; + if let Some(manifest) = manifest_toml { + fs::write(module_dir.join("bread.module.toml"), manifest)?; + } + fs::write(module_dir.join("init.lua"), module_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 "#, diff --git a/examples/modules/README.md b/examples/modules/README.md index 0dc9439..2f01cd9 100644 --- a/examples/modules/README.md +++ b/examples/modules/README.md @@ -14,6 +14,22 @@ cp low-battery-warning.lua ~/.config/bread/modules/ bread reload ``` +`cpu-temp-widget/` is a directory (not a flat file) with a `bread.module.toml` +manifest declaring its `[[permissions]]` — see +[Capability-scoped modules](../../Documentation.md#capability-scoped-modules-since-v15). +Either copy the whole directory into `~/.config/bread/modules/`, or install it +properly so the manifest travels with it: + +```sh +bread modules install ./cpu-temp-widget +bread reload +``` + +The other modules here are flat files with no manifest — they load exactly +like today, with full, ungated `bread.*` access (`bread doctor` will note +that). Run `bread modules audit ` on an installed one any time to get a +suggested `[[permissions]]` block for its own `bread.module.toml`. + ## Modules | File | What it does | Config needed | @@ -22,7 +38,7 @@ bread reload | `pause-media-on-headphone-unplug.lua` | Runs `playerctl pause` when a headphone/earbud device disconnects. | none (needs `playerctl`) | | `dock-monitors.lua` | Applies a multi-monitor layout when an external display connects, reverts when removed. | edit output names/resolutions | | `active-window-widget.lua` | Shows the focused window next to the workspace pills in breadbar, via `bread.widget` + `bread.state.watch`. | none | -| `cpu-temp-widget.lua` | Live CPU temperature readout in breadbar's stats area, via `bread.widget` + `bread.fs.read` on a timer. | edit `TEMP_PATH` for your hwmon layout | +| `cpu-temp-widget/` | Live CPU temperature readout in breadbar's stats area, via `bread.widget` + `bread.fs.read` on a timer. Directory module with a `bread.module.toml` declaring `fs.read` + `widget` — the permission-manifest worked example. | edit `TEMP_PATH` for your hwmon layout | | `bluetooth-toggle-widget.lua` | One-click Bluetooth power toggle in breadbar's tray, via `bread.widget` + a click handler. | none | | `focus-mode-widget.lua` | Click-to-toggle "Focus" profile that mutes audio; a widget as an action launcher, not just a readout, and stays in sync with profile changes triggered elsewhere. | none (needs `wpctl`) | | `workflow-status-widget.lua` | Surfaces `bread.workflow.list()` in breadbar's tray — shows whichever workflow (e.g. `dock-workflow.lua`, below) is currently running or failed, hidden otherwise. | none | diff --git a/examples/modules/cpu-temp-widget/bread.module.toml b/examples/modules/cpu-temp-widget/bread.module.toml new file mode 100644 index 0000000..74673f3 --- /dev/null +++ b/examples/modules/cpu-temp-widget/bread.module.toml @@ -0,0 +1,21 @@ +name = "cpu-temp-widget" +version = "1.0.0" +description = "Live CPU package temperature widget, read from hwmon sysfs" +author = "bread" +source = "local" +installed_at = "" + +# This module only ever calls bread.fs.read (never .write) and +# bread.widget.register/update — declaring exactly that is what makes +# bread.exec, bread.bluetooth, bread.hyprland, bread.machine, bread.notify, +# and bread.state all genuinely absent (nil) from its `bread` table at +# runtime, rather than merely unused. `source`/`installed_at` above get +# overwritten by `bread modules install`; they're placeholders for the +# drop-in/copy-paste path. + +[[permissions]] +type = "fs.read" +path = "/sys/class/hwmon" + +[[permissions]] +type = "widget" diff --git a/examples/modules/cpu-temp-widget.lua b/examples/modules/cpu-temp-widget/init.lua similarity index 73% rename from examples/modules/cpu-temp-widget.lua rename to examples/modules/cpu-temp-widget/init.lua index 5399483..2c74d6e 100644 --- a/examples/modules/cpu-temp-widget.lua +++ b/examples/modules/cpu-temp-widget/init.lua @@ -9,11 +9,20 @@ -- flags when something's hot — no CSS, no guessing which class names the -- rendering app happens to define. -- --- Drop-in: copy into ~/.config/bread/modules/. TEMP_PATH is specific to --- this machine (AMD, k10temp) — find yours with: +-- Drop-in: copy the whole cpu-temp-widget/ directory into +-- ~/.config/bread/modules/ (or `bread modules install path/to/this/dir`). +-- TEMP_PATH is specific to this machine (AMD, k10temp) — find yours with: -- grep -l k10temp /sys/class/hwmon/hwmon*/name -- and adjust below; a missing/unreadable path just shows "—" rather than -- erroring, since bread.fs.read returns nil (not an error) for that case. +-- +-- This is also the worked example for the capability-manifest permission +-- system (Documentation.md's "Capability-scoped modules" section): see the +-- sibling bread.module.toml. It declares exactly the two permissions this +-- module actually uses — `fs.read` (bread.fs.read, read-only) and `widget` +-- (bread.widget.register/update) — nothing else. If you install it that +-- way, bread.exec/bread.bluetooth/bread.hyprland/etc. are all genuinely +-- absent (nil) from this module's `bread` table, not just unused. local M = bread.module({ name = "cpu-temp-widget", version = "1.0.0" })