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.
This commit is contained in:
Jack Mousseau
2026-03-08 20:23:06 -07:00
committed by GitHub
Unverified
parent 4ad3b59de3
commit e6b93841c5
48 changed files with 3332 additions and 130 deletions
@@ -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": {
@@ -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"
}
@@ -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"
}
@@ -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": {
@@ -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": {
@@ -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": {
@@ -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;
@@ -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, };
@@ -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, };
@@ -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";
@@ -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, };
@@ -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, };
@@ -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, };
@@ -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, };
@@ -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";
@@ -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,
@@ -837,6 +837,15 @@ impl From<CoreFileSystemPermissions> for AdditionalFileSystemPermissions {
}
}
impl From<AdditionalFileSystemPermissions> 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<CoreMacOsSeatbeltProfileExtensions> for AdditionalMacOsPermissions {
}
}
impl From<AdditionalMacOsPermissions> 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<CoreNetworkPermissions> for AdditionalNetworkPermissions {
}
}
impl From<AdditionalNetworkPermissions> 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<CorePermissionProfile> for AdditionalPermissionProfile {
}
}
impl From<AdditionalPermissionProfile> 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<CoreMacOsPreferencesPermission>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub automations: Option<CoreMacOsAutomationPermission>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub accessibility: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub calendar: Option<bool>,
}
impl From<GrantedMacOsPermissions> 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<AdditionalNetworkPermissions>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub file_system: Option<AdditionalFileSystemPermissions>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub macos: Option<GrantedMacOsPermissions>,
}
impl From<GrantedPermissionProfile> 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<String>,
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::<PermissionsRequestApprovalResponse>(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::<CommandExecParams>(json!({
+48 -2
View File
@@ -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`.
@@ -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<ClientRequestResult>,
conversation: Arc<CodexThread>,
thread_state: Arc<Mutex<ThreadState>>,
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<ClientRequestResult, oneshot::error::RecvError>,
) -> Option<CoreRequestPermissionsResponse> {
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::<PermissionsRequestApprovalResponse>(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 {
+1
View File
@@ -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;
@@ -83,3 +83,23 @@ pub fn create_request_user_input_sse_response(call_id: &str) -> anyhow::Result<S
responses::ev_completed("resp-1"),
]))
}
pub fn create_request_permissions_sse_response(call_id: &str) -> anyhow::Result<String> {
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"),
]))
}
@@ -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;
@@ -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
"#
),
)
}
+6
View File
@@ -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"
},
+85
View File
@@ -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<RequestPermissionsResponse> {
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<PermissionProfile> {
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<Session>, config: Arc<Config>, 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<Session>,
id: String,
response: RequestPermissionsResponse,
) {
sess.notify_request_permissions_response(&id, response)
.await;
}
pub async fn dynamic_tool_response(
sess: &Arc<Session>,
id: String,
@@ -6217,6 +6301,7 @@ fn realtime_text_for_event(msg: &EventMsg) -> Option<String> {
| EventMsg::ImageGenerationBegin(_)
| EventMsg::ImageGenerationEnd(_)
| EventMsg::ExecApprovalRequest(_)
| EventMsg::RequestPermissions(_)
| EventMsg::RequestUserInput(_)
| EventMsg::DynamicToolCallRequest(_)
| EventMsg::DynamicToolCallResponse(_)
+157
View File
@@ -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<F>(
fut: F,
parent_session: &Session,
@@ -451,6 +491,32 @@ where
}
}
async fn await_request_permissions_with_cancel<F>(
fut: F,
parent_session: &Session,
call_id: &str,
cancel_token: &CancellationToken,
) -> RequestPermissionsResponse
where
F: core::future::Future<Output = Option<RequestPermissionsResponse>>,
{
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<F>(
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,
}
);
}
}
+22
View File
@@ -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;
+23
View File
@@ -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);
+1
View File
@@ -144,6 +144,7 @@ fn event_msg_persistence_mode(ev: &EventMsg) -> Option<EventPersistenceMode> {
| EventMsg::TerminalInteraction(_)
| EventMsg::ExecCommandOutputDelta(_)
| EventMsg::ExecApprovalRequest(_)
| EventMsg::RequestPermissions(_)
| EventMsg::RequestUserInput(_)
| EventMsg::ElicitationRequest(_)
| EventMsg::ApplyPatchApprovalRequest(_)
@@ -31,6 +31,28 @@ pub(crate) fn merge_macos_seatbelt_profile_extensions(
}
}
pub(crate) fn intersect_macos_seatbelt_profile_extensions(
requested: Option<MacOsSeatbeltProfileExtensions>,
granted: Option<MacOsSeatbeltProfileExtensions>,
) -> Option<MacOsSeatbeltProfileExtensions> {
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::<Vec<String>>();
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()));
}
}
+225 -12
View File
@@ -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<PermissionProfile, String> {
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<PermissionProfile> {
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<AbsolutePathBuf>,
_permission_kind: &str,
@@ -182,6 +299,29 @@ fn normalize_permission_paths(
out
}
fn merge_permission_paths(
base: Option<&Vec<AbsolutePathBuf>>,
permissions: Option<&Vec<AbsolutePathBuf>>,
) -> Option<Vec<AbsolutePathBuf>> {
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<AbsolutePathBuf>) -> Vec<AbsolutePathBuf> {
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() {
+30
View File
@@ -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<String, oneshot::Sender<ReviewDecision>>,
pending_request_permissions: HashMap<String, oneshot::Sender<RequestPermissionsResponse>>,
pending_user_input: HashMap<String, oneshot::Sender<RequestUserInputResponse>>,
pending_elicitations: HashMap<(String, RequestId), oneshot::Sender<ElicitationResponse>>,
pending_dynamic_tools: HashMap<String, oneshot::Sender<DynamicToolResponse>>,
pending_input: Vec<ResponseInputItem>,
granted_permissions: Option<PermissionProfile>,
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<RequestPermissionsResponse>,
) -> Option<oneshot::Sender<RequestPermissionsResponse>> {
self.pending_request_permissions.insert(key, tx)
}
pub(crate) fn remove_pending_request_permissions(
&mut self,
key: &str,
) -> Option<oneshot::Sender<RequestPermissionsResponse>> {
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<PermissionProfile> {
self.granted_permissions.clone()
}
}
impl ActiveTurn {
+55 -1
View File
@@ -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<PermissionProfile>,
permissions_preapproved: bool,
_cwd: &Path,
) -> Result<Option<PermissionProfile>, 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<PermissionProfile>,
pub permissions_preapproved: bool,
}
pub(super) async fn apply_granted_turn_permissions(
session: &Session,
sandbox_permissions: SandboxPermissions,
additional_permissions: Option<PermissionProfile>,
) -> 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,
}
}
@@ -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<ToolOutput, FunctionCallError> {
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),
})
}
}
+22 -5
View File
@@ -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,
@@ -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,
},
+239 -97
View File
@@ -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<String, AgentRoleConfig>,
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<JsonSchema> 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<String, JsonSchema> {
let mut properties = BTreeMap::from([
(
@@ -298,103 +390,7 @@ fn create_approval_parameters(request_permission_enabled: bool) -> BTreeMap<Stri
if request_permission_enabled {
properties.insert(
"additional_permissions".to_string(),
JsonSchema::Object {
properties: BTreeMap::from([
(
"network".to_string(),
JsonSchema::Object {
properties: BTreeMap::from([(
"enabled".to_string(),
JsonSchema::Boolean {
description: Some(
"Set to true to enable network access for this command."
.to_string(),
),
},
)]),
required: None,
additional_properties: Some(false.into()),
},
),
(
"file_system".to_string(),
JsonSchema::Object {
properties: BTreeMap::from([
(
"read".to_string(),
JsonSchema::Array {
items: Box::new(JsonSchema::String { description: None }),
description: Some(
"Additional filesystem paths to grant read access for this command."
.to_string(),
),
},
),
(
"write".to_string(),
JsonSchema::Array {
items: Box::new(JsonSchema::String { description: None }),
description: Some(
"Additional filesystem paths to grant write access for this command."
.to_string(),
),
},
),
]),
required: None,
additional_properties: Some(false.into()),
},
),
(
"macos".to_string(),
JsonSchema::Object {
properties: BTreeMap::from([
(
"preferences".to_string(),
JsonSchema::String {
description: Some(
"Additional macOS preferences access for this command. Supported values: \"readonly\" or \"readwrite\"."
.to_string(),
),
},
),
(
"automations".to_string(),
JsonSchema::Array {
items: Box::new(JsonSchema::String { description: None }),
description: Some(
"Additional macOS automation targets for this command as bundle IDs, or use true in clients that support boolean union payloads."
.to_string(),
),
},
),
(
"accessibility".to_string(),
JsonSchema::Boolean {
description: Some(
"Set to true to allow macOS accessibility APIs for this command."
.to_string(),
),
},
),
(
"calendar".to_string(),
JsonSchema::Boolean {
description: Some(
"Set to true to allow macOS Calendar access for this command."
.to_string(),
),
},
),
]),
required: None,
additional_properties: Some(false.into()),
},
),
]),
required: None,
additional_properties: Some(false.into()),
},
create_permissions_schema(),
);
}
@@ -1100,6 +1096,30 @@ fn create_request_user_input_tool(
})
}
fn create_request_permissions_tool() -> 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);
+2
View File
@@ -95,6 +95,7 @@ pub(crate) struct ExecCommandRequest {
pub tty: bool,
pub sandbox_permissions: SandboxPermissions,
pub additional_permissions: Option<PermissionProfile>,
pub additional_permissions_preapproved: bool,
pub justification: Option<String>,
pub prefix_rule: Option<Vec<String>>,
}
@@ -240,6 +241,7 @@ mod tests {
tty: true,
sandbox_permissions: SandboxPermissions::UseDefault,
additional_permissions: None,
additional_permissions_preapproved: false,
justification: None,
prefix_rule: None,
},
@@ -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;
@@ -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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<ExecApprovalRequestEvent> {
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<PermissionProfile> {
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(())
}
@@ -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(_)
)
+12
View File
@@ -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 {
@@ -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(_)
+1
View File
@@ -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;
+28
View File
@@ -170,9 +170,13 @@ impl TryFrom<MacOsAutomationPermissionDe> 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::<MacOsSeatbeltProfileExtensions>(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::<MacOsAutomationPermission>("\"all\"")
+13
View File
@@ -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),
@@ -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<String>,
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<String>,
pub permissions: PermissionProfile,
}
+1
View File
@@ -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),