use serde_json::Value;
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(tag = "type", content = "value", rename_all = "snake_case")]
pub enum Permission {
DatabaseReadAll,
DatabaseRead(String),
DatabaseWrite(String),
DatabaseCreateTables,
FilesystemRead,
FilesystemReadAppData,
FilesystemWrite,
FilesystemWriteAppData,
NetworkHttp,
NetworkHttpDomain(String),
IpcRegister,
EventsEmit,
EventsListen,
UiInject,
ProcessSpawn,
ProcessSpawnWhitelist(Vec<String>),
Notifications,
ClipboardRead,
ClipboardWrite,
HostNavigation,
HostAppStateRead,
HostFileIntents,
HostFileDialogs,
#[serde(rename = "host_aichat_access")]
HostAIChatAccess,
#[serde(rename = "host_enterprise_gateway_access")]
HostEnterpriseGatewayAccess,
HostDeepLinks,
HostThemeRead,
HostEventSubscribe,
AppConfigRead,
}
pub const HOST_AICHAT_ACCESS_PERMISSION: &str = "host_aichat_access";
pub const HOST_ENTERPRISE_GATEWAY_ACCESS_PERMISSION: &str = "host_enterprise_gateway_access";
pub const INVALID_HOST_A_I_CHAT_ACCESS_PERMISSION: &str = "host_a_i_chat_access";
#[derive(Clone, Copy)]
enum PermissionValueShape {
None,
String,
StringArray,
}
#[derive(Clone, Copy)]
struct PermissionSchema {
type_name: &'static str,
value_shape: PermissionValueShape,
}
const PERMISSION_SCHEMAS: &[PermissionSchema] = &[
PermissionSchema { type_name: "database_read_all", value_shape: PermissionValueShape::None },
PermissionSchema { type_name: "database_read", value_shape: PermissionValueShape::String },
PermissionSchema { type_name: "database_write", value_shape: PermissionValueShape::String },
PermissionSchema { type_name: "database_create_tables", value_shape: PermissionValueShape::None },
PermissionSchema { type_name: "filesystem_read", value_shape: PermissionValueShape::None },
PermissionSchema { type_name: "filesystem_read_app_data", value_shape: PermissionValueShape::None },
PermissionSchema { type_name: "filesystem_write", value_shape: PermissionValueShape::None },
PermissionSchema { type_name: "filesystem_write_app_data", value_shape: PermissionValueShape::None },
PermissionSchema { type_name: "network_http", value_shape: PermissionValueShape::None },
PermissionSchema { type_name: "network_http_domain", value_shape: PermissionValueShape::String },
PermissionSchema { type_name: "ipc_register", value_shape: PermissionValueShape::None },
PermissionSchema { type_name: "events_emit", value_shape: PermissionValueShape::None },
PermissionSchema { type_name: "events_listen", value_shape: PermissionValueShape::None },
PermissionSchema { type_name: "ui_inject", value_shape: PermissionValueShape::None },
PermissionSchema { type_name: "process_spawn", value_shape: PermissionValueShape::None },
PermissionSchema { type_name: "process_spawn_whitelist", value_shape: PermissionValueShape::StringArray },
PermissionSchema { type_name: "notifications", value_shape: PermissionValueShape::None },
PermissionSchema { type_name: "clipboard_read", value_shape: PermissionValueShape::None },
PermissionSchema { type_name: "clipboard_write", value_shape: PermissionValueShape::None },
PermissionSchema { type_name: "host_navigation", value_shape: PermissionValueShape::None },
PermissionSchema { type_name: "host_app_state_read", value_shape: PermissionValueShape::None },
PermissionSchema { type_name: "host_file_intents", value_shape: PermissionValueShape::None },
PermissionSchema { type_name: "host_file_dialogs", value_shape: PermissionValueShape::None },
PermissionSchema { type_name: HOST_AICHAT_ACCESS_PERMISSION, value_shape: PermissionValueShape::None },
PermissionSchema { type_name: HOST_ENTERPRISE_GATEWAY_ACCESS_PERMISSION, value_shape: PermissionValueShape::None },
PermissionSchema { type_name: "host_deep_links", value_shape: PermissionValueShape::None },
PermissionSchema { type_name: "host_theme_read", value_shape: PermissionValueShape::None },
PermissionSchema { type_name: "host_event_subscribe", value_shape: PermissionValueShape::None },
PermissionSchema { type_name: "app_config_read", value_shape: PermissionValueShape::None },
];
impl Permission {
pub fn tier(&self) -> PermissionTier {
match self {
Self::UiInject
| Self::EventsListen
| Self::DatabaseCreateTables
| Self::AppConfigRead
| Self::Notifications
| Self::HostAppStateRead
| Self::HostThemeRead => PermissionTier::Transparent,
Self::DatabaseReadAll
| Self::DatabaseRead(_)
| Self::IpcRegister
| Self::EventsEmit
| Self::NetworkHttpDomain(_)
| Self::HostNavigation
| Self::HostFileIntents
| Self::HostFileDialogs
| Self::HostAIChatAccess
| Self::HostEnterpriseGatewayAccess
| Self::HostDeepLinks
| Self::HostEventSubscribe => PermissionTier::Standard,
Self::FilesystemRead
| Self::FilesystemWrite
| Self::FilesystemReadAppData
| Self::FilesystemWriteAppData
| Self::NetworkHttp
| Self::ProcessSpawnWhitelist(_)
| Self::ClipboardRead
| Self::ClipboardWrite => PermissionTier::Sensitive,
Self::DatabaseWrite(_)
| Self::ProcessSpawn => PermissionTier::Restricted,
}
}
pub fn description(&self) -> String {
match self {
Self::DatabaseReadAll => "Read all app data".into(),
Self::DatabaseRead(t) => format!("Read table: {t}"),
Self::DatabaseWrite(t) => format!("Write to table: {t}"),
Self::DatabaseCreateTables => "Create plugin-owned database tables".into(),
Self::FilesystemRead => "Read files from your filesystem".into(),
Self::FilesystemReadAppData => "Read files in the app data directory".into(),
Self::FilesystemWrite => "Write files to your filesystem".into(),
Self::FilesystemWriteAppData => "Write files in the app data directory".into(),
Self::NetworkHttp => "Make outbound HTTP requests".into(),
Self::NetworkHttpDomain(d) => format!("Make HTTP requests to: {d}"),
Self::IpcRegister => "Register new app commands".into(),
Self::EventsEmit => "Emit app events".into(),
Self::EventsListen => "Listen to app lifecycle events".into(),
Self::UiInject => "Inject UI components".into(),
Self::ProcessSpawn => "Spawn arbitrary child processes".into(),
Self::ProcessSpawnWhitelist(v) => format!("Spawn processes: {}", v.join(", ")),
Self::Notifications => "Show desktop notifications".into(),
Self::ClipboardRead => "Read the clipboard".into(),
Self::ClipboardWrite => "Write to the clipboard".into(),
Self::HostNavigation => "Navigate within HaloForge".into(),
Self::HostAppStateRead => "Read HaloForge UI state".into(),
Self::HostFileIntents => "Receive file-open intents from HaloForge".into(),
Self::HostFileDialogs => "Open HaloForge file and directory dialogs".into(),
Self::HostAIChatAccess => "Use HaloForge AI models and chat transport".into(),
Self::HostEnterpriseGatewayAccess => "Use HaloForge managed image gateway through the host session".into(),
Self::HostDeepLinks => "Receive HaloForge deep links routed to this plugin".into(),
Self::HostThemeRead => "Read HaloForge theme tokens".into(),
Self::HostEventSubscribe => "Subscribe to HaloForge host events".into(),
Self::AppConfigRead => "Read app configuration".into(),
}
}
}
pub fn validate_manifest_permissions_json(value: &Value) -> Result<(), String> {
let permissions = value
.as_array()
.ok_or_else(|| "Plugin manifest permissions must be an array.".to_string())?;
for (index, permission) in permissions.iter().enumerate() {
validate_manifest_permission_json(permission)
.map_err(|error| format!("manifest.permissions[{index}]: {error}"))?;
}
Ok(())
}
pub fn validate_manifest_permission_json(value: &Value) -> Result<(), String> {
let permission = value
.as_object()
.ok_or_else(|| "Plugin permission entries must be JSON objects.".to_string())?;
let type_name = permission
.get("type")
.and_then(Value::as_str)
.ok_or_else(|| "Plugin permission 'type' must be a string.".to_string())?;
if type_name == INVALID_HOST_A_I_CHAT_ACCESS_PERMISSION {
return Err(format!(
"Invalid plugin permission '{INVALID_HOST_A_I_CHAT_ACCESS_PERMISSION}'. Use '{HOST_AICHAT_ACCESS_PERMISSION}'."
));
}
let schema = PERMISSION_SCHEMAS
.iter()
.find(|schema| schema.type_name == type_name)
.ok_or_else(|| format!("Unknown plugin permission '{type_name}'."))?;
match schema.value_shape {
PermissionValueShape::None => Ok(()),
PermissionValueShape::String => {
if permission.get("value").and_then(Value::as_str).is_some() {
Ok(())
} else {
Err(format!("Plugin permission '{type_name}' requires a string 'value'."))
}
}
PermissionValueShape::StringArray => {
let Some(values) = permission.get("value").and_then(Value::as_array) else {
return Err(format!("Plugin permission '{type_name}' requires an array 'value'."));
};
if values.iter().all(|value| value.as_str().is_some()) {
Ok(())
} else {
Err(format!(
"Plugin permission '{type_name}' requires every 'value' item to be a string."
))
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum PermissionTier {
Transparent = 0,
Standard = 1,
Sensitive = 2,
Restricted = 3,
}
#[cfg(test)]
mod tests {
use super::{
validate_manifest_permission_json, validate_manifest_permissions_json, Permission,
PermissionTier, HOST_AICHAT_ACCESS_PERMISSION, HOST_ENTERPRISE_GATEWAY_ACCESS_PERMISSION,
INVALID_HOST_A_I_CHAT_ACCESS_PERMISSION,
};
use serde_json::json;
#[test]
fn host_permissions_have_expected_tiers() {
assert_eq!(Permission::HostAppStateRead.tier(), PermissionTier::Transparent);
assert_eq!(Permission::HostThemeRead.tier(), PermissionTier::Transparent);
assert_eq!(Permission::HostNavigation.tier(), PermissionTier::Standard);
assert_eq!(Permission::HostFileDialogs.tier(), PermissionTier::Standard);
assert_eq!(Permission::HostAIChatAccess.tier(), PermissionTier::Standard);
assert_eq!(
Permission::HostEnterpriseGatewayAccess.tier(),
PermissionTier::Standard,
);
}
#[test]
fn host_aichat_access_serializes_with_canonical_name() {
assert_eq!(
serde_json::to_value(Permission::HostAIChatAccess).unwrap(),
json!({ "type": HOST_AICHAT_ACCESS_PERMISSION })
);
}
#[test]
fn host_aichat_access_deserializes_only_from_canonical_name() {
let parsed: Permission =
serde_json::from_value(json!({ "type": HOST_AICHAT_ACCESS_PERMISSION })).unwrap();
assert_eq!(parsed, Permission::HostAIChatAccess);
let error = serde_json::from_value::<Permission>(
json!({ "type": INVALID_HOST_A_I_CHAT_ACCESS_PERMISSION }),
)
.unwrap_err()
.to_string();
assert!(error.contains(HOST_AICHAT_ACCESS_PERMISSION));
}
#[test]
fn permission_validator_rejects_legacy_host_a_i_chat_name() {
let error = validate_manifest_permissions_json(&json!([
{ "type": INVALID_HOST_A_I_CHAT_ACCESS_PERMISSION }
]))
.unwrap_err();
assert!(error.contains(INVALID_HOST_A_I_CHAT_ACCESS_PERMISSION));
assert!(error.contains(HOST_AICHAT_ACCESS_PERMISSION));
}
#[test]
fn validates_enterprise_gateway_host_permission() {
validate_manifest_permission_json(&json!({
"type": HOST_ENTERPRISE_GATEWAY_ACCESS_PERMISSION
}))
.expect("host enterprise gateway permission should be valid");
}
#[test]
fn validates_deep_links_host_permission() {
validate_manifest_permission_json(&json!({
"type": "host_deep_links"
}))
.expect("host deep links permission should be valid");
}
}