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

@ -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(),
});
}

View file

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