Add capability-scoped module API (Workstream D)
ModuleManifest gains a structured [[permissions]] field (bread-shared's
new ModulePermission/PermissionKind, shared between bread-cli and breadd
so the two never drift on what a permission "type" string means).
breadd now gives every third-party module's Lua chunk a scoped _ENV
instead of the shared global table: load_scoped_lua_file builds a fresh
`bread` table containing only baseline bindings (event subscription,
timers, json, module/store, logging, and the pure-Lua sugar built on top
of those) plus whatever the manifest's permissions grant, with a
metatable __index falling back to the real globals for everything else
(stdlib, require/package - so require("bread.devices") keeps working,
since builtins load unscoped and their closures capture that environment
lexically regardless of the caller's). _G is explicitly rebound to the
scoped table itself to close the obvious escape hatch. A module with no
manifest, or a manifest with no permissions key, keeps full ambient
access unchanged (today's behavior) but is now tracked as `ungated` in
module status and surfaced by `bread doctor`. An explicit `permissions =
[]` is scoped for real but not flagged, since that's a deliberate
declaration.
Adds `bread modules audit <name>`: a best-effort text scan of a module's
.lua files suggesting a [[permissions]] block to paste into its manifest.
Converts examples/modules/cpu-temp-widget.lua into a directory module
with a worked bread.module.toml (fs.read + widget) as the reference
example. Documentation.md gets a new "Capability-scoped modules" section
covering the taxonomy, the require()/closure mechanism, and an explicit
note that path/bin scoping is recorded but not yet enforced per-call -
that's the out-of-process module sandboxing workstream this manifest
schema is laid down for. API_VERSION bumped 1.4.0 -> 1.5.0.
This commit is contained in:
parent
96639516b1
commit
6841163620
13 changed files with 1329 additions and 14 deletions
150
Documentation.md
150
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 <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
|
||||
|
||||
- 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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Vec<ModulePermission>>,
|
||||
}
|
||||
|
||||
/// 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<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\""));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
187
bread-shared/src/permissions.rs
Normal file
187
bread-shared/src/permissions.rs
Normal 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![]));
|
||||
}
|
||||
}
|
||||
|
|
@ -44,6 +44,7 @@ pub enum StateCommand {
|
|||
status: ModuleLoadState,
|
||||
last_error: Option<String>,
|
||||
builtin: bool,
|
||||
ungated: bool,
|
||||
},
|
||||
SetProfile {
|
||||
name: String,
|
||||
|
|
@ -128,12 +129,31 @@ impl StateHandle {
|
|||
status: ModuleLoadState,
|
||||
last_error: Option<String>,
|
||||
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 {
|
||||
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(),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -123,6 +123,15 @@ pub struct ModuleStatus {
|
|||
pub builtin: bool,
|
||||
#[serde(default)]
|
||||
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)]
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<Vec<ModulePermission>>,
|
||||
}
|
||||
|
||||
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
|
||||
// <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) => {
|
||||
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<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<()> {
|
||||
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<String>) {
|
||||
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
|
||||
/// `<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`
|
||||
/// 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<String>) -> Vec<ModuleDecl> {
|
|||
path: PathBuf::from(format!("<builtin:{name}>")),
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (`<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]
|
||||
enabled = false
|
||||
"#,
|
||||
|
|
|
|||
|
|
@ -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 <name>` 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 |
|
||||
|
|
|
|||
21
examples/modules/cpu-temp-widget/bread.module.toml
Normal file
21
examples/modules/cpu-temp-widget/bread.module.toml
Normal 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"
|
||||
|
|
@ -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" })
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue