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