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 HostThemeRead,
71 HostEventSubscribe,
73
74 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 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 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 Transparent = 0,
260 Standard = 1,
262 Sensitive = 2,
264 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}