Merge feature/capability-manifest (Workstream D)

This commit is contained in:
Breadway 2026-08-04 22:40:49 +08:00
commit 450454d164
12 changed files with 1328 additions and 13 deletions

View file

@ -8,6 +8,7 @@
- [Your first module](#your-first-module) - [Your first module](#your-first-module)
- [Run, reload, and watch](#run-reload-and-watch) - [Run, reload, and watch](#run-reload-and-watch)
- [Modules: install and manage](#modules-install-and-manage) - [Modules: install and manage](#modules-install-and-manage)
- [Capability-scoped modules](#capability-scoped-modules-since-v15)
- [Debugging tips](#debugging-tips) - [Debugging tips](#debugging-tips)
- [Dictionary: Lua API](#dictionary-lua-api) - [Dictionary: Lua API](#dictionary-lua-api)
- [Workflows](#workflows-since-v12) - [Workflows](#workflows-since-v12)
@ -146,6 +147,12 @@ Key rules:
- Register subscriptions inside `M.on_load` so they are cleaned up properly on hot reload. - Register subscriptions inside `M.on_load` so they are cleaned up properly on hot reload.
- Use `bread.log` early to verify handlers are firing. - 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 ## Run, reload, and watch
```bash ```bash
@ -176,9 +183,13 @@ bread modules install ~/src/bread-wifi
# List installed modules and their daemon status # List installed modules and their daemon status
bread modules list bread modules list
# Show full manifest for one module # Show full manifest for one module (including its declared permissions)
bread modules info bread-wifi 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 # Remove a module
bread modules remove bread-wifi bread modules remove bread-wifi
bread modules remove bread-wifi --yes # skip confirmation bread modules remove bread-wifi --yes # skip confirmation
@ -193,14 +204,149 @@ description = "WiFi management for Bread"
author = "someuser" author = "someuser"
source = "/home/you/src/bread-wifi" source = "/home/you/src/bread-wifi"
installed_at = "2026-01-01T00:00:00Z" 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 <name>`
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 ## Debugging tips
- Run `bread events` to see live normalized events. - Run `bread events` to see live normalized events.
- Run `bread events --tree` *(Since: v1.5)* to render events as a causality tree instead of a flat stream — events that a Lua handler emitted via `bread.emit()` in reaction to another event are nested underneath it, following the `caused_by` chain (see [Dictionary: Event reference](#dictionary-event-reference)). Useful for untangling "why did this event fire" when several modules chain-react to each other. - Run `bread events --tree` *(Since: v1.5)* to render events as a causality tree instead of a flat stream — events that a Lua handler emitted via `bread.emit()` in reaction to another event are nested underneath it, following the `caused_by` chain (see [Dictionary: Event reference](#dictionary-event-reference)). Useful for untangling "why did this event fire" when several modules chain-react to each other.
- Run `bread state` to see full runtime state as JSON. - 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))`. - Log event payloads with `bread.log(tostring(event.data))`.
- Use `RUST_LOG=debug breadd` for verbose daemon output. - Use `RUST_LOG=debug breadd` for verbose daemon output.

View file

@ -132,6 +132,9 @@ enum ModulesCommand {
List, List,
/// Show full manifest details for a module /// Show full manifest details for a module
Info { name: String }, 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] #[tokio::main]
@ -311,6 +314,55 @@ async fn handle_modules_cmd(cmd: ModulesCommand, socket: &Path) -> Result<()> {
println!("source: {}", m.source); println!("source: {}", m.source);
println!("installed_at: {}", m.installed_at); println!("installed_at: {}", m.installed_at);
println!("status: {}", status); 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(()) Ok(())
@ -677,14 +729,35 @@ fn render_doctor(health: &Value) {
if let Some(modules) = health.get("modules").and_then(Value::as_array) { if let Some(modules) = health.get("modules").and_then(Value::as_array) {
println!(); println!();
println!("modules"); println!("modules");
let mut ungated_count = 0;
for module in modules { for module in modules {
let name = module.get("name").and_then(Value::as_str).unwrap_or("?"); 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 status = module.get("status").and_then(Value::as_str).unwrap_or("?");
let error = module.get("last_error").and_then(Value::as_str); 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); println!(" {:30} {}", name, status);
if let Some(error) = error { if let Some(error) = error {
println!("{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"
);
} }
} }

View file

@ -1,4 +1,5 @@
use anyhow::{bail, Context, Result}; use anyhow::{bail, Context, Result};
use bread_shared::{ModulePermission, PermissionKind};
use chrono::Utc; use chrono::Utc;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::fs; use std::fs;
@ -13,6 +14,16 @@ pub struct ModuleManifest {
pub author: String, pub author: String,
pub source: String, pub source: String,
pub installed_at: 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<Vec<ModulePermission>>,
} }
/// Resolve a module source string to a local directory path. /// Resolve a module source string to a local directory path.
@ -227,3 +238,235 @@ fn copy_dir(src: &Path, dst: &Path) -> Result<()> {
} }
Ok(()) 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<Vec<ModulePermission>> {
let mut found: std::collections::BTreeMap<PermissionKind, ModulePermission> =
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<String> {
#[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<PathBuf>) -> 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.<ident>[.<ident>]` 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<PermissionKind, ModulePermission>) {
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<PermissionKind, ModulePermission>,
) {
let (kind, bin, path): (PermissionKind, Option<String>, Option<String>) = 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<String> {
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<PermissionKind> = 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\""));
}
}

View file

@ -10,8 +10,11 @@ use serde::{Deserialize, Serialize};
pub mod apps; pub mod apps;
pub mod glob; pub mod glob;
pub mod permissions;
pub mod widget; pub mod widget;
pub use permissions::{ModulePermission, PermissionKind};
/// Identifies which adapter produced an event. /// Identifies which adapter produced an event.
/// ///
/// The state engine uses this to choose a normalization strategy and the /// The state engine uses this to choose a normalization strategy and the

View file

@ -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<String>,
/// 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<String>,
}
/// 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<Vec<ModulePermission>>,
}
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<Vec<ModulePermission>>,
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<Vec<ModulePermission>>,
name: String,
}
let w: Wrapper = toml::from_str("name = \"x\"\npermissions = []\n").unwrap();
assert_eq!(w.permissions, Some(vec![]));
}
}

View file

@ -44,6 +44,7 @@ pub enum StateCommand {
status: ModuleLoadState, status: ModuleLoadState,
last_error: Option<String>, last_error: Option<String>,
builtin: bool, builtin: bool,
ungated: bool,
}, },
SetProfile { SetProfile {
name: String, name: String,
@ -128,12 +129,31 @@ impl StateHandle {
status: ModuleLoadState, status: ModuleLoadState,
last_error: Option<String>, last_error: Option<String>,
builtin: bool, 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<String>,
builtin: bool,
ungated: bool,
) { ) {
let _ = self.command_tx.send(StateCommand::SetModuleStatus { let _ = self.command_tx.send(StateCommand::SetModuleStatus {
name, name,
status, status,
last_error, last_error,
builtin, builtin,
ungated,
}); });
} }
@ -303,18 +323,21 @@ async fn handle_command(
status, status,
last_error, last_error,
builtin, builtin,
ungated,
} => { } => {
let mut guard = state.write().await; let mut guard = state.write().await;
if let Some(existing) = guard.modules.iter_mut().find(|m| m.name == name) { if let Some(existing) = guard.modules.iter_mut().find(|m| m.name == name) {
existing.status = status; existing.status = status;
existing.last_error = last_error; existing.last_error = last_error;
existing.builtin = builtin; existing.builtin = builtin;
existing.ungated = ungated;
} else { } else {
guard.modules.push(crate::core::types::ModuleStatus { guard.modules.push(crate::core::types::ModuleStatus {
name, name,
status, status,
last_error, last_error,
builtin, builtin,
ungated,
store: HashMap::new(), store: HashMap::new(),
}); });
} }

View file

@ -123,6 +123,15 @@ pub struct ModuleStatus {
pub builtin: bool, pub builtin: bool,
#[serde(default)] #[serde(default)]
pub store: HashMap<String, Value>, pub store: HashMap<String, Value>,
/// `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)] #[derive(Debug, Clone, Serialize, Deserialize)]

View file

@ -9,7 +9,7 @@ use std::time::Duration;
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use bread_shared::widget::{WidgetNode, WidgetPlacement, WidgetSpec}; 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 mlua::{Error as LuaError, Function, Lua, LuaSerdeExt, RegistryKey, Table, Value};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue; use serde_json::Value as JsonValue;
@ -198,6 +198,16 @@ struct ModuleDecl {
path: PathBuf, path: PathBuf,
source: Option<&'static str>, source: Option<&'static str>,
builtin: bool, 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<Vec<ModulePermission>>,
} }
struct ModuleInfo { struct ModuleInfo {
@ -1441,7 +1451,19 @@ impl LuaEngine {
} }
match self.scan_module_decl(&path) { 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
// <modules_dir>/<name>/{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) => { Err(err) => {
self.state_handle.set_module_status( self.state_handle.set_module_status(
name, name,
@ -1470,21 +1492,28 @@ impl LuaEngine {
let mut load_order = Vec::new(); let mut load_order = Vec::new();
for decl in ordered { for decl in ordered {
load_order.push(decl.name.clone()); 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) { match self.load_module(&decl) {
Ok(()) => { Ok(()) => {
self.state_handle.set_module_status( self.state_handle.set_module_status_ex(
decl.name.clone(), decl.name.clone(),
ModuleLoadState::Loaded, ModuleLoadState::Loaded,
None, None,
decl.builtin, decl.builtin,
ungated,
); );
} }
Err(err) => { Err(err) => {
self.state_handle.set_module_status( self.state_handle.set_module_status_ex(
decl.name.clone(), decl.name.clone(),
ModuleLoadState::LoadError, ModuleLoadState::LoadError,
Some(err.to_string()), Some(err.to_string()),
decl.builtin, decl.builtin,
ungated,
); );
} }
} }
@ -1498,9 +1527,13 @@ impl LuaEngine {
fn load_module(&self, decl: &ModuleDecl) -> Result<()> { fn load_module(&self, decl: &ModuleDecl) -> Result<()> {
self.set_current_module(Some(decl.name.clone())); self.set_current_module(Some(decl.name.clone()));
let result = if let Some(source) = decl.source { 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) self.load_lua_source(source, &decl.name)
} else { } 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); self.set_current_module(None);
result?; result?;
@ -1512,6 +1545,9 @@ impl LuaEngine {
self.run_on_load(&decl.name) 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<()> { fn load_lua_file(&self, path: &Path, module_name: &str, builtin: bool) -> Result<()> {
if !path.exists() { if !path.exists() {
warn!(path = %path.display(), "lua file does not exist; skipping"); warn!(path = %path.display(), "lua file does not exist; skipping");
@ -1532,6 +1568,207 @@ impl LuaEngine {
Ok(()) 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<Table<'_>> {
let globals = self.lua.globals();
let real_bread: Table = globals.get("bread")?;
let granted: HashSet<PermissionKind> = 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<()> { fn load_lua_source(&self, source: &str, module_name: &str) -> Result<()> {
self.lua self.lua
.load(source) .load(source)
@ -1675,11 +1912,12 @@ impl LuaEngine {
if let Err(err) = result { if let Err(err) = result {
error!(module = %name, error = %err, "module on_reload failed"); error!(module = %name, error = %err, "module on_reload failed");
let builtin = self.module_is_builtin(&name); let builtin = self.module_is_builtin(&name);
self.state_handle.set_module_status( self.state_handle.set_module_status_ex(
name.to_string(), name.to_string(),
ModuleLoadState::Degraded, ModuleLoadState::Degraded,
Some(err.to_string()), Some(err.to_string()),
builtin, builtin,
self.module_ungated(&name),
); );
} }
} }
@ -1700,11 +1938,12 @@ impl LuaEngine {
if let Err(err) = result { if let Err(err) = result {
error!(module = %name, error = %err, "module on_unload failed"); error!(module = %name, error = %err, "module on_unload failed");
let builtin = self.module_is_builtin(&name); let builtin = self.module_is_builtin(&name);
self.state_handle.set_module_status( self.state_handle.set_module_status_ex(
name.to_string(), name.to_string(),
ModuleLoadState::Degraded, ModuleLoadState::Degraded,
Some(err.to_string()), Some(err.to_string()),
builtin, builtin,
self.module_ungated(&name),
); );
} }
} }
@ -1724,11 +1963,12 @@ impl LuaEngine {
message: err.to_string(), message: err.to_string(),
}); });
} }
self.state_handle.set_module_status( self.state_handle.set_module_status_ex(
module.to_string(), module.to_string(),
ModuleLoadState::Degraded, ModuleLoadState::Degraded,
Some(err.to_string()), Some(err.to_string()),
builtin, builtin,
self.module_ungated(module),
); );
if let Some(hook) = self.get_module_hook(module, "on_error") { if let Some(hook) = self.get_module_hook(module, "on_error") {
match hook.call::<_, bool>(err.to_string()) { match hook.call::<_, bool>(err.to_string()) {
@ -1772,6 +2012,21 @@ impl LuaEngine {
.unwrap_or(false) .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<String>) { fn set_current_module(&self, name: Option<String>) {
if let Ok(mut guard) = self.current_module.lock() { if let Ok(mut guard) = self.current_module.lock() {
*guard = name; *guard = name;
@ -1908,6 +2163,10 @@ impl LuaEngine {
path: module_path.clone(), path: module_path.clone(),
source: None, source: None,
builtin: false, 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())) Err(LuaError::RuntimeError(MODULE_DECL_ABORT.to_string()))
})?; })?;
@ -2606,6 +2865,47 @@ fn is_lib_path(module_root: &Path, path: &Path) -> bool {
.unwrap_or(false) .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
/// `<modules_dir>/<name>/{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<Vec<ModulePermission>> {
#[derive(serde::Deserialize)]
struct PermissionsOnly {
#[serde(default)]
permissions: Option<Vec<ModulePermission>>,
}
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::<PermissionsOnly>(&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` /// `lua.to_value()`'s default `Options` map JSON null / Rust `Option::None`
/// to a distinct `lua.null()` sentinel rather than real Lua `nil`, to /// to a distinct `lua.null()` sentinel rather than real Lua `nil`, to
/// preserve JSON round-trip fidelity — but bread never round-trips a Lua /// preserve JSON round-trip fidelity — but bread never round-trips a Lua
@ -2697,6 +2997,10 @@ fn module_store_set(
status: ModuleLoadState::Loaded, status: ModuleLoadState::Loaded,
last_error: None, last_error: None,
builtin: false, 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, store,
}); });
} }
@ -3100,6 +3404,10 @@ fn builtin_module_decls(disabled: &HashSet<String>) -> Vec<ModuleDecl> {
path: PathBuf::from(format!("<builtin:{name}>")), path: PathBuf::from(format!("<builtin:{name}>")),
source: Some(source), source: Some(source),
builtin: true, 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,
}); });
} }

View file

@ -429,6 +429,207 @@ async fn modules_list_returns_array() -> Result<()> {
Ok(()) 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] #[tokio::test]
async fn modules_reload_succeeds() -> Result<()> { async fn modules_reload_succeeds() -> Result<()> {
let harness = TestHarness::spawn()?; let harness = TestHarness::spawn()?;
@ -1211,6 +1412,82 @@ enabled = false
[adapters.power] [adapters.power]
enabled = false 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 (`<modules_dir>/<name>/{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<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");
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] [adapters.network]
enabled = false enabled = false
"#, "#,

View file

@ -14,6 +14,22 @@ cp low-battery-warning.lua ~/.config/bread/modules/
bread reload 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 <name>` on an installed one any time to get a
suggested `[[permissions]]` block for its own `bread.module.toml`.
## Modules ## Modules
| File | What it does | Config needed | | 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`) | | `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 | | `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 | | `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 | | `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`) | | `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 | | `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 |

View file

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

View file

@ -9,11 +9,20 @@
-- flags when something's hot — no CSS, no guessing which class names the -- flags when something's hot — no CSS, no guessing which class names the
-- rendering app happens to define. -- rendering app happens to define.
-- --
-- Drop-in: copy into ~/.config/bread/modules/. TEMP_PATH is specific to -- Drop-in: copy the whole cpu-temp-widget/ directory into
-- this machine (AMD, k10temp) — find yours with: -- ~/.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 -- grep -l k10temp /sys/class/hwmon/hwmon*/name
-- and adjust below; a missing/unreadable path just shows "—" rather than -- and adjust below; a missing/unreadable path just shows "—" rather than
-- erroring, since bread.fs.read returns nil (not an error) for that case. -- 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" }) local M = bread.module({ name = "cpu-temp-widget", version = "1.0.0" })