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:
Breadway 2026-08-04 22:24:16 +08:00
parent 96639516b1
commit 6841163620
13 changed files with 1329 additions and 14 deletions

View file

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

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![]));
}
}