From e6b93841c585f8b56b2c9b38cb07708f278ea227 Mon Sep 17 00:00:00 2001 From: Jack Mousseau Date: Sun, 8 Mar 2026 20:23:06 -0700 Subject: [PATCH] Add request permissions tool (#13092) Adds a built-in `request_permissions` tool and wires it through the Codex core, protocol, and app-server layers so a running turn can ask the client for additional permissions instead of relying on a static session policy. The new flow emits a `RequestPermissions` event from core, tracks the pending request by call ID, forwards it through app-server v2 as an `item/permissions/requestApproval` request, and resumes the tool call once the client returns an approved subset of the requested permission profile. --- .../schema/json/EventMsg.json | 72 ++ .../PermissionsRequestApprovalParams.json | 164 ++++ .../PermissionsRequestApprovalResponse.json | 160 ++++ .../schema/json/ServerRequest.json | 54 ++ .../codex_app_server_protocol.schemas.json | 177 ++++ .../codex_app_server_protocol.v2.schemas.json | 36 + .../schema/typescript/EventMsg.ts | 3 +- .../typescript/RequestPermissionsEvent.ts | 15 + .../schema/typescript/ServerRequest.ts | 3 +- .../schema/typescript/index.ts | 1 + .../typescript/v2/GrantedMacOsPermissions.ts | 7 + .../typescript/v2/GrantedPermissionProfile.ts | 8 + .../v2/PermissionsRequestApprovalParams.ts | 6 + .../v2/PermissionsRequestApprovalResponse.ts | 6 + .../schema/typescript/v2/index.ts | 4 + .../src/protocol/common.rs | 6 + .../app-server-protocol/src/protocol/v2.rs | 248 ++++++ codex-rs/app-server/README.md | 50 +- .../app-server/src/bespoke_event_handling.rs | 235 ++++++ codex-rs/app-server/tests/common/lib.rs | 1 + codex-rs/app-server/tests/common/responses.rs | 20 + codex-rs/app-server/tests/suite/v2/mod.rs | 1 + .../tests/suite/v2/request_permissions.rs | 155 ++++ codex-rs/core/config.schema.json | 6 + codex-rs/core/src/codex.rs | 85 ++ codex-rs/core/src/codex_delegate.rs | 157 ++++ codex-rs/core/src/codex_tests.rs | 22 + codex-rs/core/src/features.rs | 23 + codex-rs/core/src/rollout/policy.rs | 1 + .../core/src/sandboxing/macos_permissions.rs | 88 ++ codex-rs/core/src/sandboxing/mod.rs | 237 +++++- codex-rs/core/src/state/turn.rs | 30 + codex-rs/core/src/tools/handlers/mod.rs | 56 +- .../src/tools/handlers/request_permissions.rs | 75 ++ codex-rs/core/src/tools/handlers/shell.rs | 27 +- .../core/src/tools/handlers/unified_exec.rs | 24 +- codex-rs/core/src/tools/spec.rs | 336 +++++--- codex-rs/core/src/unified_exec/mod.rs | 2 + .../core/src/unified_exec/process_manager.rs | 6 +- .../core/tests/suite/request_permissions.rs | 767 +++++++++++++++++- .../src/event_processor_with_human_output.rs | 2 + codex-rs/exec/src/lib.rs | 12 + codex-rs/mcp-server/src/codex_tool_runner.rs | 1 + codex-rs/protocol/src/lib.rs | 1 + codex-rs/protocol/src/models.rs | 28 + codex-rs/protocol/src/protocol.rs | 13 + codex-rs/protocol/src/request_permissions.rs | 30 + codex-rs/tui/src/chatwidget.rs | 1 + 48 files changed, 3332 insertions(+), 130 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json create mode 100644 codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalResponse.json create mode 100644 codex-rs/app-server-protocol/schema/typescript/RequestPermissionsEvent.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/GrantedMacOsPermissions.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/GrantedPermissionProfile.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/PermissionsRequestApprovalParams.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/PermissionsRequestApprovalResponse.ts create mode 100644 codex-rs/app-server/tests/suite/v2/request_permissions.rs create mode 100644 codex-rs/core/src/tools/handlers/request_permissions.rs create mode 100644 codex-rs/protocol/src/request_permissions.rs diff --git a/codex-rs/app-server-protocol/schema/json/EventMsg.json b/codex-rs/app-server-protocol/schema/json/EventMsg.json index 7396e358e..1a4bac2b0 100644 --- a/codex-rs/app-server-protocol/schema/json/EventMsg.json +++ b/codex-rs/app-server-protocol/schema/json/EventMsg.json @@ -1900,6 +1900,42 @@ "title": "ExecApprovalRequestEventMsg", "type": "object" }, + { + "properties": { + "call_id": { + "description": "Responses API call id for the associated tool call, if available.", + "type": "string" + }, + "permissions": { + "$ref": "#/definitions/PermissionProfile" + }, + "reason": { + "type": [ + "string", + "null" + ] + }, + "turn_id": { + "default": "", + "description": "Turn ID that this request belongs to. Uses `#[serde(default)]` for backwards compatibility.", + "type": "string" + }, + "type": { + "enum": [ + "request_permissions" + ], + "title": "RequestPermissionsEventMsgType", + "type": "string" + } + }, + "required": [ + "call_id", + "permissions", + "type" + ], + "title": "RequestPermissionsEventMsg", + "type": "object" + }, { "properties": { "call_id": { @@ -7734,6 +7770,42 @@ "title": "ExecApprovalRequestEventMsg", "type": "object" }, + { + "properties": { + "call_id": { + "description": "Responses API call id for the associated tool call, if available.", + "type": "string" + }, + "permissions": { + "$ref": "#/definitions/PermissionProfile" + }, + "reason": { + "type": [ + "string", + "null" + ] + }, + "turn_id": { + "default": "", + "description": "Turn ID that this request belongs to. Uses `#[serde(default)]` for backwards compatibility.", + "type": "string" + }, + "type": { + "enum": [ + "request_permissions" + ], + "title": "RequestPermissionsEventMsgType", + "type": "string" + } + }, + "required": [ + "call_id", + "permissions", + "type" + ], + "title": "RequestPermissionsEventMsg", + "type": "object" + }, { "properties": { "call_id": { diff --git a/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json b/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json new file mode 100644 index 000000000..f642c81cf --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json @@ -0,0 +1,164 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, + "AdditionalFileSystemPermissions": { + "properties": { + "read": { + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": [ + "array", + "null" + ] + }, + "write": { + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": [ + "array", + "null" + ] + } + }, + "type": "object" + }, + "AdditionalMacOsPermissions": { + "properties": { + "accessibility": { + "type": "boolean" + }, + "automations": { + "$ref": "#/definitions/MacOsAutomationPermission" + }, + "calendar": { + "type": "boolean" + }, + "preferences": { + "$ref": "#/definitions/MacOsPreferencesPermission" + } + }, + "required": [ + "accessibility", + "automations", + "calendar", + "preferences" + ], + "type": "object" + }, + "AdditionalNetworkPermissions": { + "properties": { + "enabled": { + "type": [ + "boolean", + "null" + ] + } + }, + "type": "object" + }, + "AdditionalPermissionProfile": { + "properties": { + "fileSystem": { + "anyOf": [ + { + "$ref": "#/definitions/AdditionalFileSystemPermissions" + }, + { + "type": "null" + } + ] + }, + "macos": { + "anyOf": [ + { + "$ref": "#/definitions/AdditionalMacOsPermissions" + }, + { + "type": "null" + } + ] + }, + "network": { + "anyOf": [ + { + "$ref": "#/definitions/AdditionalNetworkPermissions" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + "MacOsAutomationPermission": { + "oneOf": [ + { + "enum": [ + "none", + "all" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "bundle_ids": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "bundle_ids" + ], + "title": "BundleIdsMacOsAutomationPermission", + "type": "object" + } + ] + }, + "MacOsPreferencesPermission": { + "enum": [ + "none", + "read_only", + "read_write" + ], + "type": "string" + } + }, + "properties": { + "itemId": { + "type": "string" + }, + "permissions": { + "$ref": "#/definitions/AdditionalPermissionProfile" + }, + "reason": { + "type": [ + "string", + "null" + ] + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "itemId", + "permissions", + "threadId", + "turnId" + ], + "title": "PermissionsRequestApprovalParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalResponse.json b/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalResponse.json new file mode 100644 index 000000000..aca89004b --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalResponse.json @@ -0,0 +1,160 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, + "AdditionalFileSystemPermissions": { + "properties": { + "read": { + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": [ + "array", + "null" + ] + }, + "write": { + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": [ + "array", + "null" + ] + } + }, + "type": "object" + }, + "AdditionalNetworkPermissions": { + "properties": { + "enabled": { + "type": [ + "boolean", + "null" + ] + } + }, + "type": "object" + }, + "GrantedMacOsPermissions": { + "properties": { + "accessibility": { + "type": [ + "boolean", + "null" + ] + }, + "automations": { + "anyOf": [ + { + "$ref": "#/definitions/MacOsAutomationPermission" + }, + { + "type": "null" + } + ] + }, + "calendar": { + "type": [ + "boolean", + "null" + ] + }, + "preferences": { + "anyOf": [ + { + "$ref": "#/definitions/MacOsPreferencesPermission" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + "GrantedPermissionProfile": { + "properties": { + "fileSystem": { + "anyOf": [ + { + "$ref": "#/definitions/AdditionalFileSystemPermissions" + }, + { + "type": "null" + } + ] + }, + "macos": { + "anyOf": [ + { + "$ref": "#/definitions/GrantedMacOsPermissions" + }, + { + "type": "null" + } + ] + }, + "network": { + "anyOf": [ + { + "$ref": "#/definitions/AdditionalNetworkPermissions" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + "MacOsAutomationPermission": { + "oneOf": [ + { + "enum": [ + "none", + "all" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "bundle_ids": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "bundle_ids" + ], + "title": "BundleIdsMacOsAutomationPermission", + "type": "object" + } + ] + }, + "MacOsPreferencesPermission": { + "enum": [ + "none", + "read_only", + "read_write" + ], + "type": "string" + } + }, + "properties": { + "permissions": { + "$ref": "#/definitions/GrantedPermissionProfile" + } + }, + "required": [ + "permissions" + ], + "title": "PermissionsRequestApprovalResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/ServerRequest.json b/codex-rs/app-server-protocol/schema/json/ServerRequest.json index 7183de5d3..310b50171 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ServerRequest.json @@ -1423,6 +1423,35 @@ } ] }, + "PermissionsRequestApprovalParams": { + "properties": { + "itemId": { + "type": "string" + }, + "permissions": { + "$ref": "#/definitions/AdditionalPermissionProfile" + }, + "reason": { + "type": [ + "string", + "null" + ] + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "itemId", + "permissions", + "threadId", + "turnId" + ], + "type": "object" + }, "RequestId": { "anyOf": [ { @@ -1620,6 +1649,31 @@ "title": "McpServer/elicitation/requestRequest", "type": "object" }, + { + "description": "Request approval for additional permissions from the user.", + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "item/permissions/requestApproval" + ], + "title": "Item/permissions/requestApprovalRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/PermissionsRequestApprovalParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Item/permissions/requestApprovalRequest", + "type": "object" + }, { "description": "Execute a dynamic tool call on the client.", "properties": { 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 7129d3a4b..7aec38076 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 @@ -3227,6 +3227,42 @@ "title": "ExecApprovalRequestEventMsg", "type": "object" }, + { + "properties": { + "call_id": { + "description": "Responses API call id for the associated tool call, if available.", + "type": "string" + }, + "permissions": { + "$ref": "#/definitions/PermissionProfile" + }, + "reason": { + "type": [ + "string", + "null" + ] + }, + "turn_id": { + "default": "", + "description": "Turn ID that this request belongs to. Uses `#[serde(default)]` for backwards compatibility.", + "type": "string" + }, + "type": { + "enum": [ + "request_permissions" + ], + "title": "RequestPermissionsEventMsgType", + "type": "string" + } + }, + "required": [ + "call_id", + "permissions", + "type" + ], + "title": "RequestPermissionsEventMsg", + "type": "object" + }, { "properties": { "call_id": { @@ -5167,6 +5203,78 @@ "title": "FuzzyFileSearchSessionUpdatedNotification", "type": "object" }, + "GrantedMacOsPermissions": { + "properties": { + "accessibility": { + "type": [ + "boolean", + "null" + ] + }, + "automations": { + "anyOf": [ + { + "$ref": "#/definitions/MacOsAutomationPermission" + }, + { + "type": "null" + } + ] + }, + "calendar": { + "type": [ + "boolean", + "null" + ] + }, + "preferences": { + "anyOf": [ + { + "$ref": "#/definitions/MacOsPreferencesPermission" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + "GrantedPermissionProfile": { + "properties": { + "fileSystem": { + "anyOf": [ + { + "$ref": "#/definitions/AdditionalFileSystemPermissions" + }, + { + "type": "null" + } + ] + }, + "macos": { + "anyOf": [ + { + "$ref": "#/definitions/GrantedMacOsPermissions" + }, + { + "type": "null" + } + ] + }, + "network": { + "anyOf": [ + { + "$ref": "#/definitions/AdditionalNetworkPermissions" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, "HistoryEntry": { "properties": { "conversation_id": { @@ -6365,6 +6473,50 @@ }, "type": "object" }, + "PermissionsRequestApprovalParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "itemId": { + "type": "string" + }, + "permissions": { + "$ref": "#/definitions/AdditionalPermissionProfile" + }, + "reason": { + "type": [ + "string", + "null" + ] + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "itemId", + "permissions", + "threadId", + "turnId" + ], + "title": "PermissionsRequestApprovalParams", + "type": "object" + }, + "PermissionsRequestApprovalResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "permissions": { + "$ref": "#/definitions/GrantedPermissionProfile" + } + }, + "required": [ + "permissions" + ], + "title": "PermissionsRequestApprovalResponse", + "type": "object" + }, "PlanItemArg": { "additionalProperties": false, "properties": { @@ -7839,6 +7991,31 @@ "title": "McpServer/elicitation/requestRequest", "type": "object" }, + { + "description": "Request approval for additional permissions from the user.", + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "item/permissions/requestApproval" + ], + "title": "Item/permissions/requestApprovalRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/PermissionsRequestApprovalParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Item/permissions/requestApprovalRequest", + "type": "object" + }, { "description": "Execute a dynamic tool call on the client.", "properties": { 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 88f4d1de2..39408567d 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 @@ -5033,6 +5033,42 @@ "title": "ExecApprovalRequestEventMsg", "type": "object" }, + { + "properties": { + "call_id": { + "description": "Responses API call id for the associated tool call, if available.", + "type": "string" + }, + "permissions": { + "$ref": "#/definitions/PermissionProfile" + }, + "reason": { + "type": [ + "string", + "null" + ] + }, + "turn_id": { + "default": "", + "description": "Turn ID that this request belongs to. Uses `#[serde(default)]` for backwards compatibility.", + "type": "string" + }, + "type": { + "enum": [ + "request_permissions" + ], + "title": "RequestPermissionsEventMsgType", + "type": "string" + } + }, + "required": [ + "call_id", + "permissions", + "type" + ], + "title": "RequestPermissionsEventMsg", + "type": "object" + }, { "properties": { "call_id": { diff --git a/codex-rs/app-server-protocol/schema/typescript/EventMsg.ts b/codex-rs/app-server-protocol/schema/typescript/EventMsg.ts index 658a97ef1..a51bca719 100644 --- a/codex-rs/app-server-protocol/schema/typescript/EventMsg.ts +++ b/codex-rs/app-server-protocol/schema/typescript/EventMsg.ts @@ -56,6 +56,7 @@ import type { RealtimeConversationStartedEvent } from "./RealtimeConversationSta import type { ReasoningContentDeltaEvent } from "./ReasoningContentDeltaEvent"; import type { ReasoningRawContentDeltaEvent } from "./ReasoningRawContentDeltaEvent"; import type { RemoteSkillDownloadedEvent } from "./RemoteSkillDownloadedEvent"; +import type { RequestPermissionsEvent } from "./RequestPermissionsEvent"; import type { RequestUserInputEvent } from "./RequestUserInputEvent"; import type { ReviewRequest } from "./ReviewRequest"; import type { SessionConfiguredEvent } from "./SessionConfiguredEvent"; @@ -81,4 +82,4 @@ import type { WebSearchEndEvent } from "./WebSearchEndEvent"; * Response event from the agent * NOTE: Make sure none of these values have optional types, as it will mess up the extension code-gen. */ -export type EventMsg = { "type": "error" } & ErrorEvent | { "type": "warning" } & WarningEvent | { "type": "realtime_conversation_started" } & RealtimeConversationStartedEvent | { "type": "realtime_conversation_realtime" } & RealtimeConversationRealtimeEvent | { "type": "realtime_conversation_closed" } & RealtimeConversationClosedEvent | { "type": "model_reroute" } & ModelRerouteEvent | { "type": "context_compacted" } & ContextCompactedEvent | { "type": "thread_rolled_back" } & ThreadRolledBackEvent | { "type": "task_started" } & TurnStartedEvent | { "type": "task_complete" } & TurnCompleteEvent | { "type": "token_count" } & TokenCountEvent | { "type": "agent_message" } & AgentMessageEvent | { "type": "user_message" } & UserMessageEvent | { "type": "agent_message_delta" } & AgentMessageDeltaEvent | { "type": "agent_reasoning" } & AgentReasoningEvent | { "type": "agent_reasoning_delta" } & AgentReasoningDeltaEvent | { "type": "agent_reasoning_raw_content" } & AgentReasoningRawContentEvent | { "type": "agent_reasoning_raw_content_delta" } & AgentReasoningRawContentDeltaEvent | { "type": "agent_reasoning_section_break" } & AgentReasoningSectionBreakEvent | { "type": "session_configured" } & SessionConfiguredEvent | { "type": "thread_name_updated" } & ThreadNameUpdatedEvent | { "type": "mcp_startup_update" } & McpStartupUpdateEvent | { "type": "mcp_startup_complete" } & McpStartupCompleteEvent | { "type": "mcp_tool_call_begin" } & McpToolCallBeginEvent | { "type": "mcp_tool_call_end" } & McpToolCallEndEvent | { "type": "web_search_begin" } & WebSearchBeginEvent | { "type": "web_search_end" } & WebSearchEndEvent | { "type": "image_generation_begin" } & ImageGenerationBeginEvent | { "type": "image_generation_end" } & ImageGenerationEndEvent | { "type": "exec_command_begin" } & ExecCommandBeginEvent | { "type": "exec_command_output_delta" } & ExecCommandOutputDeltaEvent | { "type": "terminal_interaction" } & TerminalInteractionEvent | { "type": "exec_command_end" } & ExecCommandEndEvent | { "type": "view_image_tool_call" } & ViewImageToolCallEvent | { "type": "exec_approval_request" } & ExecApprovalRequestEvent | { "type": "request_user_input" } & RequestUserInputEvent | { "type": "dynamic_tool_call_request" } & DynamicToolCallRequest | { "type": "dynamic_tool_call_response" } & DynamicToolCallResponseEvent | { "type": "elicitation_request" } & ElicitationRequestEvent | { "type": "apply_patch_approval_request" } & ApplyPatchApprovalRequestEvent | { "type": "deprecation_notice" } & DeprecationNoticeEvent | { "type": "background_event" } & BackgroundEventEvent | { "type": "undo_started" } & UndoStartedEvent | { "type": "undo_completed" } & UndoCompletedEvent | { "type": "stream_error" } & StreamErrorEvent | { "type": "patch_apply_begin" } & PatchApplyBeginEvent | { "type": "patch_apply_end" } & PatchApplyEndEvent | { "type": "turn_diff" } & TurnDiffEvent | { "type": "get_history_entry_response" } & GetHistoryEntryResponseEvent | { "type": "mcp_list_tools_response" } & McpListToolsResponseEvent | { "type": "list_custom_prompts_response" } & ListCustomPromptsResponseEvent | { "type": "list_skills_response" } & ListSkillsResponseEvent | { "type": "list_remote_skills_response" } & ListRemoteSkillsResponseEvent | { "type": "remote_skill_downloaded" } & RemoteSkillDownloadedEvent | { "type": "skills_update_available" } | { "type": "plan_update" } & UpdatePlanArgs | { "type": "turn_aborted" } & TurnAbortedEvent | { "type": "shutdown_complete" } | { "type": "entered_review_mode" } & ReviewRequest | { "type": "exited_review_mode" } & ExitedReviewModeEvent | { "type": "raw_response_item" } & RawResponseItemEvent | { "type": "item_started" } & ItemStartedEvent | { "type": "item_completed" } & ItemCompletedEvent | { "type": "agent_message_content_delta" } & AgentMessageContentDeltaEvent | { "type": "plan_delta" } & PlanDeltaEvent | { "type": "reasoning_content_delta" } & ReasoningContentDeltaEvent | { "type": "reasoning_raw_content_delta" } & ReasoningRawContentDeltaEvent | { "type": "collab_agent_spawn_begin" } & CollabAgentSpawnBeginEvent | { "type": "collab_agent_spawn_end" } & CollabAgentSpawnEndEvent | { "type": "collab_agent_interaction_begin" } & CollabAgentInteractionBeginEvent | { "type": "collab_agent_interaction_end" } & CollabAgentInteractionEndEvent | { "type": "collab_waiting_begin" } & CollabWaitingBeginEvent | { "type": "collab_waiting_end" } & CollabWaitingEndEvent | { "type": "collab_close_begin" } & CollabCloseBeginEvent | { "type": "collab_close_end" } & CollabCloseEndEvent | { "type": "collab_resume_begin" } & CollabResumeBeginEvent | { "type": "collab_resume_end" } & CollabResumeEndEvent; +export type EventMsg = { "type": "error" } & ErrorEvent | { "type": "warning" } & WarningEvent | { "type": "realtime_conversation_started" } & RealtimeConversationStartedEvent | { "type": "realtime_conversation_realtime" } & RealtimeConversationRealtimeEvent | { "type": "realtime_conversation_closed" } & RealtimeConversationClosedEvent | { "type": "model_reroute" } & ModelRerouteEvent | { "type": "context_compacted" } & ContextCompactedEvent | { "type": "thread_rolled_back" } & ThreadRolledBackEvent | { "type": "task_started" } & TurnStartedEvent | { "type": "task_complete" } & TurnCompleteEvent | { "type": "token_count" } & TokenCountEvent | { "type": "agent_message" } & AgentMessageEvent | { "type": "user_message" } & UserMessageEvent | { "type": "agent_message_delta" } & AgentMessageDeltaEvent | { "type": "agent_reasoning" } & AgentReasoningEvent | { "type": "agent_reasoning_delta" } & AgentReasoningDeltaEvent | { "type": "agent_reasoning_raw_content" } & AgentReasoningRawContentEvent | { "type": "agent_reasoning_raw_content_delta" } & AgentReasoningRawContentDeltaEvent | { "type": "agent_reasoning_section_break" } & AgentReasoningSectionBreakEvent | { "type": "session_configured" } & SessionConfiguredEvent | { "type": "thread_name_updated" } & ThreadNameUpdatedEvent | { "type": "mcp_startup_update" } & McpStartupUpdateEvent | { "type": "mcp_startup_complete" } & McpStartupCompleteEvent | { "type": "mcp_tool_call_begin" } & McpToolCallBeginEvent | { "type": "mcp_tool_call_end" } & McpToolCallEndEvent | { "type": "web_search_begin" } & WebSearchBeginEvent | { "type": "web_search_end" } & WebSearchEndEvent | { "type": "image_generation_begin" } & ImageGenerationBeginEvent | { "type": "image_generation_end" } & ImageGenerationEndEvent | { "type": "exec_command_begin" } & ExecCommandBeginEvent | { "type": "exec_command_output_delta" } & ExecCommandOutputDeltaEvent | { "type": "terminal_interaction" } & TerminalInteractionEvent | { "type": "exec_command_end" } & ExecCommandEndEvent | { "type": "view_image_tool_call" } & ViewImageToolCallEvent | { "type": "exec_approval_request" } & ExecApprovalRequestEvent | { "type": "request_permissions" } & RequestPermissionsEvent | { "type": "request_user_input" } & RequestUserInputEvent | { "type": "dynamic_tool_call_request" } & DynamicToolCallRequest | { "type": "dynamic_tool_call_response" } & DynamicToolCallResponseEvent | { "type": "elicitation_request" } & ElicitationRequestEvent | { "type": "apply_patch_approval_request" } & ApplyPatchApprovalRequestEvent | { "type": "deprecation_notice" } & DeprecationNoticeEvent | { "type": "background_event" } & BackgroundEventEvent | { "type": "undo_started" } & UndoStartedEvent | { "type": "undo_completed" } & UndoCompletedEvent | { "type": "stream_error" } & StreamErrorEvent | { "type": "patch_apply_begin" } & PatchApplyBeginEvent | { "type": "patch_apply_end" } & PatchApplyEndEvent | { "type": "turn_diff" } & TurnDiffEvent | { "type": "get_history_entry_response" } & GetHistoryEntryResponseEvent | { "type": "mcp_list_tools_response" } & McpListToolsResponseEvent | { "type": "list_custom_prompts_response" } & ListCustomPromptsResponseEvent | { "type": "list_skills_response" } & ListSkillsResponseEvent | { "type": "list_remote_skills_response" } & ListRemoteSkillsResponseEvent | { "type": "remote_skill_downloaded" } & RemoteSkillDownloadedEvent | { "type": "skills_update_available" } | { "type": "plan_update" } & UpdatePlanArgs | { "type": "turn_aborted" } & TurnAbortedEvent | { "type": "shutdown_complete" } | { "type": "entered_review_mode" } & ReviewRequest | { "type": "exited_review_mode" } & ExitedReviewModeEvent | { "type": "raw_response_item" } & RawResponseItemEvent | { "type": "item_started" } & ItemStartedEvent | { "type": "item_completed" } & ItemCompletedEvent | { "type": "agent_message_content_delta" } & AgentMessageContentDeltaEvent | { "type": "plan_delta" } & PlanDeltaEvent | { "type": "reasoning_content_delta" } & ReasoningContentDeltaEvent | { "type": "reasoning_raw_content_delta" } & ReasoningRawContentDeltaEvent | { "type": "collab_agent_spawn_begin" } & CollabAgentSpawnBeginEvent | { "type": "collab_agent_spawn_end" } & CollabAgentSpawnEndEvent | { "type": "collab_agent_interaction_begin" } & CollabAgentInteractionBeginEvent | { "type": "collab_agent_interaction_end" } & CollabAgentInteractionEndEvent | { "type": "collab_waiting_begin" } & CollabWaitingBeginEvent | { "type": "collab_waiting_end" } & CollabWaitingEndEvent | { "type": "collab_close_begin" } & CollabCloseBeginEvent | { "type": "collab_close_end" } & CollabCloseEndEvent | { "type": "collab_resume_begin" } & CollabResumeBeginEvent | { "type": "collab_resume_end" } & CollabResumeEndEvent; diff --git a/codex-rs/app-server-protocol/schema/typescript/RequestPermissionsEvent.ts b/codex-rs/app-server-protocol/schema/typescript/RequestPermissionsEvent.ts new file mode 100644 index 000000000..33a109f46 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/RequestPermissionsEvent.ts @@ -0,0 +1,15 @@ +// 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 { PermissionProfile } from "./PermissionProfile"; + +export type RequestPermissionsEvent = { +/** + * Responses API call id for the associated tool call, if available. + */ +call_id: string, +/** + * Turn ID that this request belongs to. + * Uses `#[serde(default)]` for backwards compatibility. + */ +turn_id: string, reason: string | null, permissions: PermissionProfile, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/ServerRequest.ts b/codex-rs/app-server-protocol/schema/typescript/ServerRequest.ts index 107c5eebc..13d04b0be 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ServerRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ServerRequest.ts @@ -9,9 +9,10 @@ import type { CommandExecutionRequestApprovalParams } from "./v2/CommandExecutio import type { DynamicToolCallParams } from "./v2/DynamicToolCallParams"; import type { FileChangeRequestApprovalParams } from "./v2/FileChangeRequestApprovalParams"; import type { McpServerElicitationRequestParams } from "./v2/McpServerElicitationRequestParams"; +import type { PermissionsRequestApprovalParams } from "./v2/PermissionsRequestApprovalParams"; import type { ToolRequestUserInputParams } from "./v2/ToolRequestUserInputParams"; /** * Request initiated from the server and sent to the client. */ -export type ServerRequest = { "method": "item/commandExecution/requestApproval", id: RequestId, params: CommandExecutionRequestApprovalParams, } | { "method": "item/fileChange/requestApproval", id: RequestId, params: FileChangeRequestApprovalParams, } | { "method": "item/tool/requestUserInput", id: RequestId, params: ToolRequestUserInputParams, } | { "method": "mcpServer/elicitation/request", id: RequestId, params: McpServerElicitationRequestParams, } | { "method": "item/tool/call", id: RequestId, params: DynamicToolCallParams, } | { "method": "account/chatgptAuthTokens/refresh", id: RequestId, params: ChatgptAuthTokensRefreshParams, } | { "method": "applyPatchApproval", id: RequestId, params: ApplyPatchApprovalParams, } | { "method": "execCommandApproval", id: RequestId, params: ExecCommandApprovalParams, }; +export type ServerRequest = { "method": "item/commandExecution/requestApproval", id: RequestId, params: CommandExecutionRequestApprovalParams, } | { "method": "item/fileChange/requestApproval", id: RequestId, params: FileChangeRequestApprovalParams, } | { "method": "item/tool/requestUserInput", id: RequestId, params: ToolRequestUserInputParams, } | { "method": "mcpServer/elicitation/request", id: RequestId, params: McpServerElicitationRequestParams, } | { "method": "item/permissions/requestApproval", id: RequestId, params: PermissionsRequestApprovalParams, } | { "method": "item/tool/call", id: RequestId, params: DynamicToolCallParams, } | { "method": "account/chatgptAuthTokens/refresh", id: RequestId, params: ChatgptAuthTokensRefreshParams, } | { "method": "applyPatchApproval", id: RequestId, params: ApplyPatchApprovalParams, } | { "method": "execCommandApproval", id: RequestId, params: ExecCommandApprovalParams, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 1a53466b4..54dea84bc 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -155,6 +155,7 @@ export type { RejectConfig } from "./RejectConfig"; export type { RemoteSkillDownloadedEvent } from "./RemoteSkillDownloadedEvent"; export type { RemoteSkillSummary } from "./RemoteSkillSummary"; export type { RequestId } from "./RequestId"; +export type { RequestPermissionsEvent } from "./RequestPermissionsEvent"; export type { RequestUserInputEvent } from "./RequestUserInputEvent"; export type { RequestUserInputQuestion } from "./RequestUserInputQuestion"; export type { RequestUserInputQuestionOption } from "./RequestUserInputQuestionOption"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/GrantedMacOsPermissions.ts b/codex-rs/app-server-protocol/schema/typescript/v2/GrantedMacOsPermissions.ts new file mode 100644 index 000000000..b95a2940f --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/GrantedMacOsPermissions.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 GrantedMacOsPermissions = { preferences?: MacOsPreferencesPermission, automations?: MacOsAutomationPermission, accessibility?: boolean, calendar?: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/GrantedPermissionProfile.ts b/codex-rs/app-server-protocol/schema/typescript/v2/GrantedPermissionProfile.ts new file mode 100644 index 000000000..84a9aa377 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/GrantedPermissionProfile.ts @@ -0,0 +1,8 @@ +// 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 { AdditionalFileSystemPermissions } from "./AdditionalFileSystemPermissions"; +import type { AdditionalNetworkPermissions } from "./AdditionalNetworkPermissions"; +import type { GrantedMacOsPermissions } from "./GrantedMacOsPermissions"; + +export type GrantedPermissionProfile = { network?: AdditionalNetworkPermissions, fileSystem?: AdditionalFileSystemPermissions, macos?: GrantedMacOsPermissions, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionsRequestApprovalParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionsRequestApprovalParams.ts new file mode 100644 index 000000000..cde277f1a --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionsRequestApprovalParams.ts @@ -0,0 +1,6 @@ +// 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 { AdditionalPermissionProfile } from "./AdditionalPermissionProfile"; + +export type PermissionsRequestApprovalParams = { threadId: string, turnId: string, itemId: string, reason: string | null, permissions: AdditionalPermissionProfile, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionsRequestApprovalResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionsRequestApprovalResponse.ts new file mode 100644 index 000000000..b22cf3897 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionsRequestApprovalResponse.ts @@ -0,0 +1,6 @@ +// 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 { GrantedPermissionProfile } from "./GrantedPermissionProfile"; + +export type PermissionsRequestApprovalResponse = { permissions: GrantedPermissionProfile, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index e233c50e5..a4be44f40 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -99,6 +99,8 @@ export type { GetAccountParams } from "./GetAccountParams"; export type { GetAccountRateLimitsResponse } from "./GetAccountRateLimitsResponse"; export type { GetAccountResponse } from "./GetAccountResponse"; export type { GitInfo } from "./GitInfo"; +export type { GrantedMacOsPermissions } from "./GrantedMacOsPermissions"; +export type { GrantedPermissionProfile } from "./GrantedPermissionProfile"; export type { HazelnutScope } from "./HazelnutScope"; export type { ItemCompletedNotification } from "./ItemCompletedNotification"; export type { ItemStartedNotification } from "./ItemStartedNotification"; @@ -159,6 +161,8 @@ export type { NetworkRequirements } from "./NetworkRequirements"; export type { OverriddenMetadata } from "./OverriddenMetadata"; export type { PatchApplyStatus } from "./PatchApplyStatus"; export type { PatchChangeKind } from "./PatchChangeKind"; +export type { PermissionsRequestApprovalParams } from "./PermissionsRequestApprovalParams"; +export type { PermissionsRequestApprovalResponse } from "./PermissionsRequestApprovalResponse"; export type { PlanDeltaNotification } from "./PlanDeltaNotification"; export type { PluginInstallParams } from "./PluginInstallParams"; export type { PluginInstallResponse } from "./PluginInstallResponse"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index d32ebd9fb..c41fb977f 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -695,6 +695,12 @@ server_request_definitions! { response: v2::McpServerElicitationRequestResponse, }, + /// Request approval for additional permissions from the user. + PermissionsRequestApproval => "item/permissions/requestApproval" { + params: v2::PermissionsRequestApprovalParams, + response: v2::PermissionsRequestApprovalResponse, + }, + /// Execute a dynamic tool call on the client. DynamicToolCall => "item/tool/call" { params: v2::DynamicToolCallParams, diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index d75c0b1a4..4536c690a 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -837,6 +837,15 @@ impl From for AdditionalFileSystemPermissions { } } +impl From for CoreFileSystemPermissions { + fn from(value: AdditionalFileSystemPermissions) -> Self { + Self { + read: value.read, + write: value.write, + } + } +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -858,6 +867,17 @@ impl From for AdditionalMacOsPermissions { } } +impl From for CoreMacOsSeatbeltProfileExtensions { + fn from(value: AdditionalMacOsPermissions) -> Self { + Self { + macos_preferences: value.preferences, + macos_automation: value.automations, + macos_accessibility: value.accessibility, + macos_calendar: value.calendar, + } + } +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -873,6 +893,14 @@ impl From for AdditionalNetworkPermissions { } } +impl From for CoreNetworkPermissions { + fn from(value: AdditionalNetworkPermissions) -> Self { + Self { + enabled: value.enabled, + } + } +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -892,6 +920,86 @@ impl From for AdditionalPermissionProfile { } } +impl From for CorePermissionProfile { + fn from(value: AdditionalPermissionProfile) -> Self { + Self { + network: value.network.map(CoreNetworkPermissions::from), + file_system: value.file_system.map(CoreFileSystemPermissions::from), + macos: value.macos.map(CoreMacOsSeatbeltProfileExtensions::from), + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GrantedMacOsPermissions { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub preferences: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub automations: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub accessibility: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub calendar: Option, +} + +impl From for CoreMacOsSeatbeltProfileExtensions { + fn from(value: GrantedMacOsPermissions) -> Self { + Self { + macos_preferences: value + .preferences + .unwrap_or(CoreMacOsPreferencesPermission::None), + macos_automation: value + .automations + .unwrap_or(CoreMacOsAutomationPermission::None), + macos_accessibility: value.accessibility.unwrap_or(false), + macos_calendar: value.calendar.unwrap_or(false), + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GrantedPermissionProfile { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub network: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub file_system: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub macos: Option, +} + +impl From for CorePermissionProfile { + fn from(value: GrantedPermissionProfile) -> Self { + let macos = value.macos.and_then(|macos| { + if macos.preferences.is_none() + && macos.automations.is_none() + && macos.accessibility.is_none() + && macos.calendar.is_none() + { + None + } else { + Some(CoreMacOsSeatbeltProfileExtensions::from(macos)) + } + }); + + Self { + network: value.network.map(CoreNetworkPermissions::from), + file_system: value.file_system.map(CoreFileSystemPermissions::from), + macos, + } + } +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -4852,6 +4960,24 @@ pub struct DynamicToolCallParams { pub arguments: JsonValue, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PermissionsRequestApprovalParams { + pub thread_id: String, + pub turn_id: String, + pub item_id: String, + pub reason: Option, + pub permissions: AdditionalPermissionProfile, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PermissionsRequestApprovalResponse { + pub permissions: GrantedPermissionProfile, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -5203,6 +5329,128 @@ mod tests { ); } + #[test] + fn permissions_request_approval_response_accepts_partial_macos_grants() { + let cases = vec![ + (json!({}), Some(GrantedMacOsPermissions::default()), None), + ( + json!({ + "preferences": "read_only", + }), + Some(GrantedMacOsPermissions { + preferences: Some(CoreMacOsPreferencesPermission::ReadOnly), + ..Default::default() + }), + Some(CoreMacOsSeatbeltProfileExtensions { + macos_preferences: CoreMacOsPreferencesPermission::ReadOnly, + macos_automation: CoreMacOsAutomationPermission::None, + macos_accessibility: false, + macos_calendar: false, + }), + ), + ( + json!({ + "automations": { + "bundle_ids": ["com.apple.Notes"], + }, + }), + Some(GrantedMacOsPermissions { + automations: Some(CoreMacOsAutomationPermission::BundleIds(vec![ + "com.apple.Notes".to_string(), + ])), + ..Default::default() + }), + Some(CoreMacOsSeatbeltProfileExtensions { + macos_preferences: CoreMacOsPreferencesPermission::None, + macos_automation: CoreMacOsAutomationPermission::BundleIds(vec![ + "com.apple.Notes".to_string(), + ]), + macos_accessibility: false, + macos_calendar: false, + }), + ), + ( + json!({ + "accessibility": true, + }), + Some(GrantedMacOsPermissions { + accessibility: Some(true), + ..Default::default() + }), + Some(CoreMacOsSeatbeltProfileExtensions { + macos_preferences: CoreMacOsPreferencesPermission::None, + macos_automation: CoreMacOsAutomationPermission::None, + macos_accessibility: true, + macos_calendar: false, + }), + ), + ( + json!({ + "calendar": true, + }), + Some(GrantedMacOsPermissions { + calendar: Some(true), + ..Default::default() + }), + Some(CoreMacOsSeatbeltProfileExtensions { + macos_preferences: CoreMacOsPreferencesPermission::None, + macos_automation: CoreMacOsAutomationPermission::None, + macos_accessibility: false, + macos_calendar: true, + }), + ), + ]; + + for (macos_json, expected_granted_macos, expected_core_macos) in cases { + let response = serde_json::from_value::(json!({ + "permissions": { + "macos": macos_json, + }, + })) + .expect("partial macos permissions response should deserialize"); + + assert_eq!( + response.permissions, + GrantedPermissionProfile { + macos: expected_granted_macos, + ..Default::default() + } + ); + + assert_eq!( + CorePermissionProfile::from(response.permissions), + CorePermissionProfile { + macos: expected_core_macos, + ..Default::default() + } + ); + } + } + + #[test] + fn permissions_request_approval_response_omits_ungranted_macos_keys_when_serialized() { + let response = PermissionsRequestApprovalResponse { + permissions: GrantedPermissionProfile { + macos: Some(GrantedMacOsPermissions { + accessibility: Some(true), + ..Default::default() + }), + ..Default::default() + }, + }; + + assert_eq!( + serde_json::to_value(response).expect("response should serialize"), + json!({ + "permissions": { + "macos": { + "accessibility": true, + }, + }, + }) + ); + } + #[test] fn command_exec_params_default_optional_streaming_flags() { let params = serde_json::from_value::(json!({ diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 9f108ae25..da6362b4d 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -850,8 +850,8 @@ Order of messages: Order of messages: 1. `item/started` — emits a `fileChange` item with `changes` (diff chunk summaries) and `status: "inProgress"`. Show the proposed edits and paths to the user. -2. `item/fileChange/requestApproval` (request) — includes `itemId`, `threadId`, `turnId`, and an optional `reason`. -3. Client response — `{ "decision": "accept" }` or `{ "decision": "decline" }`. +2. `item/fileChange/requestApproval` (request) — includes `itemId`, `threadId`, `turnId`, an optional `reason`, and may include unstable `grantRoot` when the agent is asking for session-scoped write access under a specific root. +3. Client response — `{ "decision": "accept" }`, `{ "decision": "acceptForSession" }`, `{ "decision": "decline" }`, or `{ "decision": "cancel" }`. 4. `serverRequest/resolved` — `{ threadId, requestId }` confirms the pending request has been resolved or cleared, including lifecycle cleanup on turn start/complete/interrupt. 5. `item/completed` — returns the same `fileChange` item with `status` updated to `completed`, `failed`, or `declined` after the patch attempt. Rely on this to show success/failure and finalize the diff state in your UI. @@ -875,6 +875,52 @@ Order of messages: `turnId` is best-effort. When the elicitation is correlated with an active turn, the request includes that turn id; otherwise it is `null`. +### Permission requests + +The built-in `request_permissions` tool sends an `item/permissions/requestApproval` JSON-RPC request to the client with the requested permission profile. Today that commonly means additional filesystem access, but the payload is intentionally general so future requests can include non-filesystem permissions too. This request is part of the v2 protocol surface. + +```json +{ + "method": "item/permissions/requestApproval", + "id": 61, + "params": { + "threadId": "thr_123", + "turnId": "turn_123", + "itemId": "call_123", + "reason": "Select a workspace root", + "permissions": { + "fileSystem": { + "write": [ + "/Users/me/project", + "/Users/me/shared" + ] + } + } + } +} +``` + +The client responds with `result.permissions`, which should be the granted subset of the requested permission profile: + +```json +{ + "id": 61, + "result": { + "permissions": { + "fileSystem": { + "write": [ + "/Users/me/project" + ] + } + } + } +} +``` + +Only the granted subset matters on the wire. Any permissions omitted from `result.permissions` are treated as denied, including omitted nested keys inside `result.permissions.macos`, so a sparse response like `{ "permissions": { "macos": { "accessibility": true } } }` grants only accessibility. Any permissions not present in the original request are ignored by the server. + +Within the same turn, granted permissions are sticky: later shell-like tool calls can automatically reuse the granted subset without reissuing a separate permission request. + ### Dynamic tool calls (experimental) `dynamicTools` on `thread/start` and the corresponding `item/tool/call` request/response flow are experimental APIs. To enable them, set `initialize.params.capabilities.experimentalApi = true`. diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index c8665daef..38b03006b 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -42,6 +42,7 @@ use codex_app_server_protocol::FileChangeOutputDeltaNotification; use codex_app_server_protocol::FileChangeRequestApprovalParams; use codex_app_server_protocol::FileChangeRequestApprovalResponse; use codex_app_server_protocol::FileUpdateChange; +use codex_app_server_protocol::GrantedPermissionProfile as V2GrantedPermissionProfile; use codex_app_server_protocol::InterruptConversationResponse; use codex_app_server_protocol::ItemCompletedNotification; use codex_app_server_protocol::ItemStartedNotification; @@ -57,6 +58,8 @@ use codex_app_server_protocol::NetworkApprovalContext as V2NetworkApprovalContex use codex_app_server_protocol::NetworkPolicyAmendment as V2NetworkPolicyAmendment; use codex_app_server_protocol::NetworkPolicyRuleAction as V2NetworkPolicyRuleAction; use codex_app_server_protocol::PatchApplyStatus; +use codex_app_server_protocol::PermissionsRequestApprovalParams; +use codex_app_server_protocol::PermissionsRequestApprovalResponse; use codex_app_server_protocol::PlanDeltaNotification; use codex_app_server_protocol::RawResponseItemCompletedNotification; use codex_app_server_protocol::ReasoningSummaryPartAddedNotification; @@ -97,9 +100,11 @@ use codex_core::ThreadManager; use codex_core::find_thread_name_by_id; use codex_core::review_format::format_review_findings_block; use codex_core::review_prompts; +use codex_core::sandboxing::intersect_permission_profiles; use codex_protocol::ThreadId; use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem as CoreDynamicToolCallOutputContentItem; use codex_protocol::dynamic_tools::DynamicToolResponse as CoreDynamicToolResponse; +use codex_protocol::models::PermissionProfile as CorePermissionProfile; use codex_protocol::plan_tool::UpdatePlanArgs; use codex_protocol::protocol::ApplyPatchApprovalRequestEvent; use codex_protocol::protocol::CodexErrorInfo as CoreCodexErrorInfo; @@ -115,6 +120,7 @@ use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewOutputEvent; use codex_protocol::protocol::TokenCountEvent; use codex_protocol::protocol::TurnDiffEvent; +use codex_protocol::request_permissions::RequestPermissionsResponse as CoreRequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputAnswer as CoreRequestUserInputAnswer; use codex_protocol::request_user_input::RequestUserInputResponse as CoreRequestUserInputResponse; use codex_shell_command::parse_command::shlex_join; @@ -677,6 +683,53 @@ pub(crate) async fn apply_bespoke_event_handling( }); } } + EventMsg::RequestPermissions(request) => { + if matches!(api_version, ApiVersion::V2) { + let permission_guard = thread_watch_manager + .note_permission_requested(&conversation_id.to_string()) + .await; + let requested_permissions = request.permissions.clone(); + let params = PermissionsRequestApprovalParams { + thread_id: conversation_id.to_string(), + turn_id: request.turn_id.clone(), + item_id: request.call_id.clone(), + reason: request.reason, + permissions: request.permissions.into(), + }; + let (pending_request_id, rx) = outgoing + .send_request(ServerRequestPayload::PermissionsRequestApproval(params)) + .await; + tokio::spawn(async move { + on_request_permissions_response( + request.call_id, + requested_permissions, + pending_request_id, + rx, + conversation, + thread_state, + permission_guard, + ) + .await; + }); + } else { + error!( + "request_permissions is only supported on api v2 (call_id: {})", + request.call_id + ); + let empty = CoreRequestPermissionsResponse { + permissions: Default::default(), + }; + if let Err(err) = conversation + .submit(Op::RequestPermissionsResponse { + id: request.call_id, + response: empty, + }) + .await + { + error!("failed to submit RequestPermissionsResponse: {err}"); + } + } + } EventMsg::DynamicToolCallRequest(request) => { if matches!(api_version, ApiVersion::V2) { let call_id = request.call_id; @@ -2126,6 +2179,71 @@ fn mcp_server_elicitation_response_from_client_result( } } +async fn on_request_permissions_response( + call_id: String, + requested_permissions: CorePermissionProfile, + pending_request_id: RequestId, + receiver: oneshot::Receiver, + conversation: Arc, + thread_state: Arc>, + request_permissions_guard: ThreadWatchActiveGuard, +) { + let response = receiver.await; + resolve_server_request_on_thread_listener(&thread_state, pending_request_id).await; + drop(request_permissions_guard); + let Some(response) = + request_permissions_response_from_client_result(requested_permissions, response) + else { + return; + }; + + if let Err(err) = conversation + .submit(Op::RequestPermissionsResponse { + id: call_id, + response, + }) + .await + { + error!("failed to submit RequestPermissionsResponse: {err}"); + } +} + +fn request_permissions_response_from_client_result( + requested_permissions: CorePermissionProfile, + response: std::result::Result, +) -> Option { + let value = match response { + Ok(Ok(value)) => value, + Ok(Err(err)) if is_turn_transition_server_request_error(&err) => return None, + Ok(Err(err)) => { + error!("request failed with client error: {err:?}"); + return Some(CoreRequestPermissionsResponse { + permissions: Default::default(), + }); + } + Err(err) => { + error!("request failed: {err:?}"); + return Some(CoreRequestPermissionsResponse { + permissions: Default::default(), + }); + } + }; + + let response = serde_json::from_value::(value) + .unwrap_or_else(|err| { + error!("failed to deserialize PermissionsRequestApprovalResponse: {err}"); + PermissionsRequestApprovalResponse { + permissions: V2GrantedPermissionProfile::default(), + } + }); + Some(CoreRequestPermissionsResponse { + permissions: intersect_permission_profiles( + requested_permissions, + response.permissions.into(), + ), + }) +} + const REVIEW_FALLBACK_MESSAGE: &str = "Reviewer failed to output a response."; fn render_review_output_text(output: &ReviewOutputEvent) -> String { @@ -2474,6 +2592,9 @@ mod tests { use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::TurnPlanStepStatus; use codex_protocol::mcp::CallToolResult; + use codex_protocol::models::MacOsAutomationPermission; + use codex_protocol::models::MacOsPreferencesPermission; + use codex_protocol::models::MacOsSeatbeltProfileExtensions; use codex_protocol::plan_tool::PlanItemArg; use codex_protocol::plan_tool::StepStatus; use codex_protocol::protocol::CollabResumeBeginEvent; @@ -2536,6 +2657,120 @@ mod tests { ); } + #[test] + fn request_permissions_turn_transition_error_is_ignored() { + let error = JSONRPCErrorError { + code: -1, + message: "client request resolved because the turn state was changed".to_string(), + data: Some(serde_json::json!({ "reason": "turnTransition" })), + }; + + let response = request_permissions_response_from_client_result( + CorePermissionProfile::default(), + Ok(Err(error)), + ); + + assert_eq!(response, None); + } + + #[test] + fn request_permissions_response_accepts_partial_macos_grants() { + let requested_permissions = CorePermissionProfile { + macos: Some(MacOsSeatbeltProfileExtensions { + macos_preferences: MacOsPreferencesPermission::ReadWrite, + macos_automation: MacOsAutomationPermission::BundleIds(vec![ + "com.apple.Notes".to_string(), + "com.apple.Reminders".to_string(), + ]), + macos_accessibility: true, + macos_calendar: true, + }), + ..Default::default() + }; + let cases = vec![ + (serde_json::json!({}), CorePermissionProfile::default()), + ( + serde_json::json!({ + "preferences": "read_only", + }), + CorePermissionProfile { + macos: Some(MacOsSeatbeltProfileExtensions { + macos_preferences: MacOsPreferencesPermission::ReadOnly, + macos_automation: MacOsAutomationPermission::None, + macos_accessibility: false, + macos_calendar: false, + }), + ..Default::default() + }, + ), + ( + serde_json::json!({ + "automations": { + "bundle_ids": ["com.apple.Notes"], + }, + }), + CorePermissionProfile { + macos: Some(MacOsSeatbeltProfileExtensions { + macos_preferences: MacOsPreferencesPermission::None, + macos_automation: MacOsAutomationPermission::BundleIds(vec![ + "com.apple.Notes".to_string(), + ]), + macos_accessibility: false, + macos_calendar: false, + }), + ..Default::default() + }, + ), + ( + serde_json::json!({ + "accessibility": true, + }), + CorePermissionProfile { + macos: Some(MacOsSeatbeltProfileExtensions { + macos_preferences: MacOsPreferencesPermission::None, + macos_automation: MacOsAutomationPermission::None, + macos_accessibility: true, + macos_calendar: false, + }), + ..Default::default() + }, + ), + ( + serde_json::json!({ + "calendar": true, + }), + CorePermissionProfile { + macos: Some(MacOsSeatbeltProfileExtensions { + macos_preferences: MacOsPreferencesPermission::None, + macos_automation: MacOsAutomationPermission::None, + macos_accessibility: false, + macos_calendar: true, + }), + ..Default::default() + }, + ), + ]; + + for (granted_macos, expected_permissions) in cases { + let response = request_permissions_response_from_client_result( + requested_permissions.clone(), + Ok(Ok(serde_json::json!({ + "permissions": { + "macos": granted_macos, + }, + }))), + ) + .expect("response should be accepted"); + + assert_eq!( + response, + CoreRequestPermissionsResponse { + permissions: expected_permissions, + } + ); + } + } + #[test] fn collab_resume_begin_maps_to_item_started_resume_agent() { let event = CollabResumeBeginEvent { diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 4a2a99db2..74d1b47d4 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -29,6 +29,7 @@ pub use models_cache::write_models_cache_with_models; pub use responses::create_apply_patch_sse_response; pub use responses::create_exec_command_sse_response; pub use responses::create_final_assistant_message_sse_response; +pub use responses::create_request_permissions_sse_response; pub use responses::create_request_user_input_sse_response; pub use responses::create_shell_command_sse_response; pub use rollout::create_fake_rollout; diff --git a/codex-rs/app-server/tests/common/responses.rs b/codex-rs/app-server/tests/common/responses.rs index e15319e02..586d1446c 100644 --- a/codex-rs/app-server/tests/common/responses.rs +++ b/codex-rs/app-server/tests/common/responses.rs @@ -83,3 +83,23 @@ pub fn create_request_user_input_sse_response(call_id: &str) -> anyhow::Result anyhow::Result { + let tool_call_arguments = serde_json::to_string(&json!({ + "reason": "Select a workspace root", + "permissions": { + "file_system": { + "write": [ + ".", + "../shared" + ] + } + } + }))?; + + Ok(responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_function_call(call_id, "request_permissions", &tool_call_arguments), + responses::ev_completed("resp-1"), + ])) +} diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index 327b6a04d..1b62fac43 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -21,6 +21,7 @@ mod plugin_install; mod plugin_list; mod rate_limits; mod realtime_conversation; +mod request_permissions; mod request_user_input; mod review; mod safety_check_downgrade; diff --git a/codex-rs/app-server/tests/suite/v2/request_permissions.rs b/codex-rs/app-server/tests/suite/v2/request_permissions.rs new file mode 100644 index 000000000..94a7c7ecf --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/request_permissions.rs @@ -0,0 +1,155 @@ +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::create_final_assistant_message_sse_response; +use app_test_support::create_mock_responses_server_sequence; +use app_test_support::create_request_permissions_sse_response; +use app_test_support::to_response; +use codex_app_server_protocol::JSONRPCMessage; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::PermissionsRequestApprovalResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::ServerRequestResolvedNotification; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::UserInput as V2UserInput; +use tokio::time::timeout; + +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn request_permissions_round_trip() -> Result<()> { + let codex_home = tempfile::TempDir::new()?; + let responses = vec![ + create_request_permissions_sse_response("call1")?, + create_final_assistant_message_sse_response("done")?, + ]; + let server = create_mock_responses_server_sequence(responses).await; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response(thread_start_resp)?; + + let turn_start_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "pick a directory".to_string(), + text_elements: Vec::new(), + }], + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let turn_start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_start_id)), + ) + .await??; + let TurnStartResponse { turn, .. } = to_response(turn_start_resp)?; + + let server_req = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_request_message(), + ) + .await??; + let ServerRequest::PermissionsRequestApproval { request_id, params } = server_req else { + panic!("expected PermissionsRequestApproval request, got: {server_req:?}"); + }; + + assert_eq!(params.thread_id, thread.id); + assert_eq!(params.turn_id, turn.id); + assert_eq!(params.item_id, "call1"); + assert_eq!(params.reason, Some("Select a workspace root".to_string())); + let requested_writes = params + .permissions + .file_system + .and_then(|file_system| file_system.write) + .expect("request should include write permissions"); + assert_eq!(requested_writes.len(), 2); + let resolved_request_id = request_id.clone(); + + mcp.send_response( + request_id, + serde_json::to_value(PermissionsRequestApprovalResponse { + permissions: codex_app_server_protocol::GrantedPermissionProfile { + network: None, + file_system: Some(codex_app_server_protocol::AdditionalFileSystemPermissions { + read: None, + write: Some(vec![requested_writes[0].clone()]), + }), + macos: None, + }, + })?, + ) + .await?; + + let mut saw_resolved = false; + loop { + let message = timeout(DEFAULT_READ_TIMEOUT, mcp.read_next_message()).await??; + let JSONRPCMessage::Notification(notification) = message else { + continue; + }; + match notification.method.as_str() { + "serverRequest/resolved" => { + let resolved: ServerRequestResolvedNotification = serde_json::from_value( + notification + .params + .clone() + .expect("serverRequest/resolved params"), + )?; + assert_eq!(resolved.thread_id, thread.id); + assert_eq!(resolved.request_id, resolved_request_id); + saw_resolved = true; + } + "turn/completed" => { + assert!(saw_resolved, "serverRequest/resolved should arrive first"); + break; + } + _ => {} + } + } + + Ok(()) +} + +fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "untrusted" +sandbox_mode = "read-only" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 + +[features] +request_permissions_tool = true +"# + ), + ) +} diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 1f01173c5..7498ffd20 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -410,6 +410,9 @@ "request_permissions": { "type": "boolean" }, + "request_permissions_tool": { + "type": "boolean" + }, "request_rule": { "type": "boolean" }, @@ -1882,6 +1885,9 @@ "request_permissions": { "type": "boolean" }, + "request_permissions_tool": { + "type": "boolean" + }, "request_rule": { "type": "boolean" }, diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index c545848f5..ef8e7b21b 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -103,6 +103,9 @@ use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::TurnContextNetworkItem; use codex_protocol::protocol::TurnStartedEvent; +use codex_protocol::request_permissions::RequestPermissionsArgs; +use codex_protocol::request_permissions::RequestPermissionsEvent; +use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputArgs; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_rmcp_client::ElicitationResponse; @@ -2820,6 +2823,37 @@ impl Session { rx_approve } + pub async fn request_permissions( + &self, + turn_context: &TurnContext, + call_id: String, + args: RequestPermissionsArgs, + ) -> Option { + let (tx_response, rx_response) = oneshot::channel(); + let prev_entry = { + let mut active = self.active_turn.lock().await; + match active.as_mut() { + Some(at) => { + let mut ts = at.turn_state.lock().await; + ts.insert_pending_request_permissions(call_id.clone(), tx_response) + } + None => None, + } + }; + if prev_entry.is_some() { + warn!("Overwriting existing pending request_permissions for call_id: {call_id}"); + } + + let event = EventMsg::RequestPermissions(RequestPermissionsEvent { + call_id, + turn_id: turn_context.sub_id.clone(), + reason: args.reason, + permissions: args.permissions, + }); + self.send_event(turn_context, event).await; + rx_response.await.ok() + } + pub async fn request_user_input( &self, turn_context: &TurnContext, @@ -2956,6 +2990,42 @@ impl Session { } } + pub async fn notify_request_permissions_response( + &self, + call_id: &str, + response: RequestPermissionsResponse, + ) { + let entry = { + let mut active = self.active_turn.lock().await; + match active.as_mut() { + Some(at) => { + let mut ts = at.turn_state.lock().await; + let entry = ts.remove_pending_request_permissions(call_id); + if entry.is_some() && !response.permissions.is_empty() { + ts.record_granted_permissions(response.permissions.clone()); + } + entry + } + None => None, + } + }; + match entry { + Some(tx_response) => { + tx_response.send(response).ok(); + } + None => { + warn!("No pending request_permissions found for call_id: {call_id}"); + } + } + } + + pub(crate) async fn granted_turn_permissions(&self) -> Option { + let active = self.active_turn.lock().await; + let active = active.as_ref()?; + let ts = active.turn_state.lock().await; + ts.granted_permissions() + } + pub async fn notify_dynamic_tool_response(&self, call_id: &str, response: DynamicToolResponse) { let entry = { let mut active = self.active_turn.lock().await; @@ -3924,6 +3994,10 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv handlers::request_user_input_response(&sess, id, response).await; false } + Op::RequestPermissionsResponse { id, response } => { + handlers::request_permissions_response(&sess, id, response).await; + false + } Op::DynamicToolResponse { id, response } => { handlers::dynamic_tool_response(&sess, id, response).await; false @@ -4106,6 +4180,7 @@ mod handlers { use codex_protocol::protocol::ThreadRolledBackEvent; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::WarningEvent; + use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputResponse; use crate::context_manager::is_user_turn_boundary; @@ -4348,6 +4423,15 @@ mod handlers { sess.notify_user_input_response(&id, response).await; } + pub async fn request_permissions_response( + sess: &Arc, + id: String, + response: RequestPermissionsResponse, + ) { + sess.notify_request_permissions_response(&id, response) + .await; + } + pub async fn dynamic_tool_response( sess: &Arc, id: String, @@ -6217,6 +6301,7 @@ fn realtime_text_for_event(msg: &EventMsg) -> Option { | EventMsg::ImageGenerationBegin(_) | EventMsg::ImageGenerationEnd(_) | EventMsg::ExecApprovalRequest(_) + | EventMsg::RequestPermissions(_) | EventMsg::RequestUserInput(_) | EventMsg::DynamicToolCallRequest(_) | EventMsg::DynamicToolCallResponse(_) diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 9d83a4429..1595830a5 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -13,6 +13,9 @@ use codex_protocol::protocol::RequestUserInputEvent; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::Submission; +use codex_protocol::request_permissions::RequestPermissionsArgs; +use codex_protocol::request_permissions::RequestPermissionsEvent; +use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputArgs; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_protocol::user_input::UserInput; @@ -251,6 +254,19 @@ async fn forward_events( ) .await; } + Event { + msg: EventMsg::RequestPermissions(event), + .. + } => { + handle_request_permissions( + &codex, + &parent_session, + &parent_ctx, + event, + &cancel_token, + ) + .await; + } Event { id, msg: EventMsg::RequestUserInput(event), @@ -425,6 +441,30 @@ async fn handle_request_user_input( let _ = codex.submit(Op::UserInputAnswer { id, response }).await; } +async fn handle_request_permissions( + codex: &Codex, + parent_session: &Session, + parent_ctx: &TurnContext, + event: RequestPermissionsEvent, + cancel_token: &CancellationToken, +) { + let call_id = event.call_id; + let args = RequestPermissionsArgs { + reason: event.reason, + permissions: event.permissions, + }; + let response_fut = parent_session.request_permissions(parent_ctx, call_id.clone(), args); + let response = + await_request_permissions_with_cancel(response_fut, parent_session, &call_id, cancel_token) + .await; + let _ = codex + .submit(Op::RequestPermissionsResponse { + id: call_id, + response, + }) + .await; +} + async fn await_user_input_with_cancel( fut: F, parent_session: &Session, @@ -451,6 +491,32 @@ where } } +async fn await_request_permissions_with_cancel( + fut: F, + parent_session: &Session, + call_id: &str, + cancel_token: &CancellationToken, +) -> RequestPermissionsResponse +where + F: core::future::Future>, +{ + tokio::select! { + biased; + _ = cancel_token.cancelled() => { + let empty = RequestPermissionsResponse { + permissions: Default::default(), + }; + parent_session + .notify_request_permissions_response(call_id, empty.clone()) + .await; + empty + } + response = fut => response.unwrap_or_else(|| RequestPermissionsResponse { + permissions: Default::default(), + }), + } +} + /// Await an approval decision, aborting on cancellation. async fn await_approval_with_cancel( fut: F, @@ -479,11 +545,16 @@ where mod tests { use super::*; use async_channel::bounded; + use codex_protocol::models::NetworkPermissions; + use codex_protocol::models::PermissionProfile; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::AgentStatus; + use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::RawResponseItemEvent; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnAbortedEvent; + use codex_protocol::request_permissions::RequestPermissionsEvent; + use codex_protocol::request_permissions::RequestPermissionsResponse; use pretty_assertions::assert_eq; use tokio::sync::watch; @@ -602,4 +673,90 @@ mod tests { .expect("forward_ops did not exit") .expect("forward_ops join error"); } + + #[tokio::test] + async fn handle_request_permissions_uses_tool_call_id_for_round_trip() { + let (parent_session, parent_ctx, rx_events) = + crate::codex::make_session_and_context_with_rx().await; + *parent_session.active_turn.lock().await = Some(crate::state::ActiveTurn::default()); + + let (tx_sub, rx_sub) = bounded(SUBMISSION_CHANNEL_CAPACITY); + let (_tx_events, rx_events_child) = bounded(SUBMISSION_CHANNEL_CAPACITY); + let (_agent_status_tx, agent_status) = watch::channel(AgentStatus::PendingInit); + let codex = Arc::new(Codex { + tx_sub, + rx_event: rx_events_child, + agent_status, + session: Arc::clone(&parent_session), + }); + + let call_id = "tool-call-1".to_string(); + let expected_response = RequestPermissionsResponse { + permissions: PermissionProfile { + network: Some(NetworkPermissions { + enabled: Some(true), + }), + ..PermissionProfile::default() + }, + }; + let cancel_token = CancellationToken::new(); + let request_call_id = call_id.clone(); + + let handle = tokio::spawn({ + let codex = Arc::clone(&codex); + let parent_session = Arc::clone(&parent_session); + let parent_ctx = Arc::clone(&parent_ctx); + let cancel_token = cancel_token.clone(); + async move { + handle_request_permissions( + codex.as_ref(), + parent_session.as_ref(), + parent_ctx.as_ref(), + RequestPermissionsEvent { + call_id: request_call_id, + turn_id: "child-turn-1".to_string(), + reason: Some("need access".to_string()), + permissions: PermissionProfile { + network: Some(NetworkPermissions { + enabled: Some(true), + }), + ..PermissionProfile::default() + }, + }, + &cancel_token, + ) + .await; + } + }); + + let request_event = timeout(Duration::from_secs(1), rx_events.recv()) + .await + .expect("request_permissions event timed out") + .expect("request_permissions event missing"); + let EventMsg::RequestPermissions(request) = request_event.msg else { + panic!("expected RequestPermissions event"); + }; + assert_eq!(request.call_id, call_id.clone()); + + parent_session + .notify_request_permissions_response(&call_id, expected_response.clone()) + .await; + + timeout(Duration::from_secs(1), handle) + .await + .expect("handle_request_permissions hung") + .expect("handle_request_permissions join error"); + + let submission = timeout(Duration::from_secs(1), rx_sub.recv()) + .await + .expect("request_permissions response timed out") + .expect("request_permissions response missing"); + assert_eq!( + submission.op, + Op::RequestPermissionsResponse { + id: call_id, + response: expected_response, + } + ); + } } diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 6e7115619..e31176733 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2141,6 +2141,28 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { (session, turn_context) } +#[tokio::test] +async fn notify_request_permissions_response_ignores_unmatched_call_id() { + let (session, _turn_context) = make_session_and_context().await; + *session.active_turn.lock().await = Some(ActiveTurn::default()); + + session + .notify_request_permissions_response( + "missing", + codex_protocol::request_permissions::RequestPermissionsResponse { + permissions: codex_protocol::models::PermissionProfile { + network: Some(codex_protocol::models::NetworkPermissions { + enabled: Some(true), + }), + ..Default::default() + }, + }, + ) + .await; + + assert_eq!(session.granted_turn_permissions().await, None); +} + #[tokio::test] async fn submit_with_id_captures_current_span_trace_context() { let (session, _turn_context) = make_session_and_context().await; diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index 4d4a5855e..3fa716d11 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -93,6 +93,8 @@ pub enum Feature { ApplyPatchFreeform, /// Allow requesting additional filesystem permissions while staying sandboxed. RequestPermissions, + /// Expose the built-in request_permissions tool. + RequestPermissionsTool, /// Allow the model to request web searches that fetch live content. WebSearchRequest, /// Allow the model to request web searches that fetch cached content. @@ -581,6 +583,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::UnderDevelopment, default_enabled: false, }, + FeatureSpec { + id: Feature::RequestPermissionsTool, + key: "request_permissions_tool", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::UseLinuxSandboxBwrap, key: "use_linux_sandbox_bwrap", @@ -920,6 +928,21 @@ mod tests { assert_eq!(Feature::GuardianApproval.default_enabled(), false); } + #[test] + fn request_permissions_is_under_development() { + assert_eq!(Feature::RequestPermissions.stage(), Stage::UnderDevelopment); + assert_eq!(Feature::RequestPermissions.default_enabled(), false); + } + + #[test] + fn request_permissions_tool_is_under_development() { + assert_eq!( + Feature::RequestPermissionsTool.stage(), + Stage::UnderDevelopment + ); + assert_eq!(Feature::RequestPermissionsTool.default_enabled(), false); + } + #[test] fn image_generation_is_under_development() { assert_eq!(Feature::ImageGeneration.stage(), Stage::UnderDevelopment); diff --git a/codex-rs/core/src/rollout/policy.rs b/codex-rs/core/src/rollout/policy.rs index 92b607681..aff6759da 100644 --- a/codex-rs/core/src/rollout/policy.rs +++ b/codex-rs/core/src/rollout/policy.rs @@ -144,6 +144,7 @@ fn event_msg_persistence_mode(ev: &EventMsg) -> Option { | EventMsg::TerminalInteraction(_) | EventMsg::ExecCommandOutputDelta(_) | EventMsg::ExecApprovalRequest(_) + | EventMsg::RequestPermissions(_) | EventMsg::RequestUserInput(_) | EventMsg::ElicitationRequest(_) | EventMsg::ApplyPatchApprovalRequest(_) diff --git a/codex-rs/core/src/sandboxing/macos_permissions.rs b/codex-rs/core/src/sandboxing/macos_permissions.rs index 3dfe8d6c9..c3b3840d4 100644 --- a/codex-rs/core/src/sandboxing/macos_permissions.rs +++ b/codex-rs/core/src/sandboxing/macos_permissions.rs @@ -31,6 +31,28 @@ pub(crate) fn merge_macos_seatbelt_profile_extensions( } } +pub(crate) fn intersect_macos_seatbelt_profile_extensions( + requested: Option, + granted: Option, +) -> Option { + match (requested, granted) { + (Some(requested), Some(granted)) => { + let macos_automation = intersect_macos_automation_permission( + &requested.macos_automation, + &granted.macos_automation, + ); + + Some(MacOsSeatbeltProfileExtensions { + macos_preferences: requested.macos_preferences.min(granted.macos_preferences), + macos_automation, + macos_accessibility: requested.macos_accessibility && granted.macos_accessibility, + macos_calendar: requested.macos_calendar && granted.macos_calendar, + }) + } + _ => None, + } +} + /// Unions two preferences permissions by keeping the more permissive one. /// /// The larger rank wins: `None < ReadOnly < ReadWrite`. When both sides have @@ -75,8 +97,40 @@ fn union_macos_automation_permission( } } +fn intersect_macos_automation_permission( + requested: &MacOsAutomationPermission, + granted: &MacOsAutomationPermission, +) -> MacOsAutomationPermission { + match (requested, granted) { + (_, MacOsAutomationPermission::None) | (MacOsAutomationPermission::None, _) => { + MacOsAutomationPermission::None + } + (MacOsAutomationPermission::All, granted) => granted.clone(), + (MacOsAutomationPermission::BundleIds(requested), MacOsAutomationPermission::All) => { + MacOsAutomationPermission::BundleIds(requested.clone()) + } + ( + MacOsAutomationPermission::BundleIds(requested), + MacOsAutomationPermission::BundleIds(granted), + ) => { + let bundle_ids = requested + .iter() + .filter(|bundle_id| granted.contains(bundle_id)) + .cloned() + .collect::>(); + if bundle_ids.is_empty() { + MacOsAutomationPermission::None + } else { + MacOsAutomationPermission::BundleIds(bundle_ids) + } + } + } +} + #[cfg(all(test, target_os = "macos"))] mod tests { + use super::intersect_macos_automation_permission; + use super::intersect_macos_seatbelt_profile_extensions; use super::merge_macos_seatbelt_profile_extensions; use super::union_macos_automation_permission; use super::union_macos_preferences_permission; @@ -141,4 +195,38 @@ mod tests { assert_eq!(merged, MacOsAutomationPermission::All); } + + #[test] + fn intersect_macos_automation_permission_keeps_common_bundle_ids() { + let requested = MacOsAutomationPermission::BundleIds(vec![ + "com.apple.Notes".to_string(), + "com.apple.Calendar".to_string(), + ]); + let granted = MacOsAutomationPermission::BundleIds(vec!["com.apple.Notes".to_string()]); + + let intersected = intersect_macos_automation_permission(&requested, &granted); + + assert_eq!( + intersected, + MacOsAutomationPermission::BundleIds(vec!["com.apple.Notes".to_string()]) + ); + } + + #[test] + fn intersect_macos_seatbelt_profile_extensions_preserves_default_grant() { + let requested = MacOsSeatbeltProfileExtensions { + macos_preferences: MacOsPreferencesPermission::ReadWrite, + macos_automation: MacOsAutomationPermission::BundleIds(vec![ + "com.apple.Notes".to_string(), + ]), + macos_accessibility: true, + macos_calendar: true, + }; + let granted = MacOsSeatbeltProfileExtensions::default(); + + let intersected = + intersect_macos_seatbelt_profile_extensions(Some(requested), Some(granted)); + + assert_eq!(intersected, Some(MacOsSeatbeltProfileExtensions::default())); + } } diff --git a/codex-rs/core/src/sandboxing/mod.rs b/codex-rs/core/src/sandboxing/mod.rs index c43322ca7..2fb0b45f8 100644 --- a/codex-rs/core/src/sandboxing/mod.rs +++ b/codex-rs/core/src/sandboxing/mod.rs @@ -28,6 +28,7 @@ use codex_network_proxy::NetworkProxy; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::FileSystemPermissions; use codex_protocol::models::MacOsSeatbeltProfileExtensions; +use codex_protocol::models::NetworkPermissions; use codex_protocol::models::PermissionProfile; pub use codex_protocol::models::SandboxPermissions; use codex_protocol::permissions::FileSystemAccessMode; @@ -40,6 +41,7 @@ use codex_protocol::protocol::NetworkAccess; use codex_protocol::protocol::ReadOnlyAccess; use codex_utils_absolute_path::AbsolutePathBuf; use dunce::canonicalize; +use macos_permissions::intersect_macos_seatbelt_profile_extensions; use macos_permissions::merge_macos_seatbelt_profile_extensions; use std::collections::HashMap; use std::collections::HashSet; @@ -146,22 +148,137 @@ impl EffectiveSandboxPermissions { pub(crate) fn normalize_additional_permissions( additional_permissions: PermissionProfile, ) -> Result { + let network = additional_permissions + .network + .filter(|network| !network.is_empty()); + let file_system = additional_permissions + .file_system + .map(|file_system| { + 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")); + FileSystemPermissions { read, write } + }) + .filter(|file_system| !file_system.is_empty()); + let macos = additional_permissions.macos; + Ok(PermissionProfile { - network: additional_permissions.network, - 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, + network, + file_system, + macos, }) } +pub(crate) fn merge_permission_profiles( + base: Option<&PermissionProfile>, + permissions: Option<&PermissionProfile>, +) -> Option { + let Some(permissions) = permissions else { + return base.cloned(); + }; + + match base { + Some(base) => { + let network = match (base.network.as_ref(), permissions.network.as_ref()) { + ( + Some(NetworkPermissions { + enabled: Some(true), + }), + _, + ) + | ( + _, + Some(NetworkPermissions { + enabled: Some(true), + }), + ) => Some(NetworkPermissions { + enabled: Some(true), + }), + _ => None, + }; + let file_system = match (base.file_system.as_ref(), permissions.file_system.as_ref()) { + (Some(base), Some(permissions)) => Some(FileSystemPermissions { + read: merge_permission_paths(base.read.as_ref(), permissions.read.as_ref()), + write: merge_permission_paths(base.write.as_ref(), permissions.write.as_ref()), + }) + .filter(|file_system| !file_system.is_empty()), + (Some(base), None) => Some(base.clone()), + (None, Some(permissions)) => Some(permissions.clone()), + (None, None) => None, + }; + let macos = merge_macos_seatbelt_profile_extensions( + base.macos.as_ref(), + permissions.macos.as_ref(), + ); + + Some(PermissionProfile { + network, + file_system, + macos, + }) + .filter(|permissions| !permissions.is_empty()) + } + None => Some(permissions.clone()).filter(|permissions| !permissions.is_empty()), + } +} + +pub fn intersect_permission_profiles( + requested: PermissionProfile, + granted: PermissionProfile, +) -> PermissionProfile { + let file_system = requested + .file_system + .map(|requested_file_system| { + let granted_file_system = granted.file_system.unwrap_or_default(); + let read = requested_file_system + .read + .map(|requested_read| { + let granted_read = granted_file_system.read.unwrap_or_default(); + requested_read + .into_iter() + .filter(|path| granted_read.contains(path)) + .collect() + }) + .filter(|paths: &Vec<_>| !paths.is_empty()); + let write = requested_file_system + .write + .map(|requested_write| { + let granted_write = granted_file_system.write.unwrap_or_default(); + requested_write + .into_iter() + .filter(|path| granted_write.contains(path)) + .collect() + }) + .filter(|paths: &Vec<_>| !paths.is_empty()); + FileSystemPermissions { read, write } + }) + .filter(|file_system| !file_system.is_empty()); + let network = match (requested.network, granted.network) { + ( + Some(NetworkPermissions { + enabled: Some(true), + }), + Some(NetworkPermissions { + enabled: Some(true), + }), + ) => Some(NetworkPermissions { + enabled: Some(true), + }), + _ => None, + }; + + let macos = intersect_macos_seatbelt_profile_extensions(requested.macos, granted.macos); + + PermissionProfile { + network, + file_system, + macos, + } +} + fn normalize_permission_paths( paths: Vec, _permission_kind: &str, @@ -182,6 +299,29 @@ fn normalize_permission_paths( out } +fn merge_permission_paths( + base: Option<&Vec>, + permissions: Option<&Vec>, +) -> Option> { + match (base, permissions) { + (Some(base), Some(permissions)) => { + let mut merged = Vec::with_capacity(base.len() + permissions.len()); + let mut seen = HashSet::with_capacity(base.len() + permissions.len()); + + for path in base.iter().chain(permissions.iter()) { + if seen.insert(path.clone()) { + merged.push(path.clone()); + } + } + + Some(merged).filter(|paths| !paths.is_empty()) + } + (Some(base), None) => Some(base.clone()), + (None, Some(permissions)) => Some(permissions.clone()), + (None, None) => None, + } +} + fn dedup_absolute_paths(paths: Vec) -> Vec { let mut out = Vec::with_capacity(paths.len()); let mut seen = HashSet::new(); @@ -581,6 +721,8 @@ mod tests { #[cfg(target_os = "macos")] use super::EffectiveSandboxPermissions; use super::SandboxManager; + #[cfg(target_os = "macos")] + use super::intersect_permission_profiles; use super::merge_file_system_policy_with_additional_permissions; use super::normalize_additional_permissions; use super::sandbox_policy_with_additional_permissions; @@ -791,6 +933,77 @@ mod tests { ); } + #[test] + fn normalize_additional_permissions_drops_empty_nested_profiles() { + let permissions = normalize_additional_permissions(PermissionProfile { + network: Some(NetworkPermissions { enabled: None }), + file_system: Some(FileSystemPermissions { + read: None, + write: None, + }), + macos: None, + }) + .expect("permissions"); + + assert_eq!(permissions, PermissionProfile::default()); + } + + #[cfg(target_os = "macos")] + #[test] + fn normalize_additional_permissions_preserves_default_macos_preferences_permission() { + let permissions = normalize_additional_permissions(PermissionProfile { + macos: Some(MacOsSeatbeltProfileExtensions::default()), + ..Default::default() + }) + .expect("permissions"); + + assert_eq!( + permissions, + PermissionProfile { + macos: Some(MacOsSeatbeltProfileExtensions::default()), + ..Default::default() + } + ); + } + + #[cfg(target_os = "macos")] + #[test] + fn intersect_permission_profiles_preserves_default_macos_grants() { + let requested = PermissionProfile { + file_system: Some(FileSystemPermissions { + read: Some(Vec::from(["/tmp/requested" + .try_into() + .expect("absolute path")])), + write: None, + }), + 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() + }; + let granted = PermissionProfile { + file_system: Some(FileSystemPermissions { + read: Some(Vec::new()), + write: None, + }), + macos: Some(MacOsSeatbeltProfileExtensions::default()), + ..Default::default() + }; + + assert_eq!( + intersect_permission_profiles(requested, granted), + PermissionProfile { + macos: Some(MacOsSeatbeltProfileExtensions::default()), + ..Default::default() + } + ); + } + #[cfg(target_os = "macos")] #[test] fn normalize_additional_permissions_preserves_macos_permissions() { diff --git a/codex-rs/core/src/state/turn.rs b/codex-rs/core/src/state/turn.rs index 4bbcf21c9..e2e141d38 100644 --- a/codex-rs/core/src/state/turn.rs +++ b/codex-rs/core/src/state/turn.rs @@ -10,6 +10,7 @@ use tokio_util::task::AbortOnDropHandle; use codex_protocol::dynamic_tools::DynamicToolResponse; use codex_protocol::models::ResponseInputItem; +use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_rmcp_client::ElicitationResponse; use rmcp::model::RequestId; @@ -18,7 +19,9 @@ use tokio::sync::oneshot; use crate::codex::TurnContext; use crate::protocol::ReviewDecision; use crate::protocol::TokenUsage; +use crate::sandboxing::merge_permission_profiles; use crate::tasks::SessionTask; +use codex_protocol::models::PermissionProfile; /// Metadata about the currently running turn. pub(crate) struct ActiveTurn { @@ -73,10 +76,12 @@ impl ActiveTurn { #[derive(Default)] pub(crate) struct TurnState { pending_approvals: HashMap>, + pending_request_permissions: HashMap>, pending_user_input: HashMap>, pending_elicitations: HashMap<(String, RequestId), oneshot::Sender>, pending_dynamic_tools: HashMap>, pending_input: Vec, + granted_permissions: Option, pub(crate) tool_calls: u64, pub(crate) token_usage_at_turn_start: TokenUsage, } @@ -99,12 +104,28 @@ impl TurnState { pub(crate) fn clear_pending(&mut self) { self.pending_approvals.clear(); + self.pending_request_permissions.clear(); self.pending_user_input.clear(); self.pending_elicitations.clear(); self.pending_dynamic_tools.clear(); self.pending_input.clear(); } + pub(crate) fn insert_pending_request_permissions( + &mut self, + key: String, + tx: oneshot::Sender, + ) -> Option> { + self.pending_request_permissions.insert(key, tx) + } + + pub(crate) fn remove_pending_request_permissions( + &mut self, + key: &str, + ) -> Option> { + self.pending_request_permissions.remove(key) + } + pub(crate) fn insert_pending_user_input( &mut self, key: String, @@ -171,6 +192,15 @@ impl TurnState { pub(crate) fn has_pending_input(&self) -> bool { !self.pending_input.is_empty() } + + pub(crate) fn record_granted_permissions(&mut self, permissions: PermissionProfile) { + self.granted_permissions = + merge_permission_profiles(self.granted_permissions.as_ref(), Some(&permissions)); + } + + pub(crate) fn granted_permissions(&self) -> Option { + self.granted_permissions.clone() + } } impl ActiveTurn { diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index 086701d9c..2b14d9b58 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -10,6 +10,7 @@ mod mcp_resource; pub(crate) mod multi_agents; mod plan; mod read_file; +mod request_permissions; mod request_user_input; mod search_tool_bm25; mod shell; @@ -24,8 +25,10 @@ use serde_json::Value; use std::path::Path; use std::path::PathBuf; +use crate::codex::Session; use crate::function_tool::FunctionCallError; use crate::sandboxing::SandboxPermissions; +use crate::sandboxing::merge_permission_profiles; use crate::sandboxing::normalize_additional_permissions; pub use apply_patch::ApplyPatchHandler; pub use artifacts::ArtifactsHandler; @@ -41,6 +44,8 @@ pub use mcp_resource::McpResourceHandler; pub use multi_agents::MultiAgentHandler; pub use plan::PlanHandler; pub use read_file::ReadFileHandler; +pub use request_permissions::RequestPermissionsHandler; +pub(crate) use request_permissions::request_permissions_tool_description; pub use request_user_input::RequestUserInputHandler; pub(crate) use request_user_input::request_user_input_tool_description; pub(crate) use search_tool_bm25::DEFAULT_LIMIT as SEARCH_TOOL_BM25_DEFAULT_LIMIT; @@ -95,6 +100,7 @@ pub(super) fn normalize_and_validate_additional_permissions( approval_policy: AskForApproval, sandbox_permissions: SandboxPermissions, additional_permissions: Option, + permissions_preapproved: bool, _cwd: &Path, ) -> Result, String> { let uses_additional_permissions = matches!( @@ -112,7 +118,7 @@ pub(super) fn normalize_and_validate_additional_permissions( } if uses_additional_permissions { - if !matches!(approval_policy, AskForApproval::OnRequest) { + if !permissions_preapproved && !matches!(approval_policy, AskForApproval::OnRequest) { return Err(format!( "approval policy is {approval_policy:?}; reject command — you cannot request additional permissions unless the approval policy is OnRequest" )); @@ -146,3 +152,51 @@ pub(super) fn normalize_and_validate_additional_permissions( Ok(None) } } + +pub(super) struct EffectiveAdditionalPermissions { + pub sandbox_permissions: SandboxPermissions, + pub additional_permissions: Option, + pub permissions_preapproved: bool, +} + +pub(super) async fn apply_granted_turn_permissions( + session: &Session, + sandbox_permissions: SandboxPermissions, + additional_permissions: Option, +) -> EffectiveAdditionalPermissions { + if matches!(sandbox_permissions, SandboxPermissions::RequireEscalated) { + return EffectiveAdditionalPermissions { + sandbox_permissions, + additional_permissions, + permissions_preapproved: false, + }; + } + + let granted_permissions = session.granted_turn_permissions().await; + let effective_permissions = merge_permission_profiles( + additional_permissions.as_ref(), + granted_permissions.as_ref(), + ); + let permissions_preapproved = match (effective_permissions.as_ref(), granted_permissions) { + (Some(effective_permissions), Some(granted_permissions)) => { + crate::sandboxing::intersect_permission_profiles( + effective_permissions.clone(), + granted_permissions, + ) == *effective_permissions + } + _ => false, + }; + + let sandbox_permissions = + if effective_permissions.is_some() && !sandbox_permissions.uses_additional_permissions() { + SandboxPermissions::WithAdditionalPermissions + } else { + sandbox_permissions + }; + + EffectiveAdditionalPermissions { + sandbox_permissions, + additional_permissions: effective_permissions, + permissions_preapproved, + } +} diff --git a/codex-rs/core/src/tools/handlers/request_permissions.rs b/codex-rs/core/src/tools/handlers/request_permissions.rs new file mode 100644 index 000000000..be9058e09 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/request_permissions.rs @@ -0,0 +1,75 @@ +use async_trait::async_trait; +use codex_protocol::models::FunctionCallOutputBody; +use codex_protocol::request_permissions::RequestPermissionsArgs; + +use crate::function_tool::FunctionCallError; +use crate::sandboxing::normalize_additional_permissions; +use crate::tools::context::ToolInvocation; +use crate::tools::context::ToolOutput; +use crate::tools::context::ToolPayload; +use crate::tools::handlers::parse_arguments_with_base_path; +use crate::tools::registry::ToolHandler; +use crate::tools::registry::ToolKind; + +pub(crate) fn request_permissions_tool_description() -> String { + "Request additional permissions from the user and wait for the client to grant a subset of the requested permission profile. Granted permissions apply automatically to later shell-like commands in the current turn." + .to_string() +} + +pub struct RequestPermissionsHandler; + +#[async_trait] +impl ToolHandler for RequestPermissionsHandler { + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + async fn handle(&self, invocation: ToolInvocation) -> Result { + let ToolInvocation { + session, + turn, + call_id, + payload, + .. + } = invocation; + + let arguments = match payload { + ToolPayload::Function { arguments } => arguments, + _ => { + return Err(FunctionCallError::RespondToModel( + "request_permissions handler received unsupported payload".to_string(), + )); + } + }; + + let mut args: RequestPermissionsArgs = + parse_arguments_with_base_path(&arguments, turn.cwd.as_path())?; + args.permissions = normalize_additional_permissions(args.permissions) + .map_err(FunctionCallError::RespondToModel)?; + if args.permissions.is_empty() { + return Err(FunctionCallError::RespondToModel( + "request_permissions requires at least one permission".to_string(), + )); + } + + let response = session + .request_permissions(turn.as_ref(), call_id, args) + .await + .ok_or_else(|| { + FunctionCallError::RespondToModel( + "request_permissions was cancelled before receiving a response".to_string(), + ) + })?; + + let content = serde_json::to_string(&response).map_err(|err| { + FunctionCallError::Fatal(format!( + "failed to serialize request_permissions response: {err}" + )) + })?; + + Ok(ToolOutput::Function { + body: FunctionCallOutputBody::Text(content), + success: Some(true), + }) + } +} diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index cea0b9dd3..0c50d0226 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -20,6 +20,7 @@ use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; use crate::tools::events::ToolEmitter; use crate::tools::events::ToolEventCtx; +use crate::tools::handlers::apply_granted_turn_permissions; use crate::tools::handlers::apply_patch::intercept_apply_patch; use crate::tools::handlers::normalize_and_validate_additional_permissions; use crate::tools::handlers::parse_arguments_with_base_path; @@ -332,17 +333,29 @@ impl ShellHandler { } let request_permission_enabled = session.features().enabled(Feature::RequestPermissions); + let effective_additional_permissions = apply_granted_turn_permissions( + session.as_ref(), + exec_params.sandbox_permissions, + additional_permissions, + ) + .await; let normalized_additional_permissions = normalize_and_validate_additional_permissions( request_permission_enabled, turn.approval_policy.value(), - exec_params.sandbox_permissions, - additional_permissions, + effective_additional_permissions.sandbox_permissions, + effective_additional_permissions.additional_permissions, + effective_additional_permissions.permissions_preapproved, &exec_params.cwd, ) .map_err(FunctionCallError::RespondToModel)?; // Approval policy guard for explicit escalation in non-OnRequest modes. - if exec_params.sandbox_permissions.requests_sandbox_override() + // Sticky turn permissions have already been approved, so they should + // continue through the normal exec approval flow for the command. + if effective_additional_permissions + .sandbox_permissions + .requests_sandbox_override() + && !effective_additional_permissions.permissions_preapproved && !matches!( turn.approval_policy.value(), codex_protocol::protocol::AskForApproval::OnRequest @@ -387,7 +400,11 @@ impl ShellHandler { command: &exec_params.command, approval_policy: turn.approval_policy.value(), sandbox_policy: turn.sandbox_policy.get(), - sandbox_permissions: exec_params.sandbox_permissions, + sandbox_permissions: if effective_additional_permissions.permissions_preapproved { + codex_protocol::models::SandboxPermissions::UseDefault + } else { + effective_additional_permissions.sandbox_permissions + }, prefix_rule, }) .await; @@ -399,7 +416,7 @@ impl ShellHandler { env: exec_params.env.clone(), explicit_env_overrides, network: exec_params.network.clone(), - sandbox_permissions: exec_params.sandbox_permissions, + sandbox_permissions: effective_additional_permissions.sandbox_permissions, additional_permissions: normalized_additional_permissions, justification: exec_params.justification.clone(), exec_approval_requirement, diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index 915c1734c..c2fc536b3 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -10,6 +10,7 @@ use crate::skills::maybe_emit_implicit_skill_invocation; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; +use crate::tools::handlers::apply_granted_turn_permissions; use crate::tools::handlers::apply_patch::intercept_apply_patch; use crate::tools::handlers::normalize_and_validate_additional_permissions; use crate::tools::handlers::parse_arguments; @@ -170,8 +171,19 @@ impl ToolHandler for UnifiedExecHandler { let request_permission_enabled = session.features().enabled(Feature::RequestPermissions); + let effective_additional_permissions = apply_granted_turn_permissions( + context.session.as_ref(), + sandbox_permissions, + additional_permissions, + ) + .await; - if sandbox_permissions.requests_sandbox_override() + // Sticky turn permissions have already been approved, so they should + // continue through the normal exec approval flow for the command. + if effective_additional_permissions + .sandbox_permissions + .requests_sandbox_override() + && !effective_additional_permissions.permissions_preapproved && !matches!( context.turn.approval_policy.value(), codex_protocol::protocol::AskForApproval::OnRequest @@ -192,8 +204,9 @@ impl ToolHandler for UnifiedExecHandler { match normalize_and_validate_additional_permissions( request_permission_enabled, context.turn.approval_policy.value(), - sandbox_permissions, - additional_permissions, + effective_additional_permissions.sandbox_permissions, + effective_additional_permissions.additional_permissions, + effective_additional_permissions.permissions_preapproved, &cwd, ) { Ok(normalized) => normalized, @@ -229,8 +242,11 @@ impl ToolHandler for UnifiedExecHandler { workdir, network: context.turn.network.clone(), tty, - sandbox_permissions, + sandbox_permissions: effective_additional_permissions + .sandbox_permissions, additional_permissions: normalized_additional_permissions, + additional_permissions_preapproved: effective_additional_permissions + .permissions_preapproved, justification, prefix_rule, }, diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 9c6cb95a6..f3cec19a1 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -16,6 +16,7 @@ use crate::tools::handlers::apply_patch::create_apply_patch_json_tool; use crate::tools::handlers::multi_agents::DEFAULT_WAIT_TIMEOUT_MS; use crate::tools::handlers::multi_agents::MAX_WAIT_TIMEOUT_MS; use crate::tools::handlers::multi_agents::MIN_WAIT_TIMEOUT_MS; +use crate::tools::handlers::request_permissions_tool_description; use crate::tools::handlers::request_user_input_tool_description; use crate::tools::registry::ToolRegistryBuilder; use codex_protocol::config_types::WebSearchConfig; @@ -65,6 +66,7 @@ pub(crate) struct ToolsConfig { pub agent_roles: BTreeMap, pub search_tool: bool, pub request_permission_enabled: bool, + pub request_permissions_tool_enabled: bool, pub js_repl_enabled: bool, pub js_repl_tools_only: bool, pub collab_tools: bool, @@ -106,6 +108,7 @@ impl ToolsConfig { features.enabled(Feature::ImageGeneration) && supports_image_generation(model_info); let include_agent_jobs = include_collab_tools; let request_permission_enabled = features.enabled(Feature::RequestPermissions); + let request_permissions_tool_enabled = features.enabled(Feature::RequestPermissionsTool); let shell_command_backend = if features.enabled(Feature::ShellTool) && features.enabled(Feature::ShellZshFork) { ShellCommandBackendConfig::ZshFork @@ -166,6 +169,7 @@ impl ToolsConfig { agent_roles: BTreeMap::new(), search_tool: include_search_tool, request_permission_enabled, + request_permissions_tool_enabled, js_repl_enabled: include_js_repl, js_repl_tools_only: include_js_repl_tools_only, collab_tools: include_collab_tools, @@ -254,6 +258,94 @@ impl From for AdditionalProperties { } } +fn create_network_permissions_schema() -> JsonSchema { + JsonSchema::Object { + properties: BTreeMap::from([( + "enabled".to_string(), + JsonSchema::Boolean { + description: Some("Set to true to request network access.".to_string()), + }, + )]), + required: None, + additional_properties: Some(false.into()), + } +} + +fn create_file_system_permissions_schema() -> JsonSchema { + JsonSchema::Object { + properties: BTreeMap::from([ + ( + "read".to_string(), + JsonSchema::Array { + items: Box::new(JsonSchema::String { description: None }), + description: Some("Absolute paths to grant read access to.".to_string()), + }, + ), + ( + "write".to_string(), + JsonSchema::Array { + items: Box::new(JsonSchema::String { description: None }), + description: Some("Absolute paths to grant write access to.".to_string()), + }, + ), + ]), + required: None, + additional_properties: Some(false.into()), + } +} + +fn create_macos_permissions_schema() -> JsonSchema { + JsonSchema::Object { + properties: BTreeMap::from([ + ( + "preferences".to_string(), + JsonSchema::String { + description: Some( + "macOS preferences access. Supported values: `none`, `read_only`, or `read_write`." + .to_string(), + ), + }, + ), + ( + "automations".to_string(), + JsonSchema::Array { + items: Box::new(JsonSchema::String { description: None }), + description: Some("macOS automation access as app bundle identifiers.".to_string()), + }, + ), + ( + "accessibility".to_string(), + JsonSchema::Boolean { + description: Some("Whether to request macOS accessibility access.".to_string()), + }, + ), + ( + "calendar".to_string(), + JsonSchema::Boolean { + description: Some("Whether to request macOS calendar access.".to_string()), + }, + ), + ]), + required: None, + additional_properties: Some(false.into()), + } +} + +fn create_permissions_schema() -> JsonSchema { + JsonSchema::Object { + properties: BTreeMap::from([ + ("network".to_string(), create_network_permissions_schema()), + ( + "file_system".to_string(), + create_file_system_permissions_schema(), + ), + ("macos".to_string(), create_macos_permissions_schema()), + ]), + required: None, + additional_properties: Some(false.into()), + } +} + fn create_approval_parameters(request_permission_enabled: bool) -> BTreeMap { let mut properties = BTreeMap::from([ ( @@ -298,103 +390,7 @@ fn create_approval_parameters(request_permission_enabled: bool) -> BTreeMap ToolSpec { + let mut properties = BTreeMap::new(); + properties.insert( + "reason".to_string(), + JsonSchema::String { + description: Some( + "Optional short explanation for why additional permissions are needed.".to_string(), + ), + }, + ); + properties.insert("permissions".to_string(), create_permissions_schema()); + + ToolSpec::Function(ResponsesApiTool { + name: "request_permissions".to_string(), + description: request_permissions_tool_description(), + strict: false, + parameters: JsonSchema::Object { + properties, + required: Some(vec!["permissions".to_string()]), + additional_properties: Some(false.into()), + }, + }) +} + fn create_close_agent_tool() -> ToolSpec { let mut properties = BTreeMap::new(); properties.insert( @@ -1819,6 +1839,7 @@ pub(crate) fn build_specs( use crate::tools::handlers::MultiAgentHandler; use crate::tools::handlers::PlanHandler; use crate::tools::handlers::ReadFileHandler; + use crate::tools::handlers::RequestPermissionsHandler; use crate::tools::handlers::RequestUserInputHandler; use crate::tools::handlers::SearchToolBm25Handler; use crate::tools::handlers::ShellCommandHandler; @@ -1839,6 +1860,7 @@ pub(crate) fn build_specs( let mcp_handler = Arc::new(McpHandler); let mcp_resource_handler = Arc::new(McpResourceHandler); let shell_command_handler = Arc::new(ShellCommandHandler::from(config.shell_command_backend)); + let request_permissions_handler = Arc::new(RequestPermissionsHandler); let request_user_input_handler = Arc::new(RequestUserInputHandler { default_mode_request_user_input: config.default_mode_request_user_input, }); @@ -1912,6 +1934,11 @@ pub(crate) fn build_specs( builder.register_handler("request_user_input", request_user_input_handler); } + if config.request_permissions_tool_enabled { + builder.push_spec(create_request_permissions_tool()); + builder.register_handler("request_permissions", request_permissions_handler); + } + if config.search_tool && let Some(app_tools) = app_tools { @@ -2296,6 +2323,11 @@ mod tests { expected.insert(tool_name(&spec).to_string(), spec); } + if config.request_permission_enabled { + let spec = create_request_permissions_tool(); + expected.insert(tool_name(&spec).to_string(), spec); + } + // Exact name set match — this is the only test allowed to fail when tools change. let actual_names: HashSet<_> = actual.keys().cloned().collect(); let expected_names: HashSet<_> = expected.keys().cloned().collect(); @@ -2424,6 +2456,55 @@ mod tests { ); } + #[test] + fn request_permissions_requires_feature_flag() { + let config = test_config(); + let model_info = + ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); + let features = Features::with_defaults(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + }); + let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); + assert_lacks_tool_name(&tools, "request_permissions"); + + let mut features = Features::with_defaults(); + features.enable(Feature::RequestPermissionsTool); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + }); + let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); + let request_permissions_tool = find_tool(&tools, "request_permissions"); + assert_eq!( + request_permissions_tool.spec, + create_request_permissions_tool() + ); + } + + #[test] + fn request_permissions_tool_is_independent_from_additional_permissions() { + let config = test_config(); + let model_info = + ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); + let mut features = Features::with_defaults(); + features.enable(Feature::RequestPermissions); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + }); + let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); + + assert_lacks_tool_name(&tools, "request_permissions"); + } + #[test] fn get_memory_requires_feature_flag() { let config = test_config(); @@ -3528,6 +3609,67 @@ Examples of valid command strings: assert!(additional_properties.contains_key("macos")); } + #[test] + fn request_permissions_tool_includes_full_permission_schema() { + let tool = super::create_request_permissions_tool(); + let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = tool else { + panic!("expected function tool"); + }; + let JsonSchema::Object { properties, .. } = parameters else { + panic!("expected object parameters"); + }; + let Some(JsonSchema::Object { + properties: permission_properties, + additional_properties, + .. + }) = properties.get("permissions") + else { + panic!("expected permissions object"); + }; + + assert_eq!(additional_properties, &Some(false.into())); + assert!(permission_properties.contains_key("network")); + assert!(permission_properties.contains_key("file_system")); + assert!(permission_properties.contains_key("macos")); + + let Some(JsonSchema::Object { + properties: network_properties, + additional_properties, + .. + }) = permission_properties.get("network") + else { + panic!("expected network object"); + }; + assert_eq!(additional_properties, &Some(false.into())); + assert!(network_properties.contains_key("enabled")); + + let Some(JsonSchema::Object { + properties: file_system_properties, + additional_properties, + .. + }) = permission_properties.get("file_system") + else { + panic!("expected file_system object"); + }; + assert_eq!(additional_properties, &Some(false.into())); + assert!(file_system_properties.contains_key("read")); + assert!(file_system_properties.contains_key("write")); + + let Some(JsonSchema::Object { + properties: macos_properties, + additional_properties, + .. + }) = permission_properties.get("macos") + else { + panic!("expected macos object"); + }; + assert_eq!(additional_properties, &Some(false.into())); + assert!(macos_properties.contains_key("preferences")); + assert!(macos_properties.contains_key("automations")); + assert!(macos_properties.contains_key("accessibility")); + assert!(macos_properties.contains_key("calendar")); + } + #[test] fn test_shell_command_tool() { let tool = super::create_shell_command_tool(true, false); diff --git a/codex-rs/core/src/unified_exec/mod.rs b/codex-rs/core/src/unified_exec/mod.rs index 2e46d6a94..b7d349e17 100644 --- a/codex-rs/core/src/unified_exec/mod.rs +++ b/codex-rs/core/src/unified_exec/mod.rs @@ -95,6 +95,7 @@ pub(crate) struct ExecCommandRequest { pub tty: bool, pub sandbox_permissions: SandboxPermissions, pub additional_permissions: Option, + pub additional_permissions_preapproved: bool, pub justification: Option, pub prefix_rule: Option>, } @@ -240,6 +241,7 @@ mod tests { tty: true, sandbox_permissions: SandboxPermissions::UseDefault, additional_permissions: None, + additional_permissions_preapproved: false, justification: None, prefix_rule: None, }, diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index 770379afa..24214ae66 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -583,7 +583,11 @@ impl UnifiedExecProcessManager { command: &request.command, approval_policy: context.turn.approval_policy.value(), sandbox_policy: context.turn.sandbox_policy.get(), - sandbox_permissions: request.sandbox_permissions, + sandbox_permissions: if request.additional_permissions_preapproved { + crate::sandboxing::SandboxPermissions::UseDefault + } else { + request.sandbox_permissions + }, prefix_rule: request.prefix_rule.clone(), }) .await; diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index 7080592cd..01dea4217 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -12,6 +12,7 @@ use codex_protocol::protocol::ExecApprovalRequestEvent; use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::user_input::UserInput; use codex_utils_absolute_path::AbsolutePathBuf; use core_test_support::responses::ev_assistant_message; @@ -19,10 +20,10 @@ use core_test_support::responses::ev_completed; use core_test_support::responses::ev_function_call; use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_once; +use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; -#[cfg(target_os = "macos")] use core_test_support::skip_if_sandbox; use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::test_codex; @@ -97,7 +98,65 @@ fn shell_event_with_request_permissions( Ok(ev_function_call(call_id, "shell_command", &args_str)) } -#[cfg(target_os = "macos")] +fn request_permissions_tool_event( + call_id: &str, + reason: &str, + permissions: &PermissionProfile, +) -> Result { + let args = json!({ + "reason": reason, + "permissions": permissions, + }); + let args_str = serde_json::to_string(&args)?; + Ok(ev_function_call(call_id, "request_permissions", &args_str)) +} + +fn shell_command_event(call_id: &str, command: &str) -> Result { + let args = json!({ + "command": command, + "timeout_ms": 1_000_u64, + }); + let args_str = serde_json::to_string(&args)?; + Ok(ev_function_call(call_id, "shell_command", &args_str)) +} + +fn exec_command_event(call_id: &str, command: &str) -> Result { + let args = json!({ + "cmd": command, + "yield_time_ms": 1_000_u64, + }); + let args_str = serde_json::to_string(&args)?; + Ok(ev_function_call(call_id, "exec_command", &args_str)) +} + +fn exec_command_event_with_request_permissions( + call_id: &str, + command: &str, + additional_permissions: &PermissionProfile, +) -> Result { + let args = json!({ + "cmd": command, + "yield_time_ms": 1_000_u64, + "sandbox_permissions": SandboxPermissions::WithAdditionalPermissions, + "additional_permissions": additional_permissions, + }); + let args_str = serde_json::to_string(&args)?; + Ok(ev_function_call(call_id, "exec_command", &args_str)) +} + +fn exec_command_event_with_missing_additional_permissions( + call_id: &str, + command: &str, +) -> Result { + let args = json!({ + "cmd": command, + "yield_time_ms": 1_000_u64, + "sandbox_permissions": SandboxPermissions::WithAdditionalPermissions, + }); + let args_str = serde_json::to_string(&args)?; + Ok(ev_function_call(call_id, "exec_command", &args_str)) +} + fn shell_event_with_raw_request_permissions( call_id: &str, command: &str, @@ -177,6 +236,46 @@ async fn expect_exec_approval( } } +async fn wait_for_exec_approval_or_completion( + test: &TestCodex, +) -> Option { + let event = wait_for_event(&test.codex, |event| { + matches!( + event, + EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_) + ) + }) + .await; + + match event { + EventMsg::ExecApprovalRequest(approval) => Some(approval), + EventMsg::TurnComplete(_) => None, + other => panic!("unexpected event: {other:?}"), + } +} + +async fn expect_request_permissions_event( + test: &TestCodex, + expected_call_id: &str, +) -> PermissionProfile { + let event = wait_for_event(&test.codex, |event| { + matches!( + event, + EventMsg::RequestPermissions(_) | EventMsg::TurnComplete(_) + ) + }) + .await; + + match event { + EventMsg::RequestPermissions(request) => { + assert_eq!(request.call_id, expected_call_id); + request.permissions + } + EventMsg::TurnComplete(_) => panic!("expected request_permissions before completion"), + other => panic!("unexpected event: {other:?}"), + } +} + fn workspace_write_excluding_tmp() -> SandboxPolicy { SandboxPolicy::WorkspaceWrite { writable_roots: vec![], @@ -187,8 +286,27 @@ fn workspace_write_excluding_tmp() -> SandboxPolicy { } } +fn requested_directory_write_permissions(path: &Path) -> PermissionProfile { + PermissionProfile { + file_system: Some(FileSystemPermissions { + read: Some(vec![]), + write: Some(vec![absolute_path(path)]), + }), + ..Default::default() + } +} + +fn normalized_directory_write_permissions(path: &Path) -> Result { + Ok(PermissionProfile { + file_system: Some(FileSystemPermissions { + read: Some(vec![]), + write: Some(vec![AbsolutePathBuf::try_from(path.canonicalize()?)?]), + }), + ..Default::default() + }) +} + #[tokio::test(flavor = "current_thread")] -#[cfg(target_os = "macos")] async fn with_additional_permissions_requires_approval_under_on_request() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); @@ -205,6 +323,10 @@ async fn with_additional_permissions_requires_approval_under_on_request() -> Res .features .enable(Feature::RequestPermissions) .expect("test config should allow feature update"); + config + .features + .enable(Feature::RequestPermissionsTool) + .expect("test config should allow feature update"); }); let test = builder.build(&server).await?; @@ -273,7 +395,6 @@ async fn with_additional_permissions_requires_approval_under_on_request() -> Res } #[tokio::test(flavor = "current_thread")] -#[cfg(target_os = "macos")] async fn relative_additional_permissions_resolve_against_tool_workdir() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); @@ -290,6 +411,10 @@ async fn relative_additional_permissions_resolve_against_tool_workdir() -> Resul .features .enable(Feature::RequestPermissions) .expect("test config should allow feature update"); + config + .features + .enable(Feature::RequestPermissionsTool) + .expect("test config should allow feature update"); }); let test = builder.build(&server).await?; @@ -387,6 +512,10 @@ async fn read_only_with_additional_permissions_does_not_widen_to_unrequested_cwd .features .enable(Feature::RequestPermissions) .expect("test config should allow feature update"); + config + .features + .enable(Feature::RequestPermissionsTool) + .expect("test config should allow feature update"); }); let test = builder.build(&server).await?; @@ -483,6 +612,10 @@ async fn read_only_with_additional_permissions_does_not_widen_to_unrequested_tmp .features .enable(Feature::RequestPermissions) .expect("test config should allow feature update"); + config + .features + .enable(Feature::RequestPermissionsTool) + .expect("test config should allow feature update"); }); let test = builder.build(&server).await?; @@ -562,7 +695,6 @@ async fn read_only_with_additional_permissions_does_not_widen_to_unrequested_tmp } #[tokio::test(flavor = "current_thread")] -#[cfg(target_os = "macos")] async fn workspace_write_with_additional_permissions_can_write_outside_cwd() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); @@ -579,6 +711,10 @@ async fn workspace_write_with_additional_permissions_can_write_outside_cwd() -> .features .enable(Feature::RequestPermissions) .expect("test config should allow feature update"); + config + .features + .enable(Feature::RequestPermissionsTool) + .expect("test config should allow feature update"); }); let test = builder.build(&server).await?; @@ -665,7 +801,6 @@ async fn workspace_write_with_additional_permissions_can_write_outside_cwd() -> } #[tokio::test(flavor = "current_thread")] -#[cfg(unix)] async fn with_additional_permissions_denied_approval_blocks_execution() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; @@ -680,6 +815,10 @@ async fn with_additional_permissions_denied_approval_blocks_execution() -> Resul .features .enable(Feature::RequestPermissions) .expect("test config should allow feature update"); + config + .features + .enable(Feature::RequestPermissionsTool) + .expect("test config should allow feature update"); }); let test = builder.build(&server).await?; @@ -763,3 +902,619 @@ async fn with_additional_permissions_denied_approval_blocks_execution() -> Resul let _ = fs::remove_file(outside_write); Ok(()) } + +#[tokio::test(flavor = "current_thread")] +async fn request_permissions_grants_apply_to_later_exec_command_calls() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + + let server = start_mock_server().await; + let approval_policy = AskForApproval::OnRequest; + let sandbox_policy = workspace_write_excluding_tmp(); + let sandbox_policy_for_config = sandbox_policy.clone(); + + let mut builder = test_codex().with_config(move |config| { + config.permissions.approval_policy = Constrained::allow_any(approval_policy); + config.permissions.sandbox_policy = Constrained::allow_any(sandbox_policy_for_config); + config + .features + .enable(Feature::RequestPermissions) + .expect("test config should allow feature update"); + config + .features + .enable(Feature::RequestPermissionsTool) + .expect("test config should allow feature update"); + }); + let test = builder.build(&server).await?; + + let outside_dir = tempfile::tempdir()?; + let outside_write = outside_dir.path().join("sticky-write.txt"); + let command = format!( + "printf {:?} > {:?} && cat {:?}", + "sticky-grant-ok", outside_write, outside_write + ); + let requested_permissions = PermissionProfile { + file_system: Some(FileSystemPermissions { + read: Some(vec![]), + write: Some(vec![absolute_path(outside_dir.path())]), + }), + ..Default::default() + }; + let normalized_requested_permissions = PermissionProfile { + file_system: Some(FileSystemPermissions { + read: Some(vec![]), + write: Some(vec![AbsolutePathBuf::try_from( + outside_dir.path().canonicalize()?, + )?]), + }), + ..Default::default() + }; + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-sticky-1"), + request_permissions_tool_event( + "permissions-call", + "Allow writing outside the workspace", + &requested_permissions, + )?, + ev_completed("resp-sticky-1"), + ]), + sse(vec![ + ev_response_created("resp-sticky-2"), + exec_command_event("exec-call", &command)?, + ev_completed("resp-sticky-2"), + ]), + sse(vec![ + ev_response_created("resp-sticky-3"), + ev_assistant_message("msg-sticky-1", "done"), + ev_completed("resp-sticky-3"), + ]), + ], + ) + .await; + + submit_turn( + &test, + "write outside the workspace", + approval_policy, + sandbox_policy, + ) + .await?; + + let granted_permissions = expect_request_permissions_event(&test, "permissions-call").await; + assert_eq!( + granted_permissions, + normalized_requested_permissions.clone() + ); + test.codex + .submit(Op::RequestPermissionsResponse { + id: "permissions-call".to_string(), + response: RequestPermissionsResponse { + permissions: normalized_requested_permissions.clone(), + }, + }) + .await?; + + if let Some(approval) = wait_for_exec_approval_or_completion(&test).await { + assert_eq!( + approval.additional_permissions, + Some(normalized_requested_permissions.clone()) + ); + test.codex + .submit(Op::ExecApproval { + id: approval.effective_approval_id(), + turn_id: None, + decision: ReviewDecision::Approved, + }) + .await?; + wait_for_completion(&test).await; + } + + let exec_output = responses + .function_call_output_text("exec-call") + .map(|output| json!({ "output": output })) + .unwrap_or_else(|| panic!("expected exec-call output")); + let result = parse_result(&exec_output); + assert_eq!(result.exit_code, Some(0)); + assert_eq!(result.stdout.trim(), "sticky-grant-ok"); + assert_eq!(fs::read_to_string(&outside_write)?, "sticky-grant-ok"); + + Ok(()) +} + +#[tokio::test(flavor = "current_thread")] +async fn request_permissions_preapprove_explicit_exec_permissions_outside_on_request() -> Result<()> +{ + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + + let server = start_mock_server().await; + let approval_policy = AskForApproval::OnRequest; + let sandbox_policy = workspace_write_excluding_tmp(); + let sandbox_policy_for_config = sandbox_policy.clone(); + + let mut builder = test_codex().with_config(move |config| { + config.permissions.approval_policy = Constrained::allow_any(approval_policy); + config.permissions.sandbox_policy = Constrained::allow_any(sandbox_policy_for_config); + config + .features + .enable(Feature::RequestPermissions) + .expect("test config should allow feature update"); + config + .features + .enable(Feature::RequestPermissionsTool) + .expect("test config should allow feature update"); + }); + let test = builder.build(&server).await?; + + let outside_dir = tempfile::tempdir()?; + let outside_write = outside_dir.path().join("sticky-explicit-write.txt"); + let command = format!( + "printf {:?} > {:?} && cat {:?}", + "sticky-explicit-grant-ok", outside_write, outside_write + ); + let requested_permissions = requested_directory_write_permissions(outside_dir.path()); + let normalized_requested_permissions = + normalized_directory_write_permissions(outside_dir.path())?; + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-sticky-explicit-1"), + request_permissions_tool_event( + "permissions-call", + "Allow writing outside the workspace", + &requested_permissions, + )?, + ev_completed("resp-sticky-explicit-1"), + ]), + sse(vec![ + ev_response_created("resp-sticky-explicit-2"), + exec_command_event_with_request_permissions( + "exec-call", + &command, + &requested_permissions, + )?, + ev_completed("resp-sticky-explicit-2"), + ]), + sse(vec![ + ev_response_created("resp-sticky-explicit-3"), + ev_assistant_message("msg-sticky-explicit-1", "done"), + ev_completed("resp-sticky-explicit-3"), + ]), + ], + ) + .await; + + submit_turn( + &test, + "write outside the workspace", + approval_policy, + sandbox_policy, + ) + .await?; + + let granted_permissions = expect_request_permissions_event(&test, "permissions-call").await; + assert_eq!( + granted_permissions, + normalized_requested_permissions.clone() + ); + test.codex + .submit(Op::RequestPermissionsResponse { + id: "permissions-call".to_string(), + response: RequestPermissionsResponse { + permissions: normalized_requested_permissions, + }, + }) + .await?; + + if let Some(approval) = wait_for_exec_approval_or_completion(&test).await { + test.codex + .submit(Op::ExecApproval { + id: approval.effective_approval_id(), + turn_id: None, + decision: ReviewDecision::Approved, + }) + .await?; + wait_for_completion(&test).await; + } + + let exec_output = responses + .function_call_output_text("exec-call") + .map(|output| json!({ "output": output })) + .unwrap_or_else(|| panic!("expected exec-call output")); + let result = parse_result(&exec_output); + assert!( + result.exit_code.is_none_or(|exit_code| exit_code == 0), + "expected success output, got exit_code={:?}, stdout={:?}", + result.exit_code, + result.stdout + ); + assert_eq!(result.stdout.trim(), "sticky-explicit-grant-ok"); + assert_eq!( + fs::read_to_string(&outside_write)?, + "sticky-explicit-grant-ok" + ); + + Ok(()) +} + +#[tokio::test(flavor = "current_thread")] +async fn request_permissions_grants_apply_to_later_shell_command_calls() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + + let server = start_mock_server().await; + let approval_policy = AskForApproval::OnRequest; + let sandbox_policy = workspace_write_excluding_tmp(); + let sandbox_policy_for_config = sandbox_policy.clone(); + + let mut builder = test_codex().with_config(move |config| { + config.permissions.approval_policy = Constrained::allow_any(approval_policy); + config.permissions.sandbox_policy = Constrained::allow_any(sandbox_policy_for_config); + config + .features + .enable(Feature::RequestPermissions) + .expect("test config should allow feature update"); + config + .features + .enable(Feature::RequestPermissionsTool) + .expect("test config should allow feature update"); + }); + let test = builder.build(&server).await?; + + let outside_dir = tempfile::tempdir()?; + let outside_write = outside_dir.path().join("sticky-shell-write.txt"); + let command = format!( + "printf {:?} > {:?} && cat {:?}", + "sticky-shell-grant-ok", outside_write, outside_write + ); + let requested_permissions = requested_directory_write_permissions(outside_dir.path()); + let normalized_requested_permissions = + normalized_directory_write_permissions(outside_dir.path())?; + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-sticky-shell-1"), + request_permissions_tool_event( + "permissions-call", + "Allow writing outside the workspace", + &requested_permissions, + )?, + ev_completed("resp-sticky-shell-1"), + ]), + sse(vec![ + ev_response_created("resp-sticky-shell-2"), + shell_command_event("shell-call", &command)?, + ev_completed("resp-sticky-shell-2"), + ]), + sse(vec![ + ev_response_created("resp-sticky-shell-3"), + ev_assistant_message("msg-sticky-shell-1", "done"), + ev_completed("resp-sticky-shell-3"), + ]), + ], + ) + .await; + + submit_turn( + &test, + "write outside the workspace", + approval_policy, + sandbox_policy, + ) + .await?; + + let granted_permissions = expect_request_permissions_event(&test, "permissions-call").await; + assert_eq!( + granted_permissions, + normalized_requested_permissions.clone() + ); + test.codex + .submit(Op::RequestPermissionsResponse { + id: "permissions-call".to_string(), + response: RequestPermissionsResponse { + permissions: normalized_requested_permissions.clone(), + }, + }) + .await?; + + if let Some(approval) = wait_for_exec_approval_or_completion(&test).await { + test.codex + .submit(Op::ExecApproval { + id: approval.effective_approval_id(), + turn_id: None, + decision: ReviewDecision::Approved, + }) + .await?; + wait_for_completion(&test).await; + } + + let shell_output = responses + .function_call_output_text("shell-call") + .map(|output| json!({ "output": output })) + .unwrap_or_else(|| panic!("expected shell-call output")); + let result = parse_result(&shell_output); + assert!( + result.exit_code.is_none_or(|exit_code| exit_code == 0), + "expected success output, got exit_code={:?}, stdout={:?}", + result.exit_code, + result.stdout + ); + assert_eq!(result.stdout.trim(), "sticky-shell-grant-ok"); + assert_eq!(fs::read_to_string(&outside_write)?, "sticky-shell-grant-ok"); + + Ok(()) +} + +#[tokio::test(flavor = "current_thread")] +async fn partial_request_permissions_grants_do_not_preapprove_new_permissions() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + + let server = start_mock_server().await; + let approval_policy = AskForApproval::OnRequest; + let sandbox_policy = workspace_write_excluding_tmp(); + let sandbox_policy_for_config = sandbox_policy.clone(); + + let mut builder = test_codex().with_config(move |config| { + config.permissions.approval_policy = Constrained::allow_any(approval_policy); + config.permissions.sandbox_policy = Constrained::allow_any(sandbox_policy_for_config); + config + .features + .enable(Feature::RequestPermissions) + .expect("test config should allow feature update"); + config + .features + .enable(Feature::RequestPermissionsTool) + .expect("test config should allow feature update"); + }); + let test = builder.build(&server).await?; + + let first_dir = tempfile::tempdir()?; + let second_dir = tempfile::tempdir()?; + let second_write = second_dir.path().join("partial-grant-write.txt"); + let command = format!( + "printf {:?} > {:?} && cat {:?}", + "partial-grant-ok", second_write, second_write + ); + + let requested_permissions = PermissionProfile { + file_system: Some(FileSystemPermissions { + read: Some(vec![]), + write: Some(vec![ + absolute_path(first_dir.path()), + absolute_path(second_dir.path()), + ]), + }), + ..Default::default() + }; + let normalized_requested_permissions = PermissionProfile { + file_system: Some(FileSystemPermissions { + read: Some(vec![]), + write: Some(vec![ + AbsolutePathBuf::try_from(first_dir.path().canonicalize()?)?, + AbsolutePathBuf::try_from(second_dir.path().canonicalize()?)?, + ]), + }), + ..Default::default() + }; + let granted_permissions = normalized_directory_write_permissions(first_dir.path())?; + let second_dir_permissions = requested_directory_write_permissions(second_dir.path()); + let merged_permissions = PermissionProfile { + file_system: Some(FileSystemPermissions { + read: Some(vec![]), + write: Some(vec![ + AbsolutePathBuf::try_from(first_dir.path().canonicalize()?)?, + AbsolutePathBuf::try_from(second_dir.path().canonicalize()?)?, + ]), + }), + ..Default::default() + }; + + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-partial-1"), + request_permissions_tool_event( + "permissions-call", + "Allow writing outside the workspace", + &requested_permissions, + )?, + ev_completed("resp-partial-1"), + ]), + sse(vec![ + ev_response_created("resp-partial-2"), + exec_command_event_with_request_permissions( + "exec-call", + &command, + &second_dir_permissions, + )?, + ev_completed("resp-partial-2"), + ]), + sse(vec![ + ev_response_created("resp-partial-3"), + ev_assistant_message("msg-partial-1", "done"), + ev_completed("resp-partial-3"), + ]), + ], + ) + .await; + + submit_turn( + &test, + "write outside the workspace", + approval_policy, + sandbox_policy, + ) + .await?; + + let initial_request = expect_request_permissions_event(&test, "permissions-call").await; + assert_eq!(initial_request, normalized_requested_permissions); + test.codex + .submit(Op::RequestPermissionsResponse { + id: "permissions-call".to_string(), + response: RequestPermissionsResponse { + permissions: granted_permissions.clone(), + }, + }) + .await?; + + let approval = expect_exec_approval(&test, &command).await; + let approval_permissions = approval + .additional_permissions + .clone() + .unwrap_or_else(|| panic!("expected merged additional permissions")); + assert_eq!(approval_permissions.network, None); + assert_eq!(approval_permissions.macos, None); + + let approval_file_system = approval_permissions + .file_system + .unwrap_or_else(|| panic!("expected filesystem permissions")); + assert!(approval_file_system.read.as_ref().is_none_or(Vec::is_empty)); + + let mut approval_writes = approval_file_system.write.unwrap_or_default(); + approval_writes.sort_by_key(|path| path.display().to_string()); + + let mut expected_writes = merged_permissions + .file_system + .unwrap_or_else(|| panic!("expected merged filesystem permissions")) + .write + .unwrap_or_default(); + expected_writes.sort_by_key(|path| path.display().to_string()); + + assert_eq!(approval_writes, expected_writes); + test.codex + .submit(Op::ExecApproval { + id: approval.effective_approval_id(), + turn_id: None, + decision: ReviewDecision::Approved, + }) + .await?; + wait_for_completion(&test).await; + + let exec_output = responses + .function_call_output_text("exec-call") + .map(|output| json!({ "output": output })) + .unwrap_or_else(|| panic!("expected exec-call output")); + let result = parse_result(&exec_output); + assert_eq!(result.exit_code, Some(0)); + assert_eq!(result.stdout.trim(), "partial-grant-ok"); + assert_eq!(fs::read_to_string(&second_write)?, "partial-grant-ok"); + + Ok(()) +} + +#[tokio::test(flavor = "current_thread")] +async fn request_permissions_grants_do_not_carry_across_turns() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + + let server = start_mock_server().await; + let approval_policy = AskForApproval::OnRequest; + let sandbox_policy = workspace_write_excluding_tmp(); + let sandbox_policy_for_config = sandbox_policy.clone(); + + let mut builder = test_codex().with_config(move |config| { + config.permissions.approval_policy = Constrained::allow_any(approval_policy); + config.permissions.sandbox_policy = Constrained::allow_any(sandbox_policy_for_config); + config + .features + .enable(Feature::RequestPermissions) + .expect("test config should allow feature update"); + config + .features + .enable(Feature::RequestPermissionsTool) + .expect("test config should allow feature update"); + }); + let test = builder.build(&server).await?; + + let outside_dir = tempfile::tempdir()?; + let requested_permissions = requested_directory_write_permissions(outside_dir.path()); + let normalized_requested_permissions = + normalized_directory_write_permissions(outside_dir.path())?; + + let _first_turn = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-turn-1"), + request_permissions_tool_event( + "permissions-call", + "Allow writing outside the workspace", + &requested_permissions, + )?, + ev_completed("resp-turn-1"), + ]), + sse(vec![ + ev_response_created("resp-turn-2"), + ev_assistant_message("msg-turn-1", "done"), + ev_completed("resp-turn-2"), + ]), + ], + ) + .await; + + submit_turn( + &test, + "request permissions for later use", + approval_policy, + sandbox_policy.clone(), + ) + .await?; + + let granted_permissions = expect_request_permissions_event(&test, "permissions-call").await; + assert_eq!( + granted_permissions, + normalized_requested_permissions.clone() + ); + test.codex + .submit(Op::RequestPermissionsResponse { + id: "permissions-call".to_string(), + response: RequestPermissionsResponse { + permissions: normalized_requested_permissions, + }, + }) + .await?; + wait_for_completion(&test).await; + + let second_turn = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-turn-3"), + exec_command_event_with_missing_additional_permissions( + "exec-call", + "printf 'should not run'", + )?, + ev_completed("resp-turn-3"), + ]), + sse(vec![ + ev_response_created("resp-turn-4"), + ev_assistant_message("msg-turn-2", "done"), + ev_completed("resp-turn-4"), + ]), + ], + ) + .await; + + submit_turn( + &test, + "try to reuse permissions in a later turn", + approval_policy, + sandbox_policy, + ) + .await?; + wait_for_completion(&test).await; + + let output = second_turn + .function_call_output_text("exec-call") + .unwrap_or_else(|| panic!("expected exec-call output")); + assert!(output.contains("missing `additional_permissions`")); + + Ok(()) +} diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index ae58f3c9f..f40dce1fd 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -880,6 +880,7 @@ impl EventProcessor for EventProcessorWithHumanOutput { | EventMsg::UndoStarted(_) | EventMsg::ThreadRolledBack(_) | EventMsg::RequestUserInput(_) + | EventMsg::RequestPermissions(_) | EventMsg::CollabResumeBegin(_) | EventMsg::CollabResumeEnd(_) | EventMsg::RealtimeConversationStarted(_) @@ -961,6 +962,7 @@ impl EventProcessorWithHumanOutput { | EventMsg::UndoStarted(_) | EventMsg::ThreadRolledBack(_) | EventMsg::RequestUserInput(_) + | EventMsg::RequestPermissions(_) | EventMsg::DynamicToolCallRequest(_) | EventMsg::DynamicToolCallResponse(_) ) diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index b4336db81..d0a6ac2c3 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -1308,6 +1308,18 @@ async fn handle_server_request( ) .await } + ServerRequest::PermissionsRequestApproval { request_id, params } => { + reject_server_request( + client, + request_id, + &method, + format!( + "permissions approval is not supported in exec mode for thread `{}`", + params.thread_id + ), + ) + .await + } }; if let Err(err) = handle_result { diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 30b194ae5..03547a7e3 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -367,6 +367,7 @@ async fn run_codex_tool_session_inner( | EventMsg::UndoCompleted(_) | EventMsg::ExitedReviewMode(_) | EventMsg::RequestUserInput(_) + | EventMsg::RequestPermissions(_) | EventMsg::DynamicToolCallRequest(_) | EventMsg::DynamicToolCallResponse(_) | EventMsg::ContextCompacted(_) diff --git a/codex-rs/protocol/src/lib.rs b/codex-rs/protocol/src/lib.rs index afe78d37d..d6adf2c58 100644 --- a/codex-rs/protocol/src/lib.rs +++ b/codex-rs/protocol/src/lib.rs @@ -15,5 +15,6 @@ pub mod parse_command; pub mod permissions; pub mod plan_tool; pub mod protocol; +pub mod request_permissions; pub mod request_user_input; pub mod user_input; diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 151570f78..88a47f206 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -170,9 +170,13 @@ impl TryFrom for MacOsAutomationPermission { #[derive(Debug, Clone, PartialEq, Eq, Default, Hash, Serialize, Deserialize, JsonSchema, TS)] #[serde(default)] pub struct MacOsSeatbeltProfileExtensions { + #[serde(alias = "preferences")] pub macos_preferences: MacOsPreferencesPermission, + #[serde(alias = "automations")] pub macos_automation: MacOsAutomationPermission, + #[serde(alias = "accessibility")] pub macos_accessibility: bool, + #[serde(alias = "calendar")] pub macos_calendar: bool, } @@ -1504,6 +1508,30 @@ mod tests { ); } + #[test] + fn macos_seatbelt_profile_extensions_deserializes_tool_schema_aliases() { + let permissions = + serde_json::from_value::(serde_json::json!({ + "preferences": "read_write", + "automations": ["com.apple.Notes"], + "accessibility": true, + "calendar": true + })) + .expect("deserialize macos permissions"); + + assert_eq!( + permissions, + 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\"") diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index e30c2a0af..82e5084db 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -41,6 +41,8 @@ use crate::num_format::format_with_separators; use crate::openai_models::ReasoningEffort as ReasoningEffortConfig; use crate::parse_command::ParsedCommand; use crate::plan_tool::UpdatePlanArgs; +use crate::request_permissions::RequestPermissionsEvent; +use crate::request_permissions::RequestPermissionsResponse; use crate::request_user_input::RequestUserInputResponse; use crate::user_input::UserInput; use codex_utils_absolute_path::AbsolutePathBuf; @@ -69,6 +71,7 @@ pub use crate::permissions::FileSystemSandboxKind; pub use crate::permissions::FileSystemSandboxPolicy; pub use crate::permissions::FileSystemSpecialPath; pub use crate::permissions::NetworkSandboxPolicy; +pub use crate::request_permissions::RequestPermissionsArgs; pub use crate::request_user_input::RequestUserInputEvent; /// Open/close tags for special user-input blocks. Used across crates to avoid @@ -356,6 +359,14 @@ pub enum Op { response: RequestUserInputResponse, }, + /// Resolve a request_permissions tool call. + RequestPermissionsResponse { + /// Call id for the in-flight request. + id: String, + /// User-granted permissions. + response: RequestPermissionsResponse, + }, + /// Resolve a dynamic tool call request. DynamicToolResponse { /// Call id for the in-flight request. @@ -1144,6 +1155,8 @@ pub enum EventMsg { ExecApprovalRequest(ExecApprovalRequestEvent), + RequestPermissions(RequestPermissionsEvent), + RequestUserInput(RequestUserInputEvent), DynamicToolCallRequest(DynamicToolCallRequest), diff --git a/codex-rs/protocol/src/request_permissions.rs b/codex-rs/protocol/src/request_permissions.rs new file mode 100644 index 000000000..feb43c30c --- /dev/null +++ b/codex-rs/protocol/src/request_permissions.rs @@ -0,0 +1,30 @@ +use crate::models::PermissionProfile; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +pub struct RequestPermissionsArgs { + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, + pub permissions: PermissionProfile, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +pub struct RequestPermissionsResponse { + pub permissions: PermissionProfile, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +pub struct RequestPermissionsEvent { + /// Responses API call id for the associated tool call, if available. + pub call_id: String, + /// Turn ID that this request belongs to. + /// Uses `#[serde(default)]` for backwards compatibility. + #[serde(default)] + pub turn_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, + pub permissions: PermissionProfile, +} diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index e24698434..e32dc854d 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -4867,6 +4867,7 @@ impl ChatWidget { EventMsg::RequestUserInput(ev) => { self.on_request_user_input(ev); } + EventMsg::RequestPermissions(_) => {} EventMsg::ExecCommandBegin(ev) => self.on_exec_command_begin(ev), EventMsg::TerminalInteraction(delta) => self.on_terminal_interaction(delta), EventMsg::ExecCommandOutputDelta(delta) => self.on_exec_command_output_delta(delta),