core/protocol: add structured macOS additional permissions and merge them into sandbox execution (#13499)

## Summary
- Introduce strongly-typed macOS additional permissions across
protocol/core/app-server boundaries.
- Merge additional permissions into effective sandbox execution,
including macOS seatbelt profile extensions.
- Expand docs, schema/tool definitions, UI rendering, and tests for
`network`, `file_system`, and `macos` additional permissions.
This commit is contained in:
Celia Chen
2026-03-05 16:21:45 -08:00
committed by GitHub
parent 4e77ea0ec7
commit aaefee04cd
24 changed files with 1013 additions and 379 deletions

View File

@@ -109,17 +109,32 @@ pub enum MacOsAutomationValue {
BundleIds(Vec<String>),
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
#[derive(
Debug,
Clone,
PartialEq,
Eq,
PartialOrd,
Ord,
Default,
Hash,
Serialize,
Deserialize,
JsonSchema,
TS,
)]
#[serde(rename_all = "snake_case")]
pub enum MacOsPreferencesPermission {
None,
// IMPORTANT: ReadOnly needs to be the default because it's the
// security-sensitive default and keeps cf prefs working.
#[default]
ReadOnly,
ReadWrite,
None,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
#[derive(Debug, Clone, PartialEq, Eq, Default, Hash, Serialize, Deserialize, JsonSchema, TS)]
#[serde(rename_all = "snake_case", try_from = "MacOsAutomationPermissionDe")]
pub enum MacOsAutomationPermission {
#[default]
None,
@@ -127,7 +142,52 @@ pub enum MacOsAutomationPermission {
BundleIds(Vec<String>),
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum MacOsAutomationPermissionDe {
Mode(String),
BundleIds(Vec<String>),
}
impl TryFrom<MacOsAutomationPermissionDe> for MacOsAutomationPermission {
type Error = String;
/// Accepts one of:
/// - `"none"` or `"all"`
/// - a plain list of bundle IDs, e.g. `["com.apple.Notes"]`
fn try_from(value: MacOsAutomationPermissionDe) -> Result<Self, Self::Error> {
let permission = match value {
MacOsAutomationPermissionDe::Mode(value) => {
let normalized = value.trim().to_ascii_lowercase();
if normalized == "all" {
MacOsAutomationPermission::All
} else if normalized == "none" {
MacOsAutomationPermission::None
} else {
return Err(format!(
"invalid macOS automation permission: {value}; expected none, all, or bundle ids"
));
}
}
MacOsAutomationPermissionDe::BundleIds(bundle_ids) => {
let bundle_ids = bundle_ids
.into_iter()
.map(|bundle_id| bundle_id.trim().to_string())
.filter(|bundle_id| !bundle_id.is_empty())
.collect::<Vec<String>>();
if bundle_ids.is_empty() {
MacOsAutomationPermission::None
} else {
MacOsAutomationPermission::BundleIds(bundle_ids)
}
}
};
Ok(permission)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Hash, Serialize, Deserialize, JsonSchema, TS)]
pub struct MacOsSeatbeltProfileExtensions {
pub macos_preferences: MacOsPreferencesPermission,
pub macos_automation: MacOsAutomationPermission,
@@ -139,25 +199,12 @@ pub struct MacOsSeatbeltProfileExtensions {
pub struct PermissionProfile {
pub network: Option<NetworkPermissions>,
pub file_system: Option<FileSystemPermissions>,
pub macos: Option<MacOsPermissions>,
pub macos: Option<MacOsSeatbeltProfileExtensions>,
}
impl PermissionProfile {
pub fn is_empty(&self) -> bool {
self.network
.as_ref()
.map(NetworkPermissions::is_empty)
.unwrap_or(true)
&& self
.file_system
.as_ref()
.map(FileSystemPermissions::is_empty)
.unwrap_or(true)
&& self
.macos
.as_ref()
.map(MacOsPermissions::is_empty)
.unwrap_or(true)
self.network.is_none() && self.file_system.is_none() && self.macos.is_none()
}
}
@@ -1346,6 +1393,76 @@ mod tests {
);
}
#[test]
fn permission_profile_is_empty_when_all_fields_are_none() {
assert_eq!(PermissionProfile::default().is_empty(), true);
}
#[test]
fn permission_profile_is_not_empty_when_field_is_present_but_nested_empty() {
let permission_profile = PermissionProfile {
network: Some(NetworkPermissions { enabled: None }),
file_system: None,
macos: None,
};
assert_eq!(permission_profile.is_empty(), false);
}
#[test]
fn macos_preferences_permission_deserializes_read_write() {
let permission = serde_json::from_str::<MacOsPreferencesPermission>("\"read_write\"")
.expect("deserialize macos preferences permission");
assert_eq!(permission, MacOsPreferencesPermission::ReadWrite);
}
#[test]
fn macos_preferences_permission_order_matches_permissiveness() {
assert!(MacOsPreferencesPermission::None < MacOsPreferencesPermission::ReadOnly);
assert!(MacOsPreferencesPermission::ReadOnly < MacOsPreferencesPermission::ReadWrite);
}
#[test]
fn permission_profile_deserializes_macos_seatbelt_profile_extensions() {
let permission_profile = serde_json::from_value::<PermissionProfile>(serde_json::json!({
"network": null,
"file_system": null,
"macos": {
"macos_preferences": "read_write",
"macos_automation": ["com.apple.Notes"],
"macos_accessibility": true,
"macos_calendar": true
}
}))
.expect("deserialize permission profile");
assert_eq!(
permission_profile,
PermissionProfile {
network: None,
file_system: None,
macos: Some(MacOsSeatbeltProfileExtensions {
macos_preferences: MacOsPreferencesPermission::ReadWrite,
macos_automation: MacOsAutomationPermission::BundleIds(vec![
"com.apple.Notes".to_string(),
]),
macos_accessibility: true,
macos_calendar: true,
}),
}
);
}
#[test]
fn macos_automation_permission_deserializes_all_and_none() {
let all = serde_json::from_str::<MacOsAutomationPermission>("\"all\"")
.expect("deserialize all automation permission");
let none = serde_json::from_str::<MacOsAutomationPermission>("\"none\"")
.expect("deserialize none automation permission");
assert_eq!(all, MacOsAutomationPermission::All);
assert_eq!(none, MacOsAutomationPermission::None);
}
#[test]
fn convert_mcp_content_to_items_builds_data_urls_when_missing_prefix() {
let contents = vec![serde_json::json!({