homecore_plugins/permissions.rs
1//! Plugin authority / capability isolation (ADR-162, P5).
2//!
3//! Wasmtime already gives a plugin **memory** isolation — it cannot read
4//! another plugin's linear memory. It does NOT, by itself, stop a plugin
5//! from using a host import to write any entity it likes. Before this fix
6//! `hc_state_set` happily let any plugin write `lock.front_door` or
7//! `alarm_control_panel.*`, and the manifest's `homecore_permissions`
8//! claims were parsed but **never consulted** (ADR-161 deferred P5).
9//!
10//! This module adds **authority isolation**: a plugin may only write
11//! entities its manifest declared. The host import consults a
12//! [`PermissionSet`] before applying any state write and returns a typed
13//! error to the guest (it does **not** panic the host) on a violation.
14//!
15//! ## Permission grammar
16//!
17//! Each entry in `homecore_permissions` is one of:
18//!
19//! * a bare entity glob — `"light.*"`, `"light.kitchen"`, `"*"`;
20//! * the explicit capability form `"state:write:<glob>"` (the form the
21//! ADR-128 manifest doc shows), e.g. `"state:write:sensor.*"`.
22//!
23//! A glob supports a single trailing `*` (HA-style domain wildcards:
24//! `light.*` matches every `light` entity) and a leading-or-bare `*`
25//! (`*` = everything). Exact strings match exactly. A plugin with **no**
26//! `state:write` entries can write **nothing** — the secure default.
27
28use crate::manifest::PluginManifest;
29
30/// The set of entity-write permissions a plugin holds, distilled from its
31/// manifest `homecore_permissions` at load time.
32#[derive(Debug, Clone, Default)]
33pub struct PermissionSet {
34 /// Glob patterns the plugin may write (state:write authority). Empty =
35 /// the plugin may write nothing.
36 write_globs: Vec<String>,
37}
38
39impl PermissionSet {
40 /// Build a permission set from a manifest's `homecore_permissions`.
41 ///
42 /// Only `state:write` authority is modelled here (the host import this
43 /// gates is `hc_state_set`). A bare glob (`"light.*"`) is treated as a
44 /// write grant; the explicit `"state:write:<glob>"` form is also
45 /// accepted. Other capability strings (`state:read:*`, future verbs)
46 /// are ignored for write-gating purposes.
47 pub fn from_manifest(manifest: &PluginManifest) -> Self {
48 let mut write_globs = Vec::new();
49 for claim in &manifest.homecore_permissions {
50 let claim = claim.trim();
51 if let Some(glob) = claim.strip_prefix("state:write:") {
52 write_globs.push(glob.trim().to_string());
53 } else if claim.starts_with("state:read:") {
54 // read authority — not relevant to write gating.
55 } else if !claim.is_empty() {
56 // Bare glob — treat as a write grant.
57 write_globs.push(claim.to_string());
58 }
59 }
60 Self { write_globs }
61 }
62
63 /// An all-allowing set (equivalent to a `"*"` grant). Used by the
64 /// legacy permission-free `WasmtimeRuntime::load_wasm` path so existing
65 /// callers/tests that do not supply a manifest keep working; the
66 /// permission-gated path uses [`Self::from_manifest`].
67 pub fn allow_all() -> Self {
68 Self {
69 write_globs: vec!["*".to_string()],
70 }
71 }
72
73 /// May this plugin write the given entity id (e.g. `"light.kitchen"`)?
74 pub fn may_write(&self, entity_id: &str) -> bool {
75 self.write_globs.iter().any(|g| glob_matches(g, entity_id))
76 }
77
78 /// Number of write-grant globs (0 = can write nothing).
79 pub fn write_grant_count(&self) -> usize {
80 self.write_globs.len()
81 }
82}
83
84/// Match `entity_id` against a single glob pattern.
85///
86/// Supported forms:
87/// * `"*"` → matches anything.
88/// * `"light.*"` → trailing wildcard: any id with the `light.` prefix.
89/// * `"light.kitchen"` → exact match.
90fn glob_matches(pattern: &str, entity_id: &str) -> bool {
91 if pattern == "*" {
92 return true;
93 }
94 if let Some(prefix) = pattern.strip_suffix('*') {
95 return entity_id.starts_with(prefix);
96 }
97 pattern == entity_id
98}
99
100#[cfg(test)]
101mod tests {
102 use super::*;
103
104 fn manifest_with(perms: &[&str]) -> PluginManifest {
105 PluginManifest {
106 domain: "p".into(),
107 name: "P".into(),
108 version: "1".into(),
109 documentation: None,
110 iot_class: None,
111 config_flow: false,
112 integration_type: None,
113 dependencies: vec![],
114 requirements: vec![],
115 wasm_module: None,
116 wasm_module_hash: None,
117 wasm_module_sig: None,
118 publisher_key: None,
119 min_homecore_version: None,
120 host_imports_required: vec![],
121 homecore_permissions: perms.iter().map(|s| s.to_string()).collect(),
122 cog_id: None,
123 }
124 }
125
126 #[test]
127 fn domain_glob_allows_same_domain_only() {
128 let ps = PermissionSet::from_manifest(&manifest_with(&["light.*"]));
129 assert!(ps.may_write("light.kitchen"));
130 assert!(ps.may_write("light.bedroom"));
131 assert!(!ps.may_write("lock.front_door"));
132 assert!(!ps.may_write("alarm_control_panel.home"));
133 }
134
135 #[test]
136 fn no_permissions_can_write_nothing() {
137 let ps = PermissionSet::from_manifest(&manifest_with(&[]));
138 assert_eq!(ps.write_grant_count(), 0);
139 assert!(!ps.may_write("light.kitchen"));
140 assert!(!ps.may_write("sensor.temp"));
141 }
142
143 #[test]
144 fn explicit_state_write_form_is_honored() {
145 let ps = PermissionSet::from_manifest(&manifest_with(&["state:write:sensor.*"]));
146 assert!(ps.may_write("sensor.temp"));
147 assert!(!ps.may_write("light.kitchen"));
148 }
149
150 #[test]
151 fn read_grants_do_not_confer_write() {
152 let ps = PermissionSet::from_manifest(&manifest_with(&["state:read:lock.*"]));
153 assert!(!ps.may_write("lock.front_door"));
154 }
155
156 #[test]
157 fn exact_entity_grant_is_scoped() {
158 let ps = PermissionSet::from_manifest(&manifest_with(&["light.kitchen"]));
159 assert!(ps.may_write("light.kitchen"));
160 assert!(!ps.may_write("light.bedroom"));
161 }
162
163 #[test]
164 fn wildcard_grants_everything() {
165 let ps = PermissionSet::from_manifest(&manifest_with(&["*"]));
166 assert!(ps.may_write("lock.front_door"));
167 }
168}