Skip to main content

haloforge_plugin_api/
permissions.rs

1use serde_json::Value;
2
3/// Fine-grained permissions a plugin must declare in its manifest.
4/// The host checks these at install time (user approval) and at runtime (sandbox enforcement).
5#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
6#[serde(tag = "type", content = "value", rename_all = "snake_case")]
7pub enum Permission {
8    /// Read any host table.
9    DatabaseReadAll,
10    /// Read a specific host table (e.g. "launch_profiles").
11    DatabaseRead(String),
12    /// Write to a specific host table. Restricted tier.
13    DatabaseWrite(String),
14    /// Create tables in the plugin's own namespace.
15    DatabaseCreateTables,
16
17    /// Read any filesystem path (prompts user on first use).
18    FilesystemRead,
19    /// Read within the HaloForge app-data directory only.
20    FilesystemReadAppData,
21    /// Write any filesystem path (prompts user on first use).
22    FilesystemWrite,
23    /// Write within the HaloForge app-data directory only.
24    FilesystemWriteAppData,
25
26    /// Make outbound HTTP requests to any URL.
27    NetworkHttp,
28    /// Make outbound HTTP requests to a specific domain only.
29    NetworkHttpDomain(String),
30
31    /// Register new Tauri IPC commands.
32    IpcRegister,
33
34    /// Emit events on the app event bus.
35    EventsEmit,
36    /// Listen to app lifecycle events.
37    EventsListen,
38
39    /// Inject into UI slots (implied by capability_levels 1/2).
40    UiInject,
41
42    /// Spawn any child process (high risk — Restricted tier).
43    ProcessSpawn,
44    /// Spawn only executables from a declared whitelist.
45    ProcessSpawnWhitelist(Vec<String>),
46
47    /// Show desktop toast notifications.
48    Notifications,
49
50    /// Read the clipboard.
51    ClipboardRead,
52    /// Write to the clipboard.
53    ClipboardWrite,
54
55    /// Navigate within the host UI (module switches, opening settings tabs).
56    HostNavigation,
57    /// Read stable host UI state such as the active module or settings tab.
58    HostAppStateRead,
59    /// Consume file-open intents routed by the host shell.
60    HostFileIntents,
61    /// Open stable host file and directory picker dialogs.
62    HostFileDialogs,
63    /// Reuse the host AIChat transport and model selection.
64    #[serde(rename = "host_aichat_access")]
65    HostAIChatAccess,
66    /// Call managed image gateway endpoints through the host session.
67    #[serde(rename = "host_enterprise_gateway_access")]
68    HostEnterpriseGatewayAccess,
69    /// Receive plugin-scoped haloforge:// deep links routed by the host.
70    HostDeepLinks,
71    /// Read the active host theme and design tokens.
72    HostThemeRead,
73    /// Subscribe to stable host events exposed to plugins.
74    HostEventSubscribe,
75
76    /// Read app config (theme, language).
77    AppConfigRead,
78}
79
80pub const HOST_AICHAT_ACCESS_PERMISSION: &str = "host_aichat_access";
81pub const HOST_ENTERPRISE_GATEWAY_ACCESS_PERMISSION: &str = "host_enterprise_gateway_access";
82pub const INVALID_HOST_A_I_CHAT_ACCESS_PERMISSION: &str = "host_a_i_chat_access";
83
84#[derive(Clone, Copy)]
85enum PermissionValueShape {
86    None,
87    String,
88    StringArray,
89}
90
91#[derive(Clone, Copy)]
92struct PermissionSchema {
93    type_name: &'static str,
94    value_shape: PermissionValueShape,
95}
96
97const PERMISSION_SCHEMAS: &[PermissionSchema] = &[
98    PermissionSchema { type_name: "database_read_all", value_shape: PermissionValueShape::None },
99    PermissionSchema { type_name: "database_read", value_shape: PermissionValueShape::String },
100    PermissionSchema { type_name: "database_write", value_shape: PermissionValueShape::String },
101    PermissionSchema { type_name: "database_create_tables", value_shape: PermissionValueShape::None },
102    PermissionSchema { type_name: "filesystem_read", value_shape: PermissionValueShape::None },
103    PermissionSchema { type_name: "filesystem_read_app_data", value_shape: PermissionValueShape::None },
104    PermissionSchema { type_name: "filesystem_write", value_shape: PermissionValueShape::None },
105    PermissionSchema { type_name: "filesystem_write_app_data", value_shape: PermissionValueShape::None },
106    PermissionSchema { type_name: "network_http", value_shape: PermissionValueShape::None },
107    PermissionSchema { type_name: "network_http_domain", value_shape: PermissionValueShape::String },
108    PermissionSchema { type_name: "ipc_register", value_shape: PermissionValueShape::None },
109    PermissionSchema { type_name: "events_emit", value_shape: PermissionValueShape::None },
110    PermissionSchema { type_name: "events_listen", value_shape: PermissionValueShape::None },
111    PermissionSchema { type_name: "ui_inject", value_shape: PermissionValueShape::None },
112    PermissionSchema { type_name: "process_spawn", value_shape: PermissionValueShape::None },
113    PermissionSchema { type_name: "process_spawn_whitelist", value_shape: PermissionValueShape::StringArray },
114    PermissionSchema { type_name: "notifications", value_shape: PermissionValueShape::None },
115    PermissionSchema { type_name: "clipboard_read", value_shape: PermissionValueShape::None },
116    PermissionSchema { type_name: "clipboard_write", value_shape: PermissionValueShape::None },
117    PermissionSchema { type_name: "host_navigation", value_shape: PermissionValueShape::None },
118    PermissionSchema { type_name: "host_app_state_read", value_shape: PermissionValueShape::None },
119    PermissionSchema { type_name: "host_file_intents", value_shape: PermissionValueShape::None },
120    PermissionSchema { type_name: "host_file_dialogs", value_shape: PermissionValueShape::None },
121    PermissionSchema { type_name: HOST_AICHAT_ACCESS_PERMISSION, value_shape: PermissionValueShape::None },
122    PermissionSchema { type_name: HOST_ENTERPRISE_GATEWAY_ACCESS_PERMISSION, value_shape: PermissionValueShape::None },
123    PermissionSchema { type_name: "host_deep_links", value_shape: PermissionValueShape::None },
124    PermissionSchema { type_name: "host_theme_read", value_shape: PermissionValueShape::None },
125    PermissionSchema { type_name: "host_event_subscribe", value_shape: PermissionValueShape::None },
126    PermissionSchema { type_name: "app_config_read", value_shape: PermissionValueShape::None },
127];
128
129impl Permission {
130    /// Approval tier for this permission.
131    pub fn tier(&self) -> PermissionTier {
132        match self {
133            Self::UiInject
134            | Self::EventsListen
135            | Self::DatabaseCreateTables
136            | Self::AppConfigRead
137            | Self::Notifications
138            | Self::HostAppStateRead
139            | Self::HostThemeRead => PermissionTier::Transparent,
140
141            Self::DatabaseReadAll
142            | Self::DatabaseRead(_)
143            | Self::IpcRegister
144            | Self::EventsEmit
145            | Self::NetworkHttpDomain(_)
146            | Self::HostNavigation
147            | Self::HostFileIntents
148            | Self::HostFileDialogs
149            | Self::HostAIChatAccess
150            | Self::HostEnterpriseGatewayAccess
151            | Self::HostDeepLinks
152            | Self::HostEventSubscribe => PermissionTier::Standard,
153
154            Self::FilesystemRead
155            | Self::FilesystemWrite
156            | Self::FilesystemReadAppData
157            | Self::FilesystemWriteAppData
158            | Self::NetworkHttp
159            | Self::ProcessSpawnWhitelist(_)
160            | Self::ClipboardRead
161            | Self::ClipboardWrite => PermissionTier::Sensitive,
162
163            Self::DatabaseWrite(_)
164            | Self::ProcessSpawn => PermissionTier::Restricted,
165        }
166    }
167
168    /// Human-readable description shown in the permission prompt.
169    pub fn description(&self) -> String {
170        match self {
171            Self::DatabaseReadAll           => "Read all app data".into(),
172            Self::DatabaseRead(t)           => format!("Read table: {t}"),
173            Self::DatabaseWrite(t)          => format!("Write to table: {t}"),
174            Self::DatabaseCreateTables      => "Create plugin-owned database tables".into(),
175            Self::FilesystemRead            => "Read files from your filesystem".into(),
176            Self::FilesystemReadAppData     => "Read files in the app data directory".into(),
177            Self::FilesystemWrite           => "Write files to your filesystem".into(),
178            Self::FilesystemWriteAppData    => "Write files in the app data directory".into(),
179            Self::NetworkHttp               => "Make outbound HTTP requests".into(),
180            Self::NetworkHttpDomain(d)      => format!("Make HTTP requests to: {d}"),
181            Self::IpcRegister               => "Register new app commands".into(),
182            Self::EventsEmit                => "Emit app events".into(),
183            Self::EventsListen              => "Listen to app lifecycle events".into(),
184            Self::UiInject                  => "Inject UI components".into(),
185            Self::ProcessSpawn              => "Spawn arbitrary child processes".into(),
186            Self::ProcessSpawnWhitelist(v)  => format!("Spawn processes: {}", v.join(", ")),
187            Self::Notifications             => "Show desktop notifications".into(),
188            Self::ClipboardRead             => "Read the clipboard".into(),
189            Self::ClipboardWrite            => "Write to the clipboard".into(),
190            Self::HostNavigation            => "Navigate within HaloForge".into(),
191            Self::HostAppStateRead          => "Read HaloForge UI state".into(),
192            Self::HostFileIntents           => "Receive file-open intents from HaloForge".into(),
193            Self::HostFileDialogs           => "Open HaloForge file and directory dialogs".into(),
194            Self::HostAIChatAccess          => "Use HaloForge AI models and chat transport".into(),
195            Self::HostEnterpriseGatewayAccess => "Use HaloForge managed image gateway through the host session".into(),
196            Self::HostDeepLinks             => "Receive HaloForge deep links routed to this plugin".into(),
197            Self::HostThemeRead             => "Read HaloForge theme tokens".into(),
198            Self::HostEventSubscribe        => "Subscribe to HaloForge host events".into(),
199            Self::AppConfigRead             => "Read app configuration".into(),
200        }
201    }
202}
203
204pub fn validate_manifest_permissions_json(value: &Value) -> Result<(), String> {
205    let permissions = value
206        .as_array()
207        .ok_or_else(|| "Plugin manifest permissions must be an array.".to_string())?;
208
209    for (index, permission) in permissions.iter().enumerate() {
210        validate_manifest_permission_json(permission)
211            .map_err(|error| format!("manifest.permissions[{index}]: {error}"))?;
212    }
213
214    Ok(())
215}
216
217pub fn validate_manifest_permission_json(value: &Value) -> Result<(), String> {
218    let permission = value
219        .as_object()
220        .ok_or_else(|| "Plugin permission entries must be JSON objects.".to_string())?;
221    let type_name = permission
222        .get("type")
223        .and_then(Value::as_str)
224        .ok_or_else(|| "Plugin permission 'type' must be a string.".to_string())?;
225
226    if type_name == INVALID_HOST_A_I_CHAT_ACCESS_PERMISSION {
227        return Err(format!(
228            "Invalid plugin permission '{INVALID_HOST_A_I_CHAT_ACCESS_PERMISSION}'. Use '{HOST_AICHAT_ACCESS_PERMISSION}'."
229        ));
230    }
231
232    let schema = PERMISSION_SCHEMAS
233        .iter()
234        .find(|schema| schema.type_name == type_name)
235        .ok_or_else(|| format!("Unknown plugin permission '{type_name}'."))?;
236
237    match schema.value_shape {
238        PermissionValueShape::None => Ok(()),
239        PermissionValueShape::String => {
240            if permission.get("value").and_then(Value::as_str).is_some() {
241                Ok(())
242            } else {
243                Err(format!("Plugin permission '{type_name}' requires a string 'value'."))
244            }
245        }
246        PermissionValueShape::StringArray => {
247            let Some(values) = permission.get("value").and_then(Value::as_array) else {
248                return Err(format!("Plugin permission '{type_name}' requires an array 'value'."));
249            };
250            if values.iter().all(|value| value.as_str().is_some()) {
251                Ok(())
252            } else {
253                Err(format!(
254                    "Plugin permission '{type_name}' requires every 'value' item to be a string."
255                ))
256            }
257        }
258    }
259}
260
261#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
262pub enum PermissionTier {
263    /// Auto-granted at install time with no user prompt.
264    Transparent = 0,
265    /// Shown once at install time; user approves/denies.
266    Standard = 1,
267    /// Shown at install + confirmation on first actual use.
268    Sensitive = 2,
269    /// Disabled by default; user must manually enable in Plugin Manager.
270    Restricted = 3,
271}
272
273#[cfg(test)]
274mod tests {
275    use super::{
276        validate_manifest_permission_json, validate_manifest_permissions_json, Permission,
277        PermissionTier, HOST_AICHAT_ACCESS_PERMISSION, HOST_ENTERPRISE_GATEWAY_ACCESS_PERMISSION,
278        INVALID_HOST_A_I_CHAT_ACCESS_PERMISSION,
279    };
280    use serde_json::json;
281
282    #[test]
283    fn host_permissions_have_expected_tiers() {
284        assert_eq!(Permission::HostAppStateRead.tier(), PermissionTier::Transparent);
285        assert_eq!(Permission::HostThemeRead.tier(), PermissionTier::Transparent);
286        assert_eq!(Permission::HostNavigation.tier(), PermissionTier::Standard);
287        assert_eq!(Permission::HostFileDialogs.tier(), PermissionTier::Standard);
288        assert_eq!(Permission::HostAIChatAccess.tier(), PermissionTier::Standard);
289        assert_eq!(
290            Permission::HostEnterpriseGatewayAccess.tier(),
291            PermissionTier::Standard,
292        );
293    }
294
295    #[test]
296    fn host_aichat_access_serializes_with_canonical_name() {
297        assert_eq!(
298            serde_json::to_value(Permission::HostAIChatAccess).unwrap(),
299            json!({ "type": HOST_AICHAT_ACCESS_PERMISSION })
300        );
301    }
302
303    #[test]
304    fn host_aichat_access_deserializes_only_from_canonical_name() {
305        let parsed: Permission =
306            serde_json::from_value(json!({ "type": HOST_AICHAT_ACCESS_PERMISSION })).unwrap();
307        assert_eq!(parsed, Permission::HostAIChatAccess);
308
309        let error = serde_json::from_value::<Permission>(
310            json!({ "type": INVALID_HOST_A_I_CHAT_ACCESS_PERMISSION }),
311        )
312        .unwrap_err()
313        .to_string();
314        assert!(error.contains(HOST_AICHAT_ACCESS_PERMISSION));
315    }
316
317    #[test]
318    fn permission_validator_rejects_legacy_host_a_i_chat_name() {
319        let error = validate_manifest_permissions_json(&json!([
320            { "type": INVALID_HOST_A_I_CHAT_ACCESS_PERMISSION }
321        ]))
322        .unwrap_err();
323        assert!(error.contains(INVALID_HOST_A_I_CHAT_ACCESS_PERMISSION));
324        assert!(error.contains(HOST_AICHAT_ACCESS_PERMISSION));
325    }
326
327    #[test]
328    fn validates_enterprise_gateway_host_permission() {
329        validate_manifest_permission_json(&json!({
330            "type": HOST_ENTERPRISE_GATEWAY_ACCESS_PERMISSION
331        }))
332        .expect("host enterprise gateway permission should be valid");
333    }
334
335    #[test]
336    fn validates_deep_links_host_permission() {
337        validate_manifest_permission_json(&json!({
338            "type": "host_deep_links"
339        }))
340        .expect("host deep links permission should be valid");
341    }
342}