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 HostThemeRead,
68 HostEventSubscribe,
70
71 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 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 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 Transparent = 0,
253 Standard = 1,
255 Sensitive = 2,
257 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}