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
Unverified
parent 4e77ea0ec7
commit aaefee04cd
24 changed files with 1013 additions and 379 deletions
+7 -3
View File
@@ -89,7 +89,7 @@ fn resolve_workdir_base_path(
}
/// Validates feature/policy constraints for `with_additional_permissions` and
/// returns normalized absolute paths. Errors if paths are invalid.
/// normalizes any path-based permissions. Errors if the request is invalid.
pub(super) fn normalize_and_validate_additional_permissions(
request_permission_enabled: bool,
approval_policy: AskForApproval,
@@ -119,14 +119,18 @@ pub(super) fn normalize_and_validate_additional_permissions(
}
let Some(additional_permissions) = additional_permissions else {
return Err(
"missing `additional_permissions`; provide `file_system.read` and/or `file_system.write` when using `with_additional_permissions`"
"missing `additional_permissions`; provide at least one of `network`, `file_system`, or `macos` when using `with_additional_permissions`"
.to_string(),
);
};
#[cfg(not(target_os = "macos"))]
if additional_permissions.macos.is_some() {
return Err("`additional_permissions.macos` is only supported on macOS".to_string());
}
let normalized = normalize_additional_permissions(additional_permissions)?;
if normalized.is_empty() {
return Err(
"`additional_permissions` must include at least one path in `file_system.read` or `file_system.write`"
"`additional_permissions` must include at least one requested permission in `network`, `file_system`, or `macos`"
.to_string(),
);
}
@@ -839,6 +839,7 @@ impl ShellCommandExecutor for CoreShellCommandExecutor {
permission_profile,
)) => {
// Merge additive permissions into the existing turn/request sandbox policy.
// On macOS, additional profile extensions are unioned with the turn defaults.
self.prepare_sandboxed_exec(PrepareSandboxedExecParams {
command,
workdir,
@@ -846,7 +847,9 @@ impl ShellCommandExecutor for CoreShellCommandExecutor {
sandbox_policy: &self.sandbox_policy,
additional_permissions: Some(permission_profile),
#[cfg(target_os = "macos")]
macos_seatbelt_profile_extensions: None,
macos_seatbelt_profile_extensions: self
.macos_seatbelt_profile_extensions
.as_ref(),
})?
}
EscalationExecution::Permissions(EscalationPermissions::Permissions(permissions)) => {
@@ -579,3 +579,67 @@ async fn prepare_escalated_exec_permissions_preserve_macos_seatbelt_extensions()
prepared.command
);
}
#[cfg(target_os = "macos")]
#[tokio::test]
async fn prepare_escalated_exec_permission_profile_unions_turn_and_requested_macos_extensions() {
let cwd = AbsolutePathBuf::from_absolute_path(std::env::temp_dir()).unwrap();
let executor = CoreShellCommandExecutor {
command: vec!["echo".to_string(), "ok".to_string()],
cwd: cwd.to_path_buf(),
env: HashMap::new(),
network: None,
sandbox: SandboxType::None,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
windows_sandbox_level: WindowsSandboxLevel::Disabled,
sandbox_permissions: SandboxPermissions::UseDefault,
justification: None,
arg0: None,
sandbox_policy_cwd: cwd.to_path_buf(),
macos_seatbelt_profile_extensions: Some(MacOsSeatbeltProfileExtensions {
macos_preferences: MacOsPreferencesPermission::ReadOnly,
..Default::default()
}),
codex_linux_sandbox_exe: None,
use_linux_sandbox_bwrap: false,
};
let prepared = executor
.prepare_escalated_exec(
&AbsolutePathBuf::from_absolute_path("/bin/echo").unwrap(),
&["echo".to_string(), "ok".to_string()],
&cwd,
HashMap::new(),
EscalationExecution::Permissions(EscalationPermissions::PermissionProfile(
PermissionProfile {
macos: Some(MacOsSeatbeltProfileExtensions {
macos_calendar: true,
..Default::default()
}),
..Default::default()
},
)),
)
.await
.unwrap();
let policy = prepared
.command
.get(2)
.expect("seatbelt policy should be present");
assert_eq!(
prepared.command.first().map(String::as_str),
Some(MACOS_PATH_TO_SEATBELT_EXECUTABLE)
);
assert_eq!(prepared.command.get(1).map(String::as_str), Some("-p"));
assert!(
policy.contains("(allow user-preference-read)"),
"expected turn macOS seatbelt extensions to be preserved: {:?}",
prepared.command
);
assert!(
policy.contains("(allow mach-lookup (global-name \"com.apple.CalendarAgent\"))"),
"expected requested macOS seatbelt extensions to be included: {:?}",
prepared.command
);
}
+103 -27
View File
@@ -253,7 +253,7 @@ fn create_approval_parameters(request_permission_enabled: bool) -> BTreeMap<Stri
JsonSchema::String {
description: Some(
if request_permission_enabled {
"Sandbox permissions for the command. Use \"with_additional_permissions\" to request additional sandboxed filesystem access (preferred), or \"require_escalated\" to request running without sandbox restrictions; defaults to \"use_default\"."
"Sandbox permissions for the command. Use \"with_additional_permissions\" to request additional sandboxed filesystem, network, or macOS permissions (preferred), or \"require_escalated\" to request running without sandbox restrictions; defaults to \"use_default\"."
} else {
"Sandbox permissions for the command. Set to \"require_escalated\" to request running without sandbox restrictions; defaults to \"use_default\"."
}
@@ -291,36 +291,100 @@ fn create_approval_parameters(request_permission_enabled: bool) -> BTreeMap<Stri
properties.insert(
"additional_permissions".to_string(),
JsonSchema::Object {
properties: BTreeMap::from([(
"file_system".to_string(),
JsonSchema::Object {
properties: BTreeMap::from([
(
"read".to_string(),
JsonSchema::Array {
items: Box::new(JsonSchema::String { description: None }),
properties: BTreeMap::from([
(
"network".to_string(),
JsonSchema::Object {
properties: BTreeMap::from([(
"enabled".to_string(),
JsonSchema::Boolean {
description: Some(
"Additional filesystem paths to grant read access for this command."
"Set to true to enable network access for this command."
.to_string(),
),
},
),
(
"write".to_string(),
JsonSchema::Array {
items: Box::new(JsonSchema::String { description: None }),
description: Some(
"Additional filesystem paths to grant write access for this command."
.to_string(),
),
},
),
]),
required: None,
additional_properties: Some(false.into()),
},
)]),
required: Some(vec!["file_system".to_string()]),
)]),
required: None,
additional_properties: Some(false.into()),
},
),
(
"file_system".to_string(),
JsonSchema::Object {
properties: BTreeMap::from([
(
"read".to_string(),
JsonSchema::Array {
items: Box::new(JsonSchema::String { description: None }),
description: Some(
"Additional filesystem paths to grant read access for this command."
.to_string(),
),
},
),
(
"write".to_string(),
JsonSchema::Array {
items: Box::new(JsonSchema::String { description: None }),
description: Some(
"Additional filesystem paths to grant write access for this command."
.to_string(),
),
},
),
]),
required: None,
additional_properties: Some(false.into()),
},
),
(
"macos".to_string(),
JsonSchema::Object {
properties: BTreeMap::from([
(
"preferences".to_string(),
JsonSchema::String {
description: Some(
"Additional macOS preferences access for this command. Supported values: \"readonly\" or \"readwrite\"."
.to_string(),
),
},
),
(
"automations".to_string(),
JsonSchema::Array {
items: Box::new(JsonSchema::String { description: None }),
description: Some(
"Additional macOS automation targets for this command as bundle IDs, or use true in clients that support boolean union payloads."
.to_string(),
),
},
),
(
"accessibility".to_string(),
JsonSchema::Boolean {
description: Some(
"Set to true to allow macOS accessibility APIs for this command."
.to_string(),
),
},
),
(
"calendar".to_string(),
JsonSchema::Boolean {
description: Some(
"Set to true to allow macOS Calendar access for this command."
.to_string(),
),
},
),
]),
required: None,
additional_properties: Some(false.into()),
},
),
]),
required: None,
additional_properties: Some(false.into()),
},
);
@@ -3366,6 +3430,18 @@ Examples of valid command strings:
panic!("expected sandbox_permissions description");
};
assert!(description.contains("with_additional_permissions"));
assert!(description.contains("macOS permissions"));
let Some(JsonSchema::Object {
properties: additional_properties,
..
}) = properties.get("additional_permissions")
else {
panic!("expected additional_permissions schema");
};
assert!(additional_properties.contains_key("network"));
assert!(additional_properties.contains_key("file_system"));
assert!(additional_properties.contains_key("macos"));
}
#[test]