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 enterprise/community model gateway endpoints through the host session.
67    #[serde(rename = "host_enterprise_gateway_access")]
68    HostEnterpriseGatewayAccess,
69    /// Read the active host theme and design tokens.
70    HostThemeRead,
71    /// Subscribe to stable host events exposed to plugins.
72    HostEventSubscribe,
73
74    /// Read app config (theme, language).
75    AppConfigRead,
76}
77
78pub const HOST_AICHAT_ACCESS_PERMISSION: &str = "host_aichat_access";
79pub const HOST_ENTERPRISE_GATEWAY_ACCESS_PERMISSION: &str = "host_enterprise_gateway_access";
80pub const INVALID_HOST_A_I_CHAT_ACCESS_PERMISSION: &str = "host_a_i_chat_access";
81
82#[derive(Clone, Copy)]
83enum PermissionValueShape {
84    None,
85    String,
86    StringArray,
87}
88
89#[derive(Clone, Copy)]
90struct PermissionSchema {
91    type_name: &'static str,
92    value_shape: PermissionValueShape,
93}
94
95const PERMISSION_SCHEMAS: &[PermissionSchema] = &[
96    PermissionSchema { type_name: "database_read_all", value_shape: PermissionValueShape::None },
97    PermissionSchema { type_name: "database_read", value_shape: PermissionValueShape::String },
98    PermissionSchema { type_name: "database_write", value_shape: PermissionValueShape::String },
99    PermissionSchema { type_name: "database_create_tables", value_shape: PermissionValueShape::None },
100    PermissionSchema { type_name: "filesystem_read", value_shape: PermissionValueShape::None },
101    PermissionSchema { type_name: "filesystem_read_app_data", value_shape: PermissionValueShape::None },
102    PermissionSchema { type_name: "filesystem_write", value_shape: PermissionValueShape::None },
103    PermissionSchema { type_name: "filesystem_write_app_data", value_shape: PermissionValueShape::None },
104    PermissionSchema { type_name: "network_http", value_shape: PermissionValueShape::None },
105    PermissionSchema { type_name: "network_http_domain", value_shape: PermissionValueShape::String },
106    PermissionSchema { type_name: "ipc_register", value_shape: PermissionValueShape::None },
107    PermissionSchema { type_name: "events_emit", value_shape: PermissionValueShape::None },
108    PermissionSchema { type_name: "events_listen", value_shape: PermissionValueShape::None },
109    PermissionSchema { type_name: "ui_inject", value_shape: PermissionValueShape::None },
110    PermissionSchema { type_name: "process_spawn", value_shape: PermissionValueShape::None },
111    PermissionSchema { type_name: "process_spawn_whitelist", value_shape: PermissionValueShape::StringArray },
112    PermissionSchema { type_name: "notifications", value_shape: PermissionValueShape::None },
113    PermissionSchema { type_name: "clipboard_read", value_shape: PermissionValueShape::None },
114    PermissionSchema { type_name: "clipboard_write", value_shape: PermissionValueShape::None },
115    PermissionSchema { type_name: "host_navigation", value_shape: PermissionValueShape::None },
116    PermissionSchema { type_name: "host_app_state_read", value_shape: PermissionValueShape::None },
117    PermissionSchema { type_name: "host_file_intents", value_shape: PermissionValueShape::None },
118    PermissionSchema { type_name: "host_file_dialogs", value_shape: PermissionValueShape::None },
119    PermissionSchema { type_name: HOST_AICHAT_ACCESS_PERMISSION, value_shape: PermissionValueShape::None },
120    PermissionSchema { type_name: HOST_ENTERPRISE_GATEWAY_ACCESS_PERMISSION, value_shape: PermissionValueShape::None },
121    PermissionSchema { type_name: "host_theme_read", value_shape: PermissionValueShape::None },
122    PermissionSchema { type_name: "host_event_subscribe", value_shape: PermissionValueShape::None },
123    PermissionSchema { type_name: "app_config_read", value_shape: PermissionValueShape::None },
124];
125
126impl Permission {
127    /// Approval tier for this permission.
128    pub fn tier(&self) -> PermissionTier {
129        match self {
130            Self::UiInject
131            | Self::EventsListen
132            | Self::DatabaseCreateTables
133            | Self::AppConfigRead
134            | Self::Notifications
135            | Self::HostAppStateRead
136            | Self::HostThemeRead => PermissionTier::Transparent,
137
138            Self::DatabaseReadAll
139            | Self::DatabaseRead(_)
140            | Self::IpcRegister
141            | Self::EventsEmit
142            | Self::NetworkHttpDomain(_)
143            | Self::HostNavigation
144            | Self::HostFileIntents
145            | Self::HostFileDialogs
146            | Self::HostAIChatAccess
147            | Self::HostEnterpriseGatewayAccess
148            | Self::HostEventSubscribe => PermissionTier::Standard,
149
150            Self::FilesystemRead
151            | Self::FilesystemWrite
152            | Self::FilesystemReadAppData
153            | Self::FilesystemWriteAppData
154            | Self::NetworkHttp
155            | Self::ProcessSpawnWhitelist(_)
156            | Self::ClipboardRead
157            | Self::ClipboardWrite => PermissionTier::Sensitive,
158
159            Self::DatabaseWrite(_)
160            | Self::ProcessSpawn => PermissionTier::Restricted,
161        }
162    }
163
164    /// Human-readable description shown in the permission prompt.
165    pub fn description(&self) -> String {
166        match self {
167            Self::DatabaseReadAll           => "Read all app data".into(),
168            Self::DatabaseRead(t)           => format!("Read table: {t}"),
169            Self::DatabaseWrite(t)          => format!("Write to table: {t}"),
170            Self::DatabaseCreateTables      => "Create plugin-owned database tables".into(),
171            Self::FilesystemRead            => "Read files from your filesystem".into(),
172            Self::FilesystemReadAppData     => "Read files in the app data directory".into(),
173            Self::FilesystemWrite           => "Write files to your filesystem".into(),
174            Self::FilesystemWriteAppData    => "Write files in the app data directory".into(),
175            Self::NetworkHttp               => "Make outbound HTTP requests".into(),
176            Self::NetworkHttpDomain(d)      => format!("Make HTTP requests to: {d}"),
177            Self::IpcRegister               => "Register new app commands".into(),
178            Self::EventsEmit                => "Emit app events".into(),
179            Self::EventsListen              => "Listen to app lifecycle events".into(),
180            Self::UiInject                  => "Inject UI components".into(),
181            Self::ProcessSpawn              => "Spawn arbitrary child processes".into(),
182            Self::ProcessSpawnWhitelist(v)  => format!("Spawn processes: {}", v.join(", ")),
183            Self::Notifications             => "Show desktop notifications".into(),
184            Self::ClipboardRead             => "Read the clipboard".into(),
185            Self::ClipboardWrite            => "Write to the clipboard".into(),
186            Self::HostNavigation            => "Navigate within HaloForge".into(),
187            Self::HostAppStateRead          => "Read HaloForge UI state".into(),
188            Self::HostFileIntents           => "Receive file-open intents from HaloForge".into(),
189            Self::HostFileDialogs           => "Open HaloForge file and directory dialogs".into(),
190            Self::HostAIChatAccess          => "Use HaloForge AI models and chat transport".into(),
191            Self::HostEnterpriseGatewayAccess => "Use HaloForge enterprise model gateway through the host session".into(),
192            Self::HostThemeRead             => "Read HaloForge theme tokens".into(),
193            Self::HostEventSubscribe        => "Subscribe to HaloForge host events".into(),
194            Self::AppConfigRead             => "Read app configuration".into(),
195        }
196    }
197}
198
199pub fn validate_manifest_permissions_json(value: &Value) -> Result<(), String> {
200    let permissions = value
201        .as_array()
202        .ok_or_else(|| "Plugin manifest permissions must be an array.".to_string())?;
203
204    for (index, permission) in permissions.iter().enumerate() {
205        validate_manifest_permission_json(permission)
206            .map_err(|error| format!("manifest.permissions[{index}]: {error}"))?;
207    }
208
209    Ok(())
210}
211
212pub fn validate_manifest_permission_json(value: &Value) -> Result<(), String> {
213    let permission = value
214        .as_object()
215        .ok_or_else(|| "Plugin permission entries must be JSON objects.".to_string())?;
216    let type_name = permission
217        .get("type")
218        .and_then(Value::as_str)
219        .ok_or_else(|| "Plugin permission 'type' must be a string.".to_string())?;
220
221    if type_name == INVALID_HOST_A_I_CHAT_ACCESS_PERMISSION {
222        return Err(format!(
223            "Invalid plugin permission '{INVALID_HOST_A_I_CHAT_ACCESS_PERMISSION}'. Use '{HOST_AICHAT_ACCESS_PERMISSION}'."
224        ));
225    }
226
227    let schema = PERMISSION_SCHEMAS
228        .iter()
229        .find(|schema| schema.type_name == type_name)
230        .ok_or_else(|| format!("Unknown plugin permission '{type_name}'."))?;
231
232    match schema.value_shape {
233        PermissionValueShape::None => Ok(()),
234        PermissionValueShape::String => {
235            if permission.get("value").and_then(Value::as_str).is_some() {
236                Ok(())
237            } else {
238                Err(format!("Plugin permission '{type_name}' requires a string 'value'."))
239            }
240        }
241        PermissionValueShape::StringArray => {
242            let Some(values) = permission.get("value").and_then(Value::as_array) else {
243                return Err(format!("Plugin permission '{type_name}' requires an array 'value'."));
244            };
245            if values.iter().all(|value| value.as_str().is_some()) {
246                Ok(())
247            } else {
248                Err(format!(
249                    "Plugin permission '{type_name}' requires every 'value' item to be a string."
250                ))
251            }
252        }
253    }
254}
255
256#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
257pub enum PermissionTier {
258    /// Auto-granted at install time with no user prompt.
259    Transparent = 0,
260    /// Shown once at install time; user approves/denies.
261    Standard = 1,
262    /// Shown at install + confirmation on first actual use.
263    Sensitive = 2,
264    /// Disabled by default; user must manually enable in Plugin Manager.
265    Restricted = 3,
266}
267
268#[cfg(test)]
269mod tests {
270    use super::{
271        validate_manifest_permission_json, validate_manifest_permissions_json, Permission,
272        PermissionTier, HOST_AICHAT_ACCESS_PERMISSION, HOST_ENTERPRISE_GATEWAY_ACCESS_PERMISSION,
273        INVALID_HOST_A_I_CHAT_ACCESS_PERMISSION,
274    };
275    use serde_json::json;
276
277    #[test]
278    fn host_permissions_have_expected_tiers() {
279        assert_eq!(Permission::HostAppStateRead.tier(), PermissionTier::Transparent);
280        assert_eq!(Permission::HostThemeRead.tier(), PermissionTier::Transparent);
281        assert_eq!(Permission::HostNavigation.tier(), PermissionTier::Standard);
282        assert_eq!(Permission::HostFileDialogs.tier(), PermissionTier::Standard);
283        assert_eq!(Permission::HostAIChatAccess.tier(), PermissionTier::Standard);
284        assert_eq!(
285            Permission::HostEnterpriseGatewayAccess.tier(),
286            PermissionTier::Standard,
287        );
288    }
289
290    #[test]
291    fn host_aichat_access_serializes_with_canonical_name() {
292        assert_eq!(
293            serde_json::to_value(Permission::HostAIChatAccess).unwrap(),
294            json!({ "type": HOST_AICHAT_ACCESS_PERMISSION })
295        );
296    }
297
298    #[test]
299    fn host_aichat_access_deserializes_only_from_canonical_name() {
300        let parsed: Permission =
301            serde_json::from_value(json!({ "type": HOST_AICHAT_ACCESS_PERMISSION })).unwrap();
302        assert_eq!(parsed, Permission::HostAIChatAccess);
303
304        let error = serde_json::from_value::<Permission>(
305            json!({ "type": INVALID_HOST_A_I_CHAT_ACCESS_PERMISSION }),
306        )
307        .unwrap_err()
308        .to_string();
309        assert!(error.contains(HOST_AICHAT_ACCESS_PERMISSION));
310    }
311
312    #[test]
313    fn permission_validator_rejects_legacy_host_a_i_chat_name() {
314        let error = validate_manifest_permissions_json(&json!([
315            { "type": INVALID_HOST_A_I_CHAT_ACCESS_PERMISSION }
316        ]))
317        .unwrap_err();
318        assert!(error.contains(INVALID_HOST_A_I_CHAT_ACCESS_PERMISSION));
319        assert!(error.contains(HOST_AICHAT_ACCESS_PERMISSION));
320    }
321
322    #[test]
323    fn validates_enterprise_gateway_host_permission() {
324        validate_manifest_permission_json(&json!({
325            "type": HOST_ENTERPRISE_GATEWAY_ACCESS_PERMISSION
326        }))
327        .expect("host enterprise gateway permission should be valid");
328    }
329}