1use serde_json::Value;
2
3#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
6#[serde(tag = "type", content = "value", rename_all = "snake_case")]
7pub enum Permission {
8 DatabaseReadAll,
10 DatabaseRead(String),
12 DatabaseWrite(String),
14 DatabaseCreateTables,
16
17 FilesystemRead,
19 FilesystemReadAppData,
21 FilesystemWrite,
23 FilesystemWriteAppData,
25
26 NetworkHttp,
28 NetworkHttpDomain(String),
30
31 IpcRegister,
33
34 EventsEmit,
36 EventsListen,
38
39 UiInject,
41
42 ProcessSpawn,
44 ProcessSpawnWhitelist(Vec<String>),
46
47 Notifications,
49
50 ClipboardRead,
52 ClipboardWrite,
54
55 HostNavigation,
57 HostAppStateRead,
59 HostFileIntents,
61 HostFileDialogs,
63 #[serde(rename = "host_aichat_access")]
65 HostAIChatAccess,
66 #[serde(rename = "host_enterprise_gateway_access")]
68 HostEnterpriseGatewayAccess,
69 HostDeepLinks,
71 HostThemeRead,
73 HostEventSubscribe,
75
76 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 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 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 Transparent = 0,
265 Standard = 1,
267 Sensitive = 2,
269 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}