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
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 1013 additions and 379 deletions

View file

@ -31,38 +31,24 @@
"AdditionalMacOsPermissions": {
"properties": {
"accessibility": {
"type": [
"boolean",
"null"
]
"type": "boolean"
},
"automations": {
"anyOf": [
{
"$ref": "#/definitions/MacOsAutomationValue"
},
{
"type": "null"
}
]
"$ref": "#/definitions/MacOsAutomationPermission"
},
"calendar": {
"type": [
"boolean",
"null"
]
"type": "boolean"
},
"preferences": {
"anyOf": [
{
"$ref": "#/definitions/MacOsPreferencesValue"
},
{
"type": "null"
}
]
"$ref": "#/definitions/MacOsPreferencesPermission"
}
},
"required": [
"accessibility",
"automations",
"calendar",
"preferences"
],
"type": "object"
},
"AdditionalNetworkPermissions": {
@ -300,28 +286,40 @@
}
]
},
"MacOsAutomationValue": {
"anyOf": [
"MacOsAutomationPermission": {
"oneOf": [
{
"type": "boolean"
"enum": [
"none",
"all"
],
"type": "string"
},
{
"items": {
"type": "string"
"additionalProperties": false,
"properties": {
"bundle_ids": {
"items": {
"type": "string"
},
"type": "array"
}
},
"type": "array"
"required": [
"bundle_ids"
],
"title": "BundleIdsMacOsAutomationPermission",
"type": "object"
}
]
},
"MacOsPreferencesValue": {
"anyOf": [
{
"type": "boolean"
},
{
"type": "string"
}
]
"MacOsPreferencesPermission": {
"enum": [
"none",
"read_only",
"read_write"
],
"type": "string"
},
"NetworkApprovalContext": {
"properties": {

View file

@ -3756,66 +3756,64 @@
],
"type": "string"
},
"MacOsAutomationValue": {
"anyOf": [
"MacOsAutomationPermission": {
"oneOf": [
{
"type": "boolean"
"enum": [
"none",
"all"
],
"type": "string"
},
{
"items": {
"type": "string"
"additionalProperties": false,
"properties": {
"bundle_ids": {
"items": {
"type": "string"
},
"type": "array"
}
},
"type": "array"
"required": [
"bundle_ids"
],
"title": "BundleIdsMacOsAutomationPermission",
"type": "object"
}
]
},
"MacOsPermissions": {
"MacOsPreferencesPermission": {
"enum": [
"none",
"read_only",
"read_write"
],
"type": "string"
},
"MacOsSeatbeltProfileExtensions": {
"properties": {
"accessibility": {
"type": [
"boolean",
"null"
]
"macos_accessibility": {
"type": "boolean"
},
"automations": {
"anyOf": [
{
"$ref": "#/definitions/MacOsAutomationValue"
},
{
"type": "null"
}
]
"macos_automation": {
"$ref": "#/definitions/MacOsAutomationPermission"
},
"calendar": {
"type": [
"boolean",
"null"
]
"macos_calendar": {
"type": "boolean"
},
"preferences": {
"anyOf": [
{
"$ref": "#/definitions/MacOsPreferencesValue"
},
{
"type": "null"
}
]
"macos_preferences": {
"$ref": "#/definitions/MacOsPreferencesPermission"
}
},
"required": [
"macos_accessibility",
"macos_automation",
"macos_calendar",
"macos_preferences"
],
"type": "object"
},
"MacOsPreferencesValue": {
"anyOf": [
{
"type": "boolean"
},
{
"type": "string"
}
]
},
"McpAuthStatus": {
"enum": [
"unsupported",
@ -4159,7 +4157,7 @@
"macos": {
"anyOf": [
{
"$ref": "#/definitions/MacOsPermissions"
"$ref": "#/definitions/MacOsSeatbeltProfileExtensions"
},
{
"type": "null"

View file

@ -31,38 +31,24 @@
"AdditionalMacOsPermissions": {
"properties": {
"accessibility": {
"type": [
"boolean",
"null"
]
"type": "boolean"
},
"automations": {
"anyOf": [
{
"$ref": "#/definitions/MacOsAutomationValue"
},
{
"type": "null"
}
]
"$ref": "#/definitions/MacOsAutomationPermission"
},
"calendar": {
"type": [
"boolean",
"null"
]
"type": "boolean"
},
"preferences": {
"anyOf": [
{
"$ref": "#/definitions/MacOsPreferencesValue"
},
{
"type": "null"
}
]
"$ref": "#/definitions/MacOsPreferencesPermission"
}
},
"required": [
"accessibility",
"automations",
"calendar",
"preferences"
],
"type": "object"
},
"AdditionalNetworkPermissions": {
@ -629,28 +615,40 @@
],
"type": "object"
},
"MacOsAutomationValue": {
"anyOf": [
"MacOsAutomationPermission": {
"oneOf": [
{
"type": "boolean"
"enum": [
"none",
"all"
],
"type": "string"
},
{
"items": {
"type": "string"
"additionalProperties": false,
"properties": {
"bundle_ids": {
"items": {
"type": "string"
},
"type": "array"
}
},
"type": "array"
"required": [
"bundle_ids"
],
"title": "BundleIdsMacOsAutomationPermission",
"type": "object"
}
]
},
"MacOsPreferencesValue": {
"anyOf": [
{
"type": "boolean"
},
{
"type": "string"
}
]
"MacOsPreferencesPermission": {
"enum": [
"none",
"read_only",
"read_write"
],
"type": "string"
},
"McpServerElicitationRequestParams": {
"oneOf": [

View file

@ -27,38 +27,24 @@
"AdditionalMacOsPermissions": {
"properties": {
"accessibility": {
"type": [
"boolean",
"null"
]
"type": "boolean"
},
"automations": {
"anyOf": [
{
"$ref": "#/definitions/MacOsAutomationValue"
},
{
"type": "null"
}
]
"$ref": "#/definitions/MacOsAutomationPermission"
},
"calendar": {
"type": [
"boolean",
"null"
]
"type": "boolean"
},
"preferences": {
"anyOf": [
{
"$ref": "#/definitions/MacOsPreferencesValue"
},
{
"type": "null"
}
]
"$ref": "#/definitions/MacOsPreferencesPermission"
}
},
"required": [
"accessibility",
"automations",
"calendar",
"preferences"
],
"type": "object"
},
"AdditionalNetworkPermissions": {
@ -5229,66 +5215,64 @@
"title": "JSONRPCResponse",
"type": "object"
},
"MacOsAutomationValue": {
"anyOf": [
"MacOsAutomationPermission": {
"oneOf": [
{
"type": "boolean"
"enum": [
"none",
"all"
],
"type": "string"
},
{
"items": {
"type": "string"
"additionalProperties": false,
"properties": {
"bundle_ids": {
"items": {
"type": "string"
},
"type": "array"
}
},
"type": "array"
"required": [
"bundle_ids"
],
"title": "BundleIdsMacOsAutomationPermission",
"type": "object"
}
]
},
"MacOsPermissions": {
"MacOsPreferencesPermission": {
"enum": [
"none",
"read_only",
"read_write"
],
"type": "string"
},
"MacOsSeatbeltProfileExtensions": {
"properties": {
"accessibility": {
"type": [
"boolean",
"null"
]
"macos_accessibility": {
"type": "boolean"
},
"automations": {
"anyOf": [
{
"$ref": "#/definitions/MacOsAutomationValue"
},
{
"type": "null"
}
]
"macos_automation": {
"$ref": "#/definitions/MacOsAutomationPermission"
},
"calendar": {
"type": [
"boolean",
"null"
]
"macos_calendar": {
"type": "boolean"
},
"preferences": {
"anyOf": [
{
"$ref": "#/definitions/MacOsPreferencesValue"
},
{
"type": "null"
}
]
"macos_preferences": {
"$ref": "#/definitions/MacOsPreferencesPermission"
}
},
"required": [
"macos_accessibility",
"macos_automation",
"macos_calendar",
"macos_preferences"
],
"type": "object"
},
"MacOsPreferencesValue": {
"anyOf": [
{
"type": "boolean"
},
{
"type": "string"
}
]
},
"McpInvocation": {
"properties": {
"arguments": {
@ -5670,7 +5654,7 @@
"macos": {
"anyOf": [
{
"$ref": "#/definitions/MacOsPermissions"
"$ref": "#/definitions/MacOsSeatbeltProfileExtensions"
},
{
"type": "null"

View file

@ -7350,66 +7350,64 @@
"title": "LogoutAccountResponse",
"type": "object"
},
"MacOsAutomationValue": {
"anyOf": [
"MacOsAutomationPermission": {
"oneOf": [
{
"type": "boolean"
"enum": [
"none",
"all"
],
"type": "string"
},
{
"items": {
"type": "string"
"additionalProperties": false,
"properties": {
"bundle_ids": {
"items": {
"type": "string"
},
"type": "array"
}
},
"type": "array"
"required": [
"bundle_ids"
],
"title": "BundleIdsMacOsAutomationPermission",
"type": "object"
}
]
},
"MacOsPermissions": {
"MacOsPreferencesPermission": {
"enum": [
"none",
"read_only",
"read_write"
],
"type": "string"
},
"MacOsSeatbeltProfileExtensions": {
"properties": {
"accessibility": {
"type": [
"boolean",
"null"
]
"macos_accessibility": {
"type": "boolean"
},
"automations": {
"anyOf": [
{
"$ref": "#/definitions/MacOsAutomationValue"
},
{
"type": "null"
}
]
"macos_automation": {
"$ref": "#/definitions/MacOsAutomationPermission"
},
"calendar": {
"type": [
"boolean",
"null"
]
"macos_calendar": {
"type": "boolean"
},
"preferences": {
"anyOf": [
{
"$ref": "#/definitions/MacOsPreferencesValue"
},
{
"type": "null"
}
]
"macos_preferences": {
"$ref": "#/definitions/MacOsPreferencesPermission"
}
},
"required": [
"macos_accessibility",
"macos_automation",
"macos_calendar",
"macos_preferences"
],
"type": "object"
},
"MacOsPreferencesValue": {
"anyOf": [
{
"type": "boolean"
},
{
"type": "string"
}
]
},
"McpAuthStatus": {
"enum": [
"unsupported",
@ -8286,7 +8284,7 @@
"macos": {
"anyOf": [
{
"$ref": "#/definitions/MacOsPermissions"
"$ref": "#/definitions/MacOsSeatbeltProfileExtensions"
},
{
"type": "null"

View file

@ -2,4 +2,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type MacOsPreferencesValue = boolean | string;
export type MacOsAutomationPermission = "none" | "all" | { "bundle_ids": Array<string> };

View file

@ -1,7 +0,0 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { MacOsAutomationValue } from "./MacOsAutomationValue";
import type { MacOsPreferencesValue } from "./MacOsPreferencesValue";
export type MacOsPermissions = { preferences: MacOsPreferencesValue | null, automations: MacOsAutomationValue | null, accessibility: boolean | null, calendar: boolean | null, };

View file

@ -2,4 +2,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type MacOsAutomationValue = boolean | Array<string>;
export type MacOsPreferencesPermission = "none" | "read_only" | "read_write";

View file

@ -0,0 +1,7 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { MacOsAutomationPermission } from "./MacOsAutomationPermission";
import type { MacOsPreferencesPermission } from "./MacOsPreferencesPermission";
export type MacOsSeatbeltProfileExtensions = { macos_preferences: MacOsPreferencesPermission, macos_automation: MacOsAutomationPermission, macos_accessibility: boolean, macos_calendar: boolean, };

View file

@ -2,7 +2,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { FileSystemPermissions } from "./FileSystemPermissions";
import type { MacOsPermissions } from "./MacOsPermissions";
import type { MacOsSeatbeltProfileExtensions } from "./MacOsSeatbeltProfileExtensions";
import type { NetworkPermissions } from "./NetworkPermissions";
export type PermissionProfile = { network: NetworkPermissions | null, file_system: FileSystemPermissions | null, macos: MacOsPermissions | null, };
export type PermissionProfile = { network: NetworkPermissions | null, file_system: FileSystemPermissions | null, macos: MacOsSeatbeltProfileExtensions | null, };

View file

@ -100,9 +100,9 @@ export type { ListSkillsResponseEvent } from "./ListSkillsResponseEvent";
export type { LocalShellAction } from "./LocalShellAction";
export type { LocalShellExecAction } from "./LocalShellExecAction";
export type { LocalShellStatus } from "./LocalShellStatus";
export type { MacOsAutomationValue } from "./MacOsAutomationValue";
export type { MacOsPermissions } from "./MacOsPermissions";
export type { MacOsPreferencesValue } from "./MacOsPreferencesValue";
export type { MacOsAutomationPermission } from "./MacOsAutomationPermission";
export type { MacOsPreferencesPermission } from "./MacOsPreferencesPermission";
export type { MacOsSeatbeltProfileExtensions } from "./MacOsSeatbeltProfileExtensions";
export type { McpAuthStatus } from "./McpAuthStatus";
export type { McpInvocation } from "./McpInvocation";
export type { McpListToolsResponseEvent } from "./McpListToolsResponseEvent";

View file

@ -1,7 +1,7 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { MacOsAutomationValue } from "../MacOsAutomationValue";
import type { MacOsPreferencesValue } from "../MacOsPreferencesValue";
import type { MacOsAutomationPermission } from "../MacOsAutomationPermission";
import type { MacOsPreferencesPermission } from "../MacOsPreferencesPermission";
export type AdditionalMacOsPermissions = { preferences: MacOsPreferencesValue | null, automations: MacOsAutomationValue | null, accessibility: boolean | null, calendar: boolean | null, };
export type AdditionalMacOsPermissions = { preferences: MacOsPreferencesPermission, automations: MacOsAutomationPermission, accessibility: boolean, calendar: boolean, };

View file

@ -28,9 +28,9 @@ use codex_protocol::mcp::Resource as McpResource;
use codex_protocol::mcp::ResourceTemplate as McpResourceTemplate;
use codex_protocol::mcp::Tool as McpTool;
use codex_protocol::models::FileSystemPermissions as CoreFileSystemPermissions;
use codex_protocol::models::MacOsAutomationValue as CoreMacOsAutomationValue;
use codex_protocol::models::MacOsPermissions as CoreMacOsPermissions;
use codex_protocol::models::MacOsPreferencesValue as CoreMacOsPreferencesValue;
use codex_protocol::models::MacOsAutomationPermission as CoreMacOsAutomationPermission;
use codex_protocol::models::MacOsPreferencesPermission as CoreMacOsPreferencesPermission;
use codex_protocol::models::MacOsSeatbeltProfileExtensions as CoreMacOsSeatbeltProfileExtensions;
use codex_protocol::models::MessagePhase;
use codex_protocol::models::NetworkPermissions as CoreNetworkPermissions;
use codex_protocol::models::PermissionProfile as CorePermissionProfile;
@ -837,19 +837,19 @@ impl From<CoreFileSystemPermissions> for AdditionalFileSystemPermissions {
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct AdditionalMacOsPermissions {
pub preferences: Option<CoreMacOsPreferencesValue>,
pub automations: Option<CoreMacOsAutomationValue>,
pub accessibility: Option<bool>,
pub calendar: Option<bool>,
pub preferences: CoreMacOsPreferencesPermission,
pub automations: CoreMacOsAutomationPermission,
pub accessibility: bool,
pub calendar: bool,
}
impl From<CoreMacOsPermissions> for AdditionalMacOsPermissions {
fn from(value: CoreMacOsPermissions) -> Self {
impl From<CoreMacOsSeatbeltProfileExtensions> for AdditionalMacOsPermissions {
fn from(value: CoreMacOsSeatbeltProfileExtensions) -> Self {
Self {
preferences: value.preferences,
automations: value.automations,
accessibility: value.accessibility,
calendar: value.calendar,
preferences: value.macos_preferences,
automations: value.macos_automation,
accessibility: value.macos_accessibility,
calendar: value.macos_calendar,
}
}
}

View file

@ -0,0 +1,144 @@
use std::collections::BTreeSet;
use codex_protocol::models::MacOsAutomationPermission;
use codex_protocol::models::MacOsPreferencesPermission;
use codex_protocol::models::MacOsSeatbeltProfileExtensions;
/// Merges macOS seatbelt profile extensions by taking the permissive union of
/// each permission field.
pub(crate) fn merge_macos_seatbelt_profile_extensions(
base: Option<&MacOsSeatbeltProfileExtensions>,
permissions: Option<&MacOsSeatbeltProfileExtensions>,
) -> Option<MacOsSeatbeltProfileExtensions> {
let Some(permissions) = permissions else {
return base.cloned();
};
match base {
Some(base) => Some(MacOsSeatbeltProfileExtensions {
macos_preferences: union_macos_preferences_permission(
&base.macos_preferences,
&permissions.macos_preferences,
),
macos_automation: union_macos_automation_permission(
&base.macos_automation,
&permissions.macos_automation,
),
macos_accessibility: base.macos_accessibility || permissions.macos_accessibility,
macos_calendar: base.macos_calendar || permissions.macos_calendar,
}),
None => Some(permissions.clone()),
}
}
/// Unions two preferences permissions by keeping the more permissive one.
///
/// The larger rank wins: `None < ReadOnly < ReadWrite`. When both sides have
/// the same rank, this keeps `base`.
fn union_macos_preferences_permission(
base: &MacOsPreferencesPermission,
requested: &MacOsPreferencesPermission,
) -> MacOsPreferencesPermission {
if base < requested {
requested.clone()
} else {
base.clone()
}
}
/// Unions two automation permissions by keeping the more permissive result.
///
/// `All` wins over everything, `None` yields to the other side, and two bundle
/// ID allowlists are unioned together.
fn union_macos_automation_permission(
base: &MacOsAutomationPermission,
requested: &MacOsAutomationPermission,
) -> MacOsAutomationPermission {
match (base, requested) {
(MacOsAutomationPermission::All, _) | (_, MacOsAutomationPermission::All) => {
MacOsAutomationPermission::All
}
(MacOsAutomationPermission::None, _) => requested.clone(),
(_, MacOsAutomationPermission::None) => base.clone(),
(
MacOsAutomationPermission::BundleIds(base_bundle_ids),
MacOsAutomationPermission::BundleIds(requested_bundle_ids),
) => MacOsAutomationPermission::BundleIds(
base_bundle_ids
.iter()
.chain(requested_bundle_ids.iter())
.cloned()
.collect::<BTreeSet<_>>()
.into_iter()
.collect(),
),
}
}
#[cfg(all(test, target_os = "macos"))]
mod tests {
use super::merge_macos_seatbelt_profile_extensions;
use super::union_macos_automation_permission;
use super::union_macos_preferences_permission;
use codex_protocol::models::MacOsAutomationPermission;
use codex_protocol::models::MacOsPreferencesPermission;
use codex_protocol::models::MacOsSeatbeltProfileExtensions;
use pretty_assertions::assert_eq;
#[test]
fn merge_extensions_widens_permissions() {
let base = MacOsSeatbeltProfileExtensions {
macos_preferences: MacOsPreferencesPermission::ReadOnly,
macos_automation: MacOsAutomationPermission::BundleIds(vec![
"com.apple.Calendar".to_string(),
]),
macos_accessibility: false,
macos_calendar: false,
};
let requested = MacOsSeatbeltProfileExtensions {
macos_preferences: MacOsPreferencesPermission::ReadWrite,
macos_automation: MacOsAutomationPermission::BundleIds(vec![
"com.apple.Notes".to_string(),
"com.apple.Calendar".to_string(),
]),
macos_accessibility: true,
macos_calendar: true,
};
let merged =
merge_macos_seatbelt_profile_extensions(Some(&base), Some(&requested)).expect("merge");
assert_eq!(
merged,
MacOsSeatbeltProfileExtensions {
macos_preferences: MacOsPreferencesPermission::ReadWrite,
macos_automation: MacOsAutomationPermission::BundleIds(vec![
"com.apple.Calendar".to_string(),
"com.apple.Notes".to_string(),
]),
macos_accessibility: true,
macos_calendar: true,
}
);
}
#[test]
fn union_macos_preferences_permission_does_not_downgrade() {
let base = MacOsPreferencesPermission::ReadWrite;
let requested = MacOsPreferencesPermission::ReadOnly;
let merged = union_macos_preferences_permission(&base, &requested);
assert_eq!(merged, MacOsPreferencesPermission::ReadWrite);
}
#[test]
fn union_macos_automation_permission_all_is_dominant() {
let base = MacOsAutomationPermission::BundleIds(vec!["com.apple.Notes".to_string()]);
let requested = MacOsAutomationPermission::All;
let merged = union_macos_automation_permission(&base, &requested);
assert_eq!(merged, MacOsAutomationPermission::All);
}
}

View file

@ -6,6 +6,8 @@ sandbox placement and transformation of portable CommandSpec into a
readytospawn environment.
*/
pub(crate) mod macos_permissions;
use crate::exec::ExecExpiration;
use crate::exec::ExecToolCallOutput;
use crate::exec::SandboxType;
@ -25,13 +27,13 @@ use crate::tools::sandboxing::SandboxablePreference;
use codex_network_proxy::NetworkProxy;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::models::FileSystemPermissions;
#[cfg(target_os = "macos")]
use codex_protocol::models::MacOsSeatbeltProfileExtensions;
use codex_protocol::models::PermissionProfile;
pub use codex_protocol::models::SandboxPermissions;
use codex_protocol::protocol::ReadOnlyAccess;
use codex_utils_absolute_path::AbsolutePathBuf;
use dunce::canonicalize;
use macos_permissions::merge_macos_seatbelt_profile_extensions;
use std::collections::HashMap;
use std::collections::HashSet;
use std::path::Path;
@ -98,22 +100,54 @@ pub(crate) enum SandboxTransformError {
SeatbeltUnavailable,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct EffectiveSandboxPermissions {
pub(crate) sandbox_policy: SandboxPolicy,
pub(crate) macos_seatbelt_profile_extensions: Option<MacOsSeatbeltProfileExtensions>,
}
impl EffectiveSandboxPermissions {
pub(crate) fn new(
sandbox_policy: &SandboxPolicy,
macos_seatbelt_profile_extensions: Option<&MacOsSeatbeltProfileExtensions>,
additional_permissions: Option<&PermissionProfile>,
) -> Self {
let Some(additional_permissions) = additional_permissions else {
return Self {
sandbox_policy: sandbox_policy.clone(),
macos_seatbelt_profile_extensions: macos_seatbelt_profile_extensions.cloned(),
};
};
Self {
sandbox_policy: sandbox_policy_with_additional_permissions(
sandbox_policy,
additional_permissions,
),
macos_seatbelt_profile_extensions: merge_macos_seatbelt_profile_extensions(
macos_seatbelt_profile_extensions,
additional_permissions.macos.as_ref(),
),
}
}
}
pub(crate) fn normalize_additional_permissions(
additional_permissions: PermissionProfile,
) -> Result<PermissionProfile, String> {
let Some(file_system) = additional_permissions.file_system else {
return Ok(PermissionProfile::default());
};
let read = file_system
.read
.map(|paths| normalize_permission_paths(paths, "file_system.read"));
let write = file_system
.write
.map(|paths| normalize_permission_paths(paths, "file_system.write"));
Ok(PermissionProfile {
network: additional_permissions.network,
file_system: Some(FileSystemPermissions { read, write }),
..Default::default()
file_system: additional_permissions
.file_system
.map(|file_system| FileSystemPermissions {
read: file_system
.read
.map(|paths| normalize_permission_paths(paths, "file_system.read")),
write: file_system
.write
.map(|paths| normalize_permission_paths(paths, "file_system.write")),
}),
macos: additional_permissions.macos,
})
}
@ -204,14 +238,14 @@ fn merge_network_access(
fn sandbox_policy_with_additional_permissions(
sandbox_policy: &SandboxPolicy,
additional_permissions: &PermissionProfile,
) -> Result<SandboxPolicy, SandboxTransformError> {
) -> SandboxPolicy {
if additional_permissions.is_empty() {
return Ok(sandbox_policy.clone());
return sandbox_policy.clone();
}
let (extra_reads, extra_writes) = additional_permission_roots(additional_permissions);
let policy = match sandbox_policy {
match sandbox_policy {
SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => {
sandbox_policy.clone()
}
@ -260,9 +294,7 @@ fn sandbox_policy_with_additional_permissions(
}
}
}
};
Ok(policy)
}
}
#[derive(Default)]
@ -326,14 +358,18 @@ impl SandboxManager {
use_linux_sandbox_bwrap,
windows_sandbox_level,
} = request;
let effective_policy =
if let Some(additional_permissions) = spec.additional_permissions.take() {
sandbox_policy_with_additional_permissions(policy, &additional_permissions)?
} else {
policy.clone()
};
#[cfg(not(target_os = "macos"))]
let macos_seatbelt_profile_extensions = None;
let effective_permissions = EffectiveSandboxPermissions::new(
policy,
macos_seatbelt_profile_extensions,
spec.additional_permissions.as_ref(),
);
let mut env = spec.env;
if !effective_policy.has_full_network_access() {
if !effective_permissions
.sandbox_policy
.has_full_network_access()
{
env.insert(
CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR.to_string(),
"1".to_string(),
@ -352,11 +388,13 @@ impl SandboxManager {
seatbelt_env.insert(CODEX_SANDBOX_ENV_VAR.to_string(), "seatbelt".to_string());
let mut args = create_seatbelt_command_args_with_extensions(
command.clone(),
&effective_policy,
&effective_permissions.sandbox_policy,
sandbox_policy_cwd,
enforce_managed_network,
network,
macos_seatbelt_profile_extensions,
effective_permissions
.macos_seatbelt_profile_extensions
.as_ref(),
);
let mut full_command = Vec::with_capacity(1 + args.len());
full_command.push(MACOS_PATH_TO_SEATBELT_EXECUTABLE.to_string());
@ -371,7 +409,7 @@ impl SandboxManager {
let allow_proxy_network = allow_network_for_proxy(enforce_managed_network);
let mut args = create_linux_sandbox_command_args(
command.clone(),
&effective_policy,
&effective_permissions.sandbox_policy,
sandbox_policy_cwd,
use_linux_sandbox_bwrap,
allow_proxy_network,
@ -406,7 +444,7 @@ impl SandboxManager {
sandbox,
windows_sandbox_level,
sandbox_permissions: spec.sandbox_permissions,
sandbox_policy: effective_policy,
sandbox_policy: effective_permissions.sandbox_policy,
justification: spec.justification,
arg0: arg0_override,
})
@ -436,6 +474,8 @@ pub async fn execute_exec_request_with_after_spawn(
#[cfg(test)]
mod tests {
#[cfg(target_os = "macos")]
use super::EffectiveSandboxPermissions;
use super::SandboxManager;
use super::normalize_additional_permissions;
use super::sandbox_policy_with_additional_permissions;
@ -445,6 +485,12 @@ mod tests {
use crate::tools::sandboxing::SandboxablePreference;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::models::FileSystemPermissions;
#[cfg(target_os = "macos")]
use codex_protocol::models::MacOsAutomationPermission;
#[cfg(target_os = "macos")]
use codex_protocol::models::MacOsPreferencesPermission;
#[cfg(target_os = "macos")]
use codex_protocol::models::MacOsSeatbeltProfileExtensions;
use codex_protocol::models::NetworkPermissions;
use codex_protocol::models::PermissionProfile;
use codex_utils_absolute_path::AbsolutePathBuf;
@ -511,6 +557,35 @@ mod tests {
);
}
#[cfg(target_os = "macos")]
#[test]
fn normalize_additional_permissions_preserves_macos_permissions() {
let permissions = normalize_additional_permissions(PermissionProfile {
macos: Some(MacOsSeatbeltProfileExtensions {
macos_preferences: MacOsPreferencesPermission::ReadWrite,
macos_automation: MacOsAutomationPermission::BundleIds(vec![
"com.apple.Notes".to_string(),
]),
macos_accessibility: true,
macos_calendar: true,
}),
..Default::default()
})
.expect("permissions");
assert_eq!(
permissions.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 read_only_additional_permissions_can_enable_network_without_writes() {
let temp_dir = TempDir::new().expect("create temp dir");
@ -536,8 +611,7 @@ mod tests {
}),
..Default::default()
},
)
.expect("policy");
);
assert_eq!(
policy,
@ -550,4 +624,59 @@ mod tests {
}
);
}
#[cfg(target_os = "macos")]
#[test]
fn effective_permissions_merge_macos_extensions_with_additional_permissions() {
let temp_dir = TempDir::new().expect("create temp dir");
let path = AbsolutePathBuf::from_absolute_path(
canonicalize(temp_dir.path()).expect("canonicalize temp dir"),
)
.expect("absolute temp dir");
let effective_permissions = EffectiveSandboxPermissions::new(
&SandboxPolicy::ReadOnly {
access: ReadOnlyAccess::Restricted {
include_platform_defaults: true,
readable_roots: vec![path.clone()],
},
network_access: false,
},
Some(&MacOsSeatbeltProfileExtensions {
macos_preferences: MacOsPreferencesPermission::ReadOnly,
macos_automation: MacOsAutomationPermission::BundleIds(vec![
"com.apple.Calendar".to_string(),
]),
macos_accessibility: false,
macos_calendar: false,
}),
Some(&PermissionProfile {
file_system: Some(FileSystemPermissions {
read: Some(vec![path]),
write: Some(Vec::new()),
}),
macos: Some(MacOsSeatbeltProfileExtensions {
macos_preferences: MacOsPreferencesPermission::ReadWrite,
macos_automation: MacOsAutomationPermission::BundleIds(vec![
"com.apple.Notes".to_string(),
]),
macos_accessibility: true,
macos_calendar: true,
}),
..Default::default()
}),
);
assert_eq!(
effective_permissions.macos_seatbelt_profile_extensions,
Some(MacOsSeatbeltProfileExtensions {
macos_preferences: MacOsPreferencesPermission::ReadWrite,
macos_automation: MacOsAutomationPermission::BundleIds(vec![
"com.apple.Calendar".to_string(),
"com.apple.Notes".to_string(),
]),
macos_accessibility: true,
macos_calendar: true,
})
);
}
}

View file

@ -866,6 +866,9 @@ mod tests {
use codex_config::CONFIG_TOML_FILE;
use codex_protocol::config_types::TrustLevel;
use codex_protocol::models::FileSystemPermissions;
use codex_protocol::models::MacOsAutomationPermission;
use codex_protocol::models::MacOsPreferencesPermission;
use codex_protocol::models::MacOsSeatbeltProfileExtensions;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::SkillScope;
use codex_utils_absolute_path::AbsolutePathBuf;
@ -1454,6 +1457,37 @@ permissions: {}
assert_eq!(outcome.skills[0].permission_profile, None);
}
#[test]
fn skill_metadata_parses_macos_permissions_yaml() {
let parsed = serde_yaml::from_str::<SkillMetadataFile>(
r#"
permissions:
macos:
macos_preferences: "read_write"
macos_automation:
- "com.apple.Notes"
macos_accessibility: true
macos_calendar: true
"#,
)
.expect("parse skill metadata");
assert_eq!(
parsed.permissions,
Some(PermissionProfile {
macos: Some(MacOsSeatbeltProfileExtensions {
macos_preferences: MacOsPreferencesPermission::ReadWrite,
macos_automation: MacOsAutomationPermission::BundleIds(vec![
"com.apple.Notes".to_string(),
]),
macos_accessibility: true,
macos_calendar: true,
}),
..Default::default()
})
);
}
#[cfg(target_os = "macos")]
#[tokio::test]
async fn loads_skill_macos_permissions_from_yaml() {
@ -1466,11 +1500,11 @@ permissions: {}
r#"
permissions:
macos:
preferences: "readwrite"
automations:
macos_preferences: "read_write"
macos_automation:
- "com.apple.Notes"
accessibility: true
calendar: true
macos_accessibility: true
macos_calendar: true
"#,
);
@ -1486,15 +1520,13 @@ permissions:
assert_eq!(
outcome.skills[0].permission_profile,
Some(PermissionProfile {
macos: Some(codex_protocol::models::MacOsPermissions {
preferences: Some(codex_protocol::models::MacOsPreferencesValue::Mode(
"readwrite".to_string(),
),),
automations: Some(codex_protocol::models::MacOsAutomationValue::BundleIds(
vec!["com.apple.Notes".to_string()],
)),
accessibility: Some(true),
calendar: Some(true),
macos: Some(MacOsSeatbeltProfileExtensions {
macos_preferences: MacOsPreferencesPermission::ReadWrite,
macos_automation: MacOsAutomationPermission::BundleIds(vec![
"com.apple.Notes".to_string()
],),
macos_accessibility: true,
macos_calendar: true,
}),
..Default::default()
})
@ -1513,11 +1545,11 @@ permissions:
r#"
permissions:
macos:
preferences: "readwrite"
automations:
macos_preferences: "read_write"
macos_automation:
- "com.apple.Notes"
accessibility: true
calendar: true
macos_accessibility: true
macos_calendar: true
"#,
);
@ -1533,15 +1565,13 @@ permissions:
assert_eq!(
outcome.skills[0].permission_profile,
Some(PermissionProfile {
macos: Some(codex_protocol::models::MacOsPermissions {
preferences: Some(codex_protocol::models::MacOsPreferencesValue::Mode(
"readwrite".to_string(),
)),
automations: Some(codex_protocol::models::MacOsAutomationValue::BundleIds(
vec!["com.apple.Notes".to_string()],
)),
accessibility: Some(true),
calendar: Some(true),
macos: Some(MacOsSeatbeltProfileExtensions {
macos_preferences: MacOsPreferencesPermission::ReadWrite,
macos_automation: MacOsAutomationPermission::BundleIds(vec![
"com.apple.Notes".to_string()
],),
macos_accessibility: true,
macos_calendar: true,
}),
..Default::default()
})

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(),
);
}

View file

@ -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)) => {

View file

@ -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
);
}

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]

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!({

View file

@ -4,12 +4,17 @@ Commands may require user approval before execution. Prefer requesting sandboxed
## Preferred request mode
When you need extra filesystem access for one command, use:
When you need extra sandboxed permissions for one command, use:
- `sandbox_permissions: "with_additional_permissions"`
- `additional_permissions` with one or both fields:
- `additional_permissions` with one or more of:
- `network.enabled`: set to `true` to enable network access
- `file_system.read`: list of paths that need read access
- `file_system.write`: list of paths that need write access
- `macos.preferences`: `readonly` or `readwrite`
- `macos.automations`: list of bundle IDs that need Apple Events access
- `macos.accessibility`: set to `true` to allow accessibility APIs
- `macos.calendar`: set to `true` to allow Calendar access
This keeps execution inside the current sandbox policy, while adding only the requested permissions for that command, unless an exec-policy allow rule applies and authorizes running the command outside the sandbox.

View file

@ -19,6 +19,8 @@ use crate::render::renderable::Renderable;
use codex_core::features::Features;
use codex_protocol::ThreadId;
use codex_protocol::mcp::RequestId;
use codex_protocol::models::MacOsAutomationPermission;
use codex_protocol::models::MacOsPreferencesPermission;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::ElicitationAction;
use codex_protocol::protocol::FileChange;
@ -668,6 +670,36 @@ fn format_additional_permissions_rule(
parts.push(format!("write {writes}"));
}
}
if let Some(macos) = additional_permissions.macos.as_ref() {
if !matches!(
macos.macos_preferences,
MacOsPreferencesPermission::ReadOnly
) {
let value = match macos.macos_preferences {
MacOsPreferencesPermission::ReadOnly => "readonly",
MacOsPreferencesPermission::ReadWrite => "readwrite",
MacOsPreferencesPermission::None => "none",
};
parts.push(format!("macOS preferences {value}"));
}
match &macos.macos_automation {
MacOsAutomationPermission::All => {
parts.push("macOS automation all".to_string());
}
MacOsAutomationPermission::BundleIds(bundle_ids) => {
if !bundle_ids.is_empty() {
parts.push(format!("macOS automation {}", bundle_ids.join(", ")));
}
}
MacOsAutomationPermission::None => {}
}
if macos.macos_accessibility {
parts.push("macOS accessibility".to_string());
}
if macos.macos_calendar {
parts.push("macOS calendar".to_string());
}
}
if parts.is_empty() {
None
@ -727,6 +759,9 @@ mod tests {
use super::*;
use crate::app_event::AppEvent;
use codex_protocol::models::FileSystemPermissions;
use codex_protocol::models::MacOsAutomationPermission;
use codex_protocol::models::MacOsPreferencesPermission;
use codex_protocol::models::MacOsSeatbeltProfileExtensions;
use codex_protocol::models::NetworkPermissions;
use codex_protocol::protocol::ExecPolicyAmendment;
use codex_protocol::protocol::NetworkApprovalProtocol;
@ -1150,6 +1185,39 @@ mod tests {
);
}
#[test]
fn additional_permissions_macos_prompt_snapshot() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx);
let exec_request = ApprovalRequest::Exec {
thread_id: ThreadId::new(),
thread_label: None,
id: "test".into(),
command: vec!["osascript".into(), "-e".into(), "tell application".into()],
reason: Some("need macOS automation".into()),
available_decisions: vec![ReviewDecision::Approved, ReviewDecision::Abort],
network_approval_context: None,
additional_permissions: Some(PermissionProfile {
macos: Some(MacOsSeatbeltProfileExtensions {
macos_preferences: MacOsPreferencesPermission::ReadWrite,
macos_automation: MacOsAutomationPermission::BundleIds(vec![
"com.apple.Calendar".to_string(),
"com.apple.Notes".to_string(),
]),
macos_accessibility: true,
macos_calendar: true,
}),
..Default::default()
}),
};
let view = ApprovalOverlay::new(exec_request, tx, Features::with_defaults());
assert_snapshot!(
"approval_overlay_additional_permissions_macos_prompt",
render_overlay_lines(&view, 120)
);
}
#[test]
fn network_exec_prompt_title_includes_host() {
let (tx, _rx) = unbounded_channel::<AppEvent>();

View file

@ -0,0 +1,18 @@
---
source: tui/src/bottom_pane/approval_overlay.rs
expression: "render_overlay_lines(&view, 120)"
---
Would you like to run the following command?
Reason: need macOS automation
Permission rule: macOS preferences readwrite; macOS automation com.apple.Calendar, com.apple.Notes; macOS
accessibility; macOS calendar
$ osascript -e 'tell application'
1. Yes, proceed (y)
2. No, and tell Codex what to do differently (esc)
Press enter to confirm or esc to cancel