From aaefee04cd62bd54d248901e670a78285dc0748d Mon Sep 17 00:00:00 2001 From: Celia Chen Date: Thu, 5 Mar 2026 16:21:45 -0800 Subject: [PATCH] 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. --- ...CommandExecutionRequestApprovalParams.json | 76 ++++--- .../schema/json/EventMsg.json | 90 ++++----- .../schema/json/ServerRequest.json | 76 ++++--- .../codex_app_server_protocol.schemas.json | 124 +++++------- .../codex_app_server_protocol.v2.schemas.json | 90 ++++----- ...sValue.ts => MacOsAutomationPermission.ts} | 2 +- .../schema/typescript/MacOsPermissions.ts | 7 - ...Value.ts => MacOsPreferencesPermission.ts} | 2 +- .../MacOsSeatbeltProfileExtensions.ts | 7 + .../schema/typescript/PermissionProfile.ts | 4 +- .../schema/typescript/index.ts | 6 +- .../v2/AdditionalMacOsPermissions.ts | 6 +- .../app-server-protocol/src/protocol/v2.rs | 26 +-- .../core/src/sandboxing/macos_permissions.rs | 144 +++++++++++++ codex-rs/core/src/sandboxing/mod.rs | 191 +++++++++++++++--- codex-rs/core/src/skills/loader.rs | 82 +++++--- codex-rs/core/src/tools/handlers/mod.rs | 10 +- .../tools/runtimes/shell/unix_escalation.rs | 5 +- .../runtimes/shell/unix_escalation_tests.rs | 64 ++++++ codex-rs/core/src/tools/spec.rs | 130 +++++++++--- codex-rs/protocol/src/models.rs | 155 ++++++++++++-- .../on_request_rule_request_permission.md | 9 +- .../tui/src/bottom_pane/approval_overlay.rs | 68 +++++++ ...y_additional_permissions_macos_prompt.snap | 18 ++ 24 files changed, 1013 insertions(+), 379 deletions(-) rename codex-rs/app-server-protocol/schema/typescript/{MacOsPreferencesValue.ts => MacOsAutomationPermission.ts} (62%) delete mode 100644 codex-rs/app-server-protocol/schema/typescript/MacOsPermissions.ts rename codex-rs/app-server-protocol/schema/typescript/{MacOsAutomationValue.ts => MacOsPreferencesPermission.ts} (66%) create mode 100644 codex-rs/app-server-protocol/schema/typescript/MacOsSeatbeltProfileExtensions.ts create mode 100644 codex-rs/core/src/sandboxing/macos_permissions.rs create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__approval_overlay__tests__approval_overlay_additional_permissions_macos_prompt.snap diff --git a/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json b/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json index 891946fd9..fad972da4 100644 --- a/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json +++ b/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json @@ -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": { diff --git a/codex-rs/app-server-protocol/schema/json/EventMsg.json b/codex-rs/app-server-protocol/schema/json/EventMsg.json index 26f15b8fe..f52905c96 100644 --- a/codex-rs/app-server-protocol/schema/json/EventMsg.json +++ b/codex-rs/app-server-protocol/schema/json/EventMsg.json @@ -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" diff --git a/codex-rs/app-server-protocol/schema/json/ServerRequest.json b/codex-rs/app-server-protocol/schema/json/ServerRequest.json index 8abf14095..ef7f673dc 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ServerRequest.json @@ -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": [ diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 439023b07..c14ca1acd 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -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" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 5573321e7..5a783a538 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -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" diff --git a/codex-rs/app-server-protocol/schema/typescript/MacOsPreferencesValue.ts b/codex-rs/app-server-protocol/schema/typescript/MacOsAutomationPermission.ts similarity index 62% rename from codex-rs/app-server-protocol/schema/typescript/MacOsPreferencesValue.ts rename to codex-rs/app-server-protocol/schema/typescript/MacOsAutomationPermission.ts index 74a67ca1c..31036b23e 100644 --- a/codex-rs/app-server-protocol/schema/typescript/MacOsPreferencesValue.ts +++ b/codex-rs/app-server-protocol/schema/typescript/MacOsAutomationPermission.ts @@ -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 }; diff --git a/codex-rs/app-server-protocol/schema/typescript/MacOsPermissions.ts b/codex-rs/app-server-protocol/schema/typescript/MacOsPermissions.ts deleted file mode 100644 index 5c0792412..000000000 --- a/codex-rs/app-server-protocol/schema/typescript/MacOsPermissions.ts +++ /dev/null @@ -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, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/MacOsAutomationValue.ts b/codex-rs/app-server-protocol/schema/typescript/MacOsPreferencesPermission.ts similarity index 66% rename from codex-rs/app-server-protocol/schema/typescript/MacOsAutomationValue.ts rename to codex-rs/app-server-protocol/schema/typescript/MacOsPreferencesPermission.ts index e351c319d..2f5234a26 100644 --- a/codex-rs/app-server-protocol/schema/typescript/MacOsAutomationValue.ts +++ b/codex-rs/app-server-protocol/schema/typescript/MacOsPreferencesPermission.ts @@ -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; +export type MacOsPreferencesPermission = "none" | "read_only" | "read_write"; diff --git a/codex-rs/app-server-protocol/schema/typescript/MacOsSeatbeltProfileExtensions.ts b/codex-rs/app-server-protocol/schema/typescript/MacOsSeatbeltProfileExtensions.ts new file mode 100644 index 000000000..91d83df60 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/MacOsSeatbeltProfileExtensions.ts @@ -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, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/PermissionProfile.ts b/codex-rs/app-server-protocol/schema/typescript/PermissionProfile.ts index c9a60f067..a81fd86b5 100644 --- a/codex-rs/app-server-protocol/schema/typescript/PermissionProfile.ts +++ b/codex-rs/app-server-protocol/schema/typescript/PermissionProfile.ts @@ -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, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 21272ecf2..67b98c394 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -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"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AdditionalMacOsPermissions.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AdditionalMacOsPermissions.ts index eae1ad810..4030294f3 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/AdditionalMacOsPermissions.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AdditionalMacOsPermissions.ts @@ -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, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index ce2e6b27e..a0b16b128 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -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 for AdditionalFileSystemPermissions { #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct AdditionalMacOsPermissions { - pub preferences: Option, - pub automations: Option, - pub accessibility: Option, - pub calendar: Option, + pub preferences: CoreMacOsPreferencesPermission, + pub automations: CoreMacOsAutomationPermission, + pub accessibility: bool, + pub calendar: bool, } -impl From for AdditionalMacOsPermissions { - fn from(value: CoreMacOsPermissions) -> Self { +impl From 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, } } } diff --git a/codex-rs/core/src/sandboxing/macos_permissions.rs b/codex-rs/core/src/sandboxing/macos_permissions.rs new file mode 100644 index 000000000..3dfe8d6c9 --- /dev/null +++ b/codex-rs/core/src/sandboxing/macos_permissions.rs @@ -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 { + 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::>() + .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); + } +} diff --git a/codex-rs/core/src/sandboxing/mod.rs b/codex-rs/core/src/sandboxing/mod.rs index 8cbb18e90..9258889c7 100644 --- a/codex-rs/core/src/sandboxing/mod.rs +++ b/codex-rs/core/src/sandboxing/mod.rs @@ -6,6 +6,8 @@ sandbox placement and transformation of portable CommandSpec into a ready‑to‑spawn 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, +} + +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 { - 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 { 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, + }) + ); + } } diff --git a/codex-rs/core/src/skills/loader.rs b/codex-rs/core/src/skills/loader.rs index 751fa0720..96b42e3a2 100644 --- a/codex-rs/core/src/skills/loader.rs +++ b/codex-rs/core/src/skills/loader.rs @@ -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::( + 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() }) diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index d98576809..086701d9c 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -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(), ); } diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs index 7cc8d47f3..e4f7c80ab 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -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)) => { diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs index e83992b09..5a94fa0fb 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs @@ -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 + ); +} diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index e04bb2b5c..b6eb592dc 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -253,7 +253,7 @@ fn create_approval_parameters(request_permission_enabled: bool) -> BTreeMap BTreeMap), } -#[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), } -#[derive(Debug, Clone, PartialEq, Eq, Default)] +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum MacOsAutomationPermissionDe { + Mode(String), + BundleIds(Vec), +} + +impl TryFrom 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 { + 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::>(); + 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, pub file_system: Option, - pub macos: Option, + pub macos: Option, } 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::("\"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::(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::("\"all\"") + .expect("deserialize all automation permission"); + let none = serde_json::from_str::("\"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!({ diff --git a/codex-rs/protocol/src/prompts/permissions/approval_policy/on_request_rule_request_permission.md b/codex-rs/protocol/src/prompts/permissions/approval_policy/on_request_rule_request_permission.md index 1c9a3853a..68a342bf3 100644 --- a/codex-rs/protocol/src/prompts/permissions/approval_policy/on_request_rule_request_permission.md +++ b/codex-rs/protocol/src/prompts/permissions/approval_policy/on_request_rule_request_permission.md @@ -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. diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index f5db62981..dd056b0c7 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -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::(); + 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::(); diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__approval_overlay__tests__approval_overlay_additional_permissions_macos_prompt.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__approval_overlay__tests__approval_overlay_additional_permissions_macos_prompt.snap new file mode 100644 index 000000000..32c0f2a30 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__approval_overlay__tests__approval_overlay_additional_permissions_macos_prompt.snap @@ -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