diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 6c101b940d..d678696061 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1934,6 +1934,7 @@ dependencies = [ "codex-terminal-detection", "codex-tools", "codex-utils-absolute-path", + "codex-utils-approval-presets", "codex-utils-cache", "codex-utils-cargo-bin", "codex-utils-home-dir", @@ -2819,6 +2820,7 @@ dependencies = [ "codex-features", "codex-protocol", "codex-utils-absolute-path", + "codex-utils-approval-presets", "codex-utils-pty", "pretty_assertions", "rmcp", diff --git a/codex-rs/app-server-protocol/schema/json/PermissionPresetRequestApprovalParams.json b/codex-rs/app-server-protocol/schema/json/PermissionPresetRequestApprovalParams.json new file mode 100644 index 0000000000..8a840ec2e9 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/PermissionPresetRequestApprovalParams.json @@ -0,0 +1,297 @@ +{ + "$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" + }, + "ApprovalsReviewer": { + "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `guardian_subagent` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request.", + "enum": [ + "user", + "guardian_subagent" + ], + "type": "string" + }, + "AskForApproval": { + "oneOf": [ + { + "enum": [ + "untrusted", + "on-failure", + "on-request", + "never" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "granular": { + "properties": { + "mcp_elicitations": { + "type": "boolean" + }, + "request_permissions": { + "default": false, + "type": "boolean" + }, + "rules": { + "type": "boolean" + }, + "sandbox_approval": { + "type": "boolean" + }, + "skill_approval": { + "default": false, + "type": "boolean" + } + }, + "required": [ + "mcp_elicitations", + "rules", + "sandbox_approval" + ], + "type": "object" + } + }, + "required": [ + "granular" + ], + "title": "GranularAskForApproval", + "type": "object" + } + ] + }, + "NetworkAccess": { + "enum": [ + "restricted", + "enabled" + ], + "type": "string" + }, + "PermissionPresetId": { + "enum": [ + "auto", + "full-access", + "read-only", + "guardian-approvals" + ], + "type": "string" + }, + "ReadOnlyAccess": { + "oneOf": [ + { + "properties": { + "includePlatformDefaults": { + "default": true, + "type": "boolean" + }, + "readableRoots": { + "default": [], + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + }, + "type": { + "enum": [ + "restricted" + ], + "title": "RestrictedReadOnlyAccessType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "RestrictedReadOnlyAccess", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "fullAccess" + ], + "title": "FullAccessReadOnlyAccessType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "FullAccessReadOnlyAccess", + "type": "object" + } + ] + }, + "SandboxPolicy": { + "oneOf": [ + { + "properties": { + "type": { + "enum": [ + "dangerFullAccess" + ], + "title": "DangerFullAccessSandboxPolicyType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "DangerFullAccessSandboxPolicy", + "type": "object" + }, + { + "properties": { + "access": { + "allOf": [ + { + "$ref": "#/definitions/ReadOnlyAccess" + } + ], + "default": { + "type": "fullAccess" + } + }, + "networkAccess": { + "default": false, + "type": "boolean" + }, + "type": { + "enum": [ + "readOnly" + ], + "title": "ReadOnlySandboxPolicyType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "ReadOnlySandboxPolicy", + "type": "object" + }, + { + "properties": { + "networkAccess": { + "allOf": [ + { + "$ref": "#/definitions/NetworkAccess" + } + ], + "default": "restricted" + }, + "type": { + "enum": [ + "externalSandbox" + ], + "title": "ExternalSandboxSandboxPolicyType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "ExternalSandboxSandboxPolicy", + "type": "object" + }, + { + "properties": { + "excludeSlashTmp": { + "default": false, + "type": "boolean" + }, + "excludeTmpdirEnvVar": { + "default": false, + "type": "boolean" + }, + "networkAccess": { + "default": false, + "type": "boolean" + }, + "readOnlyAccess": { + "allOf": [ + { + "$ref": "#/definitions/ReadOnlyAccess" + } + ], + "default": { + "type": "fullAccess" + } + }, + "type": { + "enum": [ + "workspaceWrite" + ], + "title": "WorkspaceWriteSandboxPolicyType", + "type": "string" + }, + "writableRoots": { + "default": [], + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + } + }, + "required": [ + "type" + ], + "title": "WorkspaceWriteSandboxPolicy", + "type": "object" + } + ] + } + }, + "properties": { + "approvalPolicy": { + "$ref": "#/definitions/AskForApproval" + }, + "approvalsReviewer": { + "$ref": "#/definitions/ApprovalsReviewer" + }, + "description": { + "type": "string" + }, + "itemId": { + "type": "string" + }, + "label": { + "type": "string" + }, + "preset": { + "$ref": "#/definitions/PermissionPresetId" + }, + "reason": { + "type": [ + "string", + "null" + ] + }, + "sandboxPolicy": { + "$ref": "#/definitions/SandboxPolicy" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "approvalPolicy", + "approvalsReviewer", + "description", + "itemId", + "label", + "preset", + "sandboxPolicy", + "threadId", + "turnId" + ], + "title": "PermissionPresetRequestApprovalParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/PermissionPresetRequestApprovalResponse.json b/codex-rs/app-server-protocol/schema/json/PermissionPresetRequestApprovalResponse.json new file mode 100644 index 0000000000..26511dd39e --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/PermissionPresetRequestApprovalResponse.json @@ -0,0 +1,35 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "PermissionPresetApprovalDecision": { + "enum": [ + "accepted", + "declined" + ], + "type": "string" + }, + "PermissionPresetId": { + "enum": [ + "auto", + "full-access", + "read-only", + "guardian-approvals" + ], + "type": "string" + } + }, + "properties": { + "decision": { + "$ref": "#/definitions/PermissionPresetApprovalDecision" + }, + "preset": { + "$ref": "#/definitions/PermissionPresetId" + } + }, + "required": [ + "decision", + "preset" + ], + "title": "PermissionPresetRequestApprovalResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/ServerRequest.json b/codex-rs/app-server-protocol/schema/json/ServerRequest.json index 7c11a4c02b..337058cafd 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ServerRequest.json @@ -101,6 +101,64 @@ ], "type": "object" }, + "ApprovalsReviewer": { + "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `guardian_subagent` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request.", + "enum": [ + "user", + "guardian_subagent" + ], + "type": "string" + }, + "AskForApproval": { + "oneOf": [ + { + "enum": [ + "untrusted", + "on-failure", + "on-request", + "never" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "granular": { + "properties": { + "mcp_elicitations": { + "type": "boolean" + }, + "request_permissions": { + "default": false, + "type": "boolean" + }, + "rules": { + "type": "boolean" + }, + "sandbox_approval": { + "type": "boolean" + }, + "skill_approval": { + "default": false, + "type": "boolean" + } + }, + "required": [ + "mcp_elicitations", + "rules", + "sandbox_approval" + ], + "type": "object" + } + }, + "required": [ + "granular" + ], + "title": "GranularAskForApproval", + "type": "object" + } + ] + }, "ChatgptAuthTokensRefreshParams": { "properties": { "previousAccountId": { @@ -1187,6 +1245,13 @@ ], "type": "object" }, + "NetworkAccess": { + "enum": [ + "restricted", + "enabled" + ], + "type": "string" + }, "NetworkApprovalContext": { "properties": { "host": { @@ -1344,6 +1409,64 @@ } ] }, + "PermissionPresetId": { + "enum": [ + "auto", + "full-access", + "read-only", + "guardian-approvals" + ], + "type": "string" + }, + "PermissionPresetRequestApprovalParams": { + "properties": { + "approvalPolicy": { + "$ref": "#/definitions/AskForApproval" + }, + "approvalsReviewer": { + "$ref": "#/definitions/ApprovalsReviewer" + }, + "description": { + "type": "string" + }, + "itemId": { + "type": "string" + }, + "label": { + "type": "string" + }, + "preset": { + "$ref": "#/definitions/PermissionPresetId" + }, + "reason": { + "type": [ + "string", + "null" + ] + }, + "sandboxPolicy": { + "$ref": "#/definitions/SandboxPolicy" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "approvalPolicy", + "approvalsReviewer", + "description", + "itemId", + "label", + "preset", + "sandboxPolicy", + "threadId", + "turnId" + ], + "type": "object" + }, "PermissionsRequestApprovalParams": { "properties": { "itemId": { @@ -1373,6 +1496,53 @@ ], "type": "object" }, + "ReadOnlyAccess": { + "oneOf": [ + { + "properties": { + "includePlatformDefaults": { + "default": true, + "type": "boolean" + }, + "readableRoots": { + "default": [], + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + }, + "type": { + "enum": [ + "restricted" + ], + "title": "RestrictedReadOnlyAccessType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "RestrictedReadOnlyAccess", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "fullAccess" + ], + "title": "FullAccessReadOnlyAccessType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "FullAccessReadOnlyAccess", + "type": "object" + } + ] + }, "RequestId": { "anyOf": [ { @@ -1410,6 +1580,125 @@ }, "type": "object" }, + "SandboxPolicy": { + "oneOf": [ + { + "properties": { + "type": { + "enum": [ + "dangerFullAccess" + ], + "title": "DangerFullAccessSandboxPolicyType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "DangerFullAccessSandboxPolicy", + "type": "object" + }, + { + "properties": { + "access": { + "allOf": [ + { + "$ref": "#/definitions/ReadOnlyAccess" + } + ], + "default": { + "type": "fullAccess" + } + }, + "networkAccess": { + "default": false, + "type": "boolean" + }, + "type": { + "enum": [ + "readOnly" + ], + "title": "ReadOnlySandboxPolicyType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "ReadOnlySandboxPolicy", + "type": "object" + }, + { + "properties": { + "networkAccess": { + "allOf": [ + { + "$ref": "#/definitions/NetworkAccess" + } + ], + "default": "restricted" + }, + "type": { + "enum": [ + "externalSandbox" + ], + "title": "ExternalSandboxSandboxPolicyType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "ExternalSandboxSandboxPolicy", + "type": "object" + }, + { + "properties": { + "excludeSlashTmp": { + "default": false, + "type": "boolean" + }, + "excludeTmpdirEnvVar": { + "default": false, + "type": "boolean" + }, + "networkAccess": { + "default": false, + "type": "boolean" + }, + "readOnlyAccess": { + "allOf": [ + { + "$ref": "#/definitions/ReadOnlyAccess" + } + ], + "default": { + "type": "fullAccess" + } + }, + "type": { + "enum": [ + "workspaceWrite" + ], + "title": "WorkspaceWriteSandboxPolicyType", + "type": "string" + }, + "writableRoots": { + "default": [], + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + } + }, + "required": [ + "type" + ], + "title": "WorkspaceWriteSandboxPolicy", + "type": "object" + } + ] + }, "ThreadId": { "type": "string" }, @@ -1621,6 +1910,31 @@ "title": "Item/permissions/requestApprovalRequest", "type": "object" }, + { + "description": "Request the client to open its permission preset picker.", + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "item/permissionPreset/requestApproval" + ], + "title": "Item/permissionPreset/requestApprovalRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/PermissionPresetRequestApprovalParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Item/permissionPreset/requestApprovalRequest", + "type": "object" + }, { "description": "Execute a dynamic tool call on the client.", "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 15b31cc0ef..bdcac72960 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -3260,6 +3260,90 @@ ], "type": "string" }, + "PermissionPresetApprovalDecision": { + "enum": [ + "accepted", + "declined" + ], + "type": "string" + }, + "PermissionPresetId": { + "enum": [ + "auto", + "full-access", + "read-only", + "guardian-approvals" + ], + "type": "string" + }, + "PermissionPresetRequestApprovalParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "approvalPolicy": { + "$ref": "#/definitions/v2/AskForApproval" + }, + "approvalsReviewer": { + "$ref": "#/definitions/v2/ApprovalsReviewer" + }, + "description": { + "type": "string" + }, + "itemId": { + "type": "string" + }, + "label": { + "type": "string" + }, + "preset": { + "$ref": "#/definitions/PermissionPresetId" + }, + "reason": { + "type": [ + "string", + "null" + ] + }, + "sandboxPolicy": { + "$ref": "#/definitions/v2/SandboxPolicy" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "approvalPolicy", + "approvalsReviewer", + "description", + "itemId", + "label", + "preset", + "sandboxPolicy", + "threadId", + "turnId" + ], + "title": "PermissionPresetRequestApprovalParams", + "type": "object" + }, + "PermissionPresetRequestApprovalResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "decision": { + "$ref": "#/definitions/PermissionPresetApprovalDecision" + }, + "preset": { + "$ref": "#/definitions/PermissionPresetId" + } + }, + "required": [ + "decision", + "preset" + ], + "title": "PermissionPresetRequestApprovalResponse", + "type": "object" + }, "PermissionsRequestApprovalParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -4619,6 +4703,31 @@ "title": "Item/permissions/requestApprovalRequest", "type": "object" }, + { + "description": "Request the client to open its permission preset picker.", + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "item/permissionPreset/requestApproval" + ], + "title": "Item/permissionPreset/requestApprovalRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/PermissionPresetRequestApprovalParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Item/permissionPreset/requestApprovalRequest", + "type": "object" + }, { "description": "Execute a dynamic tool call on the client.", "properties": { diff --git a/codex-rs/app-server-protocol/schema/typescript/ServerRequest.ts b/codex-rs/app-server-protocol/schema/typescript/ServerRequest.ts index 13d04b0be7..d0f574817d 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ServerRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ServerRequest.ts @@ -9,10 +9,11 @@ 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 { PermissionPresetRequestApprovalParams } from "./v2/PermissionPresetRequestApprovalParams"; 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/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, }; +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/permissionPreset/requestApproval", id: RequestId, params: PermissionPresetRequestApprovalParams, } | { "method": "item/tool/call", id: RequestId, params: DynamicToolCallParams, } | { "method": "account/chatgptAuthTokens/refresh", id: RequestId, params: ChatgptAuthTokensRefreshParams, } | { "method": "applyPatchApproval", id: RequestId, params: ApplyPatchApprovalParams, } | { "method": "execCommandApproval", id: RequestId, params: ExecCommandApprovalParams, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionPresetApprovalDecision.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionPresetApprovalDecision.ts new file mode 100644 index 0000000000..c5a7fbe11f --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionPresetApprovalDecision.ts @@ -0,0 +1,5 @@ +// 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. + +export type PermissionPresetApprovalDecision = "accepted" | "declined"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionPresetId.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionPresetId.ts new file mode 100644 index 0000000000..478c2e0251 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionPresetId.ts @@ -0,0 +1,5 @@ +// 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. + +export type PermissionPresetId = "auto" | "full-access" | "read-only" | "guardian-approvals"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionPresetRequestApprovalParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionPresetRequestApprovalParams.ts new file mode 100644 index 0000000000..c1975da80f --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionPresetRequestApprovalParams.ts @@ -0,0 +1,9 @@ +// 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 { ApprovalsReviewer } from "./ApprovalsReviewer"; +import type { AskForApproval } from "./AskForApproval"; +import type { PermissionPresetId } from "./PermissionPresetId"; +import type { SandboxPolicy } from "./SandboxPolicy"; + +export type PermissionPresetRequestApprovalParams = { threadId: string, turnId: string, itemId: string, preset: PermissionPresetId, label: string, description: string, approvalPolicy: AskForApproval, approvalsReviewer: ApprovalsReviewer, sandboxPolicy: SandboxPolicy, reason: string | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionPresetRequestApprovalResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionPresetRequestApprovalResponse.ts new file mode 100644 index 0000000000..a9feba5f71 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionPresetRequestApprovalResponse.ts @@ -0,0 +1,7 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { PermissionPresetApprovalDecision } from "./PermissionPresetApprovalDecision"; +import type { PermissionPresetId } from "./PermissionPresetId"; + +export type PermissionPresetRequestApprovalResponse = { decision: PermissionPresetApprovalDecision, preset: PermissionPresetId, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index f815fee3e9..942da0829d 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -216,6 +216,10 @@ export type { OverriddenMetadata } from "./OverriddenMetadata"; export type { PatchApplyStatus } from "./PatchApplyStatus"; export type { PatchChangeKind } from "./PatchChangeKind"; export type { PermissionGrantScope } from "./PermissionGrantScope"; +export type { PermissionPresetApprovalDecision } from "./PermissionPresetApprovalDecision"; +export type { PermissionPresetId } from "./PermissionPresetId"; +export type { PermissionPresetRequestApprovalParams } from "./PermissionPresetRequestApprovalParams"; +export type { PermissionPresetRequestApprovalResponse } from "./PermissionPresetRequestApprovalResponse"; export type { PermissionsRequestApprovalParams } from "./PermissionsRequestApprovalParams"; export type { PermissionsRequestApprovalResponse } from "./PermissionsRequestApprovalResponse"; export type { PlanDeltaNotification } from "./PlanDeltaNotification"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 3450e41534..3c8857d7bb 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -851,6 +851,12 @@ server_request_definitions! { response: v2::PermissionsRequestApprovalResponse, }, + /// Request the client to open its permission preset picker. + PermissionPresetRequestApproval => "item/permissionPreset/requestApproval" { + params: v2::PermissionPresetRequestApprovalParams, + response: v2::PermissionPresetRequestApprovalResponse, + }, + /// Execute a dynamic tool call on the client. DynamicToolCall => "item/tool/call" { params: v2::DynamicToolCallParams, diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 30adc152ea..3d59e4677c 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -87,6 +87,8 @@ use codex_protocol::protocol::SkillToolDependency as CoreSkillToolDependency; use codex_protocol::protocol::SubAgentSource as CoreSubAgentSource; use codex_protocol::protocol::TokenUsage as CoreTokenUsage; use codex_protocol::protocol::TokenUsageInfo as CoreTokenUsageInfo; +use codex_protocol::request_permission_preset::PermissionPresetId as CorePermissionPresetId; +use codex_protocol::request_permission_preset::RequestPermissionPresetDecision as CoreRequestPermissionPresetDecision; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequestPermissionProfile; use codex_protocol::user_input::ByteRange as CoreByteRange; @@ -6239,6 +6241,69 @@ pub struct PermissionsRequestApprovalResponse { pub scope: PermissionGrantScope, } +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "kebab-case")] +#[ts(rename_all = "kebab-case", export_to = "v2/")] +pub enum PermissionPresetId { + Auto, + FullAccess, + ReadOnly, + GuardianApprovals, +} + +impl PermissionPresetId { + pub fn to_core(self) -> CorePermissionPresetId { + match self { + Self::Auto => CorePermissionPresetId::Auto, + Self::FullAccess => CorePermissionPresetId::FullAccess, + Self::ReadOnly => CorePermissionPresetId::ReadOnly, + Self::GuardianApprovals => CorePermissionPresetId::GuardianApprovals, + } + } +} + +impl From for PermissionPresetId { + fn from(value: CorePermissionPresetId) -> Self { + match value { + CorePermissionPresetId::Auto => Self::Auto, + CorePermissionPresetId::FullAccess => Self::FullAccess, + CorePermissionPresetId::ReadOnly => Self::ReadOnly, + CorePermissionPresetId::GuardianApprovals => Self::GuardianApprovals, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PermissionPresetRequestApprovalParams { + pub thread_id: String, + pub turn_id: String, + pub item_id: String, + pub preset: PermissionPresetId, + pub label: String, + pub description: String, + pub approval_policy: AskForApproval, + pub approvals_reviewer: ApprovalsReviewer, + pub sandbox_policy: SandboxPolicy, + pub reason: Option, +} + +v2_enum_from_core!( + pub enum PermissionPresetApprovalDecision from CoreRequestPermissionPresetDecision { + Accepted, + Declined + } +); + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PermissionPresetRequestApprovalResponse { + pub decision: PermissionPresetApprovalDecision, + pub preset: PermissionPresetId, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 08a51108ae..fd42b9641f 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -62,6 +62,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::PermissionPresetRequestApprovalParams; +use codex_app_server_protocol::PermissionPresetRequestApprovalResponse; use codex_app_server_protocol::PermissionsRequestApprovalParams; use codex_app_server_protocol::PermissionsRequestApprovalResponse; use codex_app_server_protocol::PlanDeltaNotification; @@ -131,6 +133,8 @@ use codex_protocol::protocol::TokenCountEvent; use codex_protocol::protocol::TurnAbortedEvent; use codex_protocol::protocol::TurnCompleteEvent; use codex_protocol::protocol::TurnDiffEvent; +use codex_protocol::request_permission_preset::RequestPermissionPresetDecision as CoreRequestPermissionPresetDecision; +use codex_protocol::request_permission_preset::RequestPermissionPresetResponse as CoreRequestPermissionPresetResponse; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequestPermissionProfile; use codex_protocol::request_permissions::RequestPermissionsResponse as CoreRequestPermissionsResponse; @@ -901,6 +905,57 @@ pub(crate) async fn apply_bespoke_event_handling( } } } + EventMsg::RequestPermissionPreset(request) => { + if matches!(api_version, ApiVersion::V2) { + let params = PermissionPresetRequestApprovalParams { + thread_id: conversation_id.to_string(), + turn_id: request.turn_id.clone(), + item_id: request.call_id.clone(), + preset: request.preset.into(), + label: request.label.clone(), + description: request.description, + approval_policy: request.approval_policy.into(), + approvals_reviewer: request.approvals_reviewer.into(), + sandbox_policy: request.sandbox_policy.into(), + reason: request.reason, + }; + let (pending_request_id, rx) = outgoing + .send_request(ServerRequestPayload::PermissionPresetRequestApproval( + params, + )) + .await; + tokio::spawn(async move { + on_request_permission_preset_response( + request.call_id, + request.preset, + pending_request_id, + rx, + conversation, + thread_state, + ) + .await; + }); + } else { + error!( + "request_permission_preset is only supported on api v2 (call_id: {})", + request.call_id + ); + let declined = CoreRequestPermissionPresetResponse { + decision: CoreRequestPermissionPresetDecision::Declined, + preset: request.preset, + message: "request_permission_preset is only supported on api v2".to_string(), + }; + if let Err(err) = conversation + .submit(Op::RequestPermissionPresetResponse { + id: request.call_id, + response: declined, + }) + .await + { + error!("failed to submit RequestPermissionPresetResponse: {err}"); + } + } + } EventMsg::DynamicToolCallRequest(request) => { if matches!(api_version, ApiVersion::V2) { let call_id = request.call_id; @@ -2513,6 +2568,85 @@ async fn on_request_permissions_response( } } +async fn on_request_permission_preset_response( + call_id: String, + preset: codex_protocol::request_permission_preset::PermissionPresetId, + pending_request_id: RequestId, + receiver: oneshot::Receiver, + conversation: Arc, + thread_state: Arc>, +) { + let response = receiver.await; + resolve_server_request_on_thread_listener(&thread_state, pending_request_id).await; + let response = request_permission_preset_response_from_client_result(preset, response); + + if let Err(err) = conversation + .submit(Op::RequestPermissionPresetResponse { + id: call_id, + response, + }) + .await + { + error!("failed to submit RequestPermissionPresetResponse: {err}"); + } +} + +fn request_permission_preset_response_from_client_result( + preset: codex_protocol::request_permission_preset::PermissionPresetId, + response: std::result::Result, +) -> CoreRequestPermissionPresetResponse { + let value = match response { + Ok(Ok(value)) => value, + Ok(Err(err)) if is_turn_transition_server_request_error(&err) => { + return CoreRequestPermissionPresetResponse { + decision: CoreRequestPermissionPresetDecision::Declined, + preset, + message: "turn transitioned before the permission preset request was resolved" + .to_string(), + }; + } + Ok(Err(err)) => { + error!("request failed with client error: {err:?}"); + return CoreRequestPermissionPresetResponse { + decision: CoreRequestPermissionPresetDecision::Declined, + preset, + message: "permission preset request failed".to_string(), + }; + } + Err(err) => { + error!("request failed: {err:?}"); + return CoreRequestPermissionPresetResponse { + decision: CoreRequestPermissionPresetDecision::Declined, + preset, + message: "permission preset request failed".to_string(), + }; + } + }; + + let response = serde_json::from_value::(value) + .unwrap_or_else(|err| { + error!("failed to deserialize PermissionPresetRequestApprovalResponse: {err}"); + PermissionPresetRequestApprovalResponse { + decision: codex_app_server_protocol::PermissionPresetApprovalDecision::Declined, + preset: preset.into(), + } + }); + let decision = response.decision.to_core(); + let selected_preset = response.preset.to_core(); + CoreRequestPermissionPresetResponse { + decision, + preset: selected_preset, + message: match decision { + CoreRequestPermissionPresetDecision::Accepted => { + "permission preset request accepted".to_string() + } + CoreRequestPermissionPresetDecision::Declined => { + "permission preset request declined".to_string() + } + }, + } +} + fn request_permissions_response_from_client_result( requested_permissions: CoreRequestPermissionProfile, response: std::result::Result, diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 55ce13afdc..5185d67165 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -58,6 +58,7 @@ codex-state = { workspace = true } codex-terminal-detection = { workspace = true } codex-tools = { workspace = true } codex-utils-absolute-path = { workspace = true } +codex-utils-approval-presets = { workspace = true } codex-utils-cache = { workspace = true } codex-utils-image = { workspace = true } codex-utils-home-dir = { workspace = true } diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index bd74c46595..cff4f60db0 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -443,6 +443,9 @@ "remote_models": { "type": "boolean" }, + "request_permission_preset_tool": { + "type": "boolean" + }, "request_permissions": { "type": "boolean" }, @@ -2292,6 +2295,9 @@ "remote_models": { "type": "boolean" }, + "request_permission_preset_tool": { + "type": "boolean" + }, "request_permissions": { "type": "boolean" }, diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index c19c4b48fd..3394f01ff8 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -125,6 +125,11 @@ use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::TurnContextNetworkItem; use codex_protocol::protocol::W3cTraceContext; +use codex_protocol::request_permission_preset::PermissionPresetId; +use codex_protocol::request_permission_preset::RequestPermissionPresetArgs; +use codex_protocol::request_permission_preset::RequestPermissionPresetDecision; +use codex_protocol::request_permission_preset::RequestPermissionPresetEvent; +use codex_protocol::request_permission_preset::RequestPermissionPresetResponse; use codex_protocol::request_permissions::PermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile; use codex_protocol::request_permissions::RequestPermissionsArgs; @@ -137,6 +142,8 @@ use codex_rollout::state_db; use codex_shell_command::parse_command::parse_command; use codex_terminal_detection::user_agent; use codex_tools::filter_tool_suggest_discoverable_tools_for_client; +use codex_utils_approval_presets::PermissionPreset; +use codex_utils_approval_presets::find_builtin_permission_preset; use codex_utils_output_truncation::TruncationPolicy; use codex_utils_stream_parser::AssistantTextChunk; use codex_utils_stream_parser::AssistantTextStreamParser; @@ -302,6 +309,7 @@ use crate::state::ActiveTurn; use crate::state::MailboxDeliveryPhase; use crate::state::SessionServices; use crate::state::SessionState; +use crate::state::TurnPermissionPresetOverride; use crate::tasks::GhostSnapshotTask; use crate::tasks::ReviewTask; use crate::tasks::SessionTask; @@ -862,7 +870,7 @@ impl TurnSkillsContext { } /// The context needed for a single turn of the thread. -#[derive(Debug)] +#[derive(Clone, Debug)] pub(crate) struct TurnContext { pub(crate) sub_id: String, pub(crate) trace_id: Option, @@ -910,6 +918,21 @@ pub(crate) struct TurnContext { pub(crate) turn_timing_state: Arc, } impl TurnContext { + pub(crate) fn with_effective_permission_settings( + &self, + settings: EffectivePermissionSettings, + ) -> ConstraintResult { + let mut next = self.clone(); + next.approval_policy.set(settings.approval_policy)?; + let mut config = (*next.config).clone(); + config.approvals_reviewer = settings.approvals_reviewer; + next.config = Arc::new(config); + next.sandbox_policy.set(settings.sandbox_policy)?; + next.file_system_sandbox_policy = settings.file_system_sandbox_policy; + next.network_sandbox_policy = settings.network_sandbox_policy; + Ok(next) + } + pub(crate) fn model_context_window(&self) -> Option { let effective_context_window_percent = self.model_info.effective_context_window_percent; self.model_info.context_window.map(|context_window| { @@ -1286,6 +1309,15 @@ pub(crate) struct SessionSettingsUpdate { pub(crate) app_server_client_version: Option, } +#[derive(Clone)] +pub(crate) struct EffectivePermissionSettings { + pub(crate) approval_policy: AskForApproval, + pub(crate) approvals_reviewer: ApprovalsReviewer, + pub(crate) sandbox_policy: SandboxPolicy, + pub(crate) file_system_sandbox_policy: FileSystemSandboxPolicy, + pub(crate) network_sandbox_policy: NetworkSandboxPolicy, +} + pub(crate) struct AppServerClientMetadata { pub(crate) client_name: Option, pub(crate) client_version: Option, @@ -3302,6 +3334,195 @@ impl Session { rx_response.await.ok() } + pub async fn request_permission_preset( + &self, + turn_context: &TurnContext, + call_id: String, + args: RequestPermissionPresetArgs, + ) -> RequestPermissionPresetResponse { + if let AskForApproval::Granular(granular_config) = turn_context.approval_policy.value() + && !granular_config.allows_request_permissions() + { + return RequestPermissionPresetResponse { + decision: RequestPermissionPresetDecision::Declined, + preset: args.preset, + message: "permission preset prompts are disabled by granular.request_permissions" + .to_string(), + }; + } + + let Some(preset) = resolve_permission_preset(turn_context, args.preset) else { + return RequestPermissionPresetResponse { + decision: RequestPermissionPresetDecision::Declined, + preset: args.preset, + message: "requested permission preset is not available in this session".to_string(), + }; + }; + + if let Err(err) = turn_context.approval_policy.can_set(&preset.approval) { + return RequestPermissionPresetResponse { + decision: RequestPermissionPresetDecision::Declined, + preset: args.preset, + message: err.to_string(), + }; + } + if let Err(err) = turn_context.sandbox_policy.can_set(&preset.sandbox) { + return RequestPermissionPresetResponse { + decision: RequestPermissionPresetDecision::Declined, + preset: args.preset, + message: err.to_string(), + }; + } + + let Some(preset_id) = PermissionPresetId::from_id(preset.id) else { + return RequestPermissionPresetResponse { + decision: RequestPermissionPresetDecision::Declined, + preset: args.preset, + message: "requested permission preset is not supported".to_string(), + }; + }; + + 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_permission_preset(call_id.clone(), tx_response) + } + None => None, + } + }; + if prev_entry.is_some() { + warn!("Overwriting existing pending request_permission_preset for call_id: {call_id}"); + } + + let event = EventMsg::RequestPermissionPreset(RequestPermissionPresetEvent { + call_id, + turn_id: turn_context.sub_id.clone(), + preset: preset_id, + label: preset.label.to_string(), + description: preset.description.to_string(), + approval_policy: preset.approval, + approvals_reviewer: preset.approvals_reviewer, + sandbox_policy: preset.sandbox, + reason: args.reason, + }); + self.send_event(turn_context, event).await; + rx_response + .await + .unwrap_or_else(|_| RequestPermissionPresetResponse { + decision: RequestPermissionPresetDecision::Declined, + preset: args.preset, + message: "request_permission_preset was cancelled before receiving a response" + .to_string(), + }) + } + + pub async fn notify_request_permission_preset_response( + &self, + call_id: &str, + response: RequestPermissionPresetResponse, + ) { + if matches!(response.decision, RequestPermissionPresetDecision::Accepted) + && let Some(preset) = self + .resolve_permission_preset_for_current_session(response.preset) + .await + { + let preset_override = TurnPermissionPresetOverride { + approval_policy: preset.approval, + approvals_reviewer: preset.approvals_reviewer, + sandbox_policy: preset.sandbox.clone(), + }; + { + let mut active = self.active_turn.lock().await; + if let Some(at) = active.as_mut() { + let mut ts = at.turn_state.lock().await; + ts.record_permission_preset_override(preset_override); + } + } + if let Err(err) = self + .update_settings(SessionSettingsUpdate { + approval_policy: Some(preset.approval), + approvals_reviewer: Some(preset.approvals_reviewer), + sandbox_policy: Some(preset.sandbox), + ..Default::default() + }) + .await + { + warn!("failed to apply approved permission preset: {err}"); + } + } + + let entry = { + let mut active = self.active_turn.lock().await; + match active.as_mut() { + Some(at) => { + let mut ts = at.turn_state.lock().await; + ts.remove_pending_request_permission_preset(call_id) + } + None => None, + } + }; + match entry { + Some(tx_response) => { + tx_response.send(response).ok(); + } + None => { + warn!("No pending request_permission_preset found for call_id: {call_id}"); + } + } + } + + async fn resolve_permission_preset_for_current_session( + &self, + preset_id: PermissionPresetId, + ) -> Option { + let features = self.features(); + find_builtin_permission_preset( + preset_id.as_str(), + cfg!(target_os = "windows"), + features.enabled(Feature::GuardianApproval), + ) + } + + pub(crate) async fn effective_permission_settings( + &self, + turn_context: &TurnContext, + ) -> EffectivePermissionSettings { + let preset_override = { + let active = self.active_turn.lock().await; + match active.as_ref() { + Some(at) => { + let ts = at.turn_state.lock().await; + ts.permission_preset_override() + } + None => None, + } + }; + + if let Some(preset_override) = preset_override { + return EffectivePermissionSettings { + approval_policy: preset_override.approval_policy, + approvals_reviewer: preset_override.approvals_reviewer, + file_system_sandbox_policy: FileSystemSandboxPolicy::from_legacy_sandbox_policy( + &preset_override.sandbox_policy, + &turn_context.cwd, + ), + network_sandbox_policy: NetworkSandboxPolicy::from(&preset_override.sandbox_policy), + sandbox_policy: preset_override.sandbox_policy, + }; + } + + EffectivePermissionSettings { + approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: turn_context.config.approvals_reviewer, + sandbox_policy: turn_context.sandbox_policy.get().clone(), + file_system_sandbox_policy: turn_context.file_system_sandbox_policy.clone(), + network_sandbox_policy: turn_context.network_sandbox_policy, + } + } + pub async fn request_user_input( &self, turn_context: &TurnContext, @@ -3748,6 +3969,9 @@ impl Session { turn_context .features .enabled(Feature::RequestPermissionsTool), + turn_context + .features + .enabled(Feature::RequestPermissionPresetTool), ) .into_text(), ); @@ -4730,6 +4954,10 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv handlers::request_permissions_response(&sess, id, response).await; false } + Op::RequestPermissionPresetResponse { id, response } => { + handlers::request_permission_preset_response(&sess, id, response).await; + false + } Op::DynamicToolResponse { id, response } => { handlers::dynamic_tool_response(&sess, id, response).await; false @@ -4861,6 +5089,17 @@ fn submission_dispatch_span(sub: &Submission) -> tracing::Span { dispatch_span } +fn resolve_permission_preset( + turn_context: &TurnContext, + preset_id: PermissionPresetId, +) -> Option { + find_builtin_permission_preset( + preset_id.as_str(), + cfg!(target_os = "windows"), + turn_context.features.enabled(Feature::GuardianApproval), + ) +} + /// Operation handlers mod handlers { use crate::codex::Session; @@ -4903,6 +5142,7 @@ mod handlers { use codex_protocol::protocol::ThreadRolledBackEvent; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::WarningEvent; + use codex_protocol::request_permission_preset::RequestPermissionPresetResponse; use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputResponse; @@ -5250,6 +5490,15 @@ mod handlers { .await; } + pub async fn request_permission_preset_response( + sess: &Arc, + id: String, + response: RequestPermissionPresetResponse, + ) { + sess.notify_request_permission_preset_response(&id, response) + .await; + } + pub async fn dynamic_tool_response( sess: &Arc, id: String, @@ -7259,6 +7508,7 @@ fn realtime_text_for_event(msg: &EventMsg) -> Option { | EventMsg::ImageGenerationEnd(_) | EventMsg::ExecApprovalRequest(_) | EventMsg::RequestPermissions(_) + | EventMsg::RequestPermissionPreset(_) | EventMsg::RequestUserInput(_) | EventMsg::DynamicToolCallRequest(_) | EventMsg::DynamicToolCallResponse(_) diff --git a/codex-rs/core/src/context_manager/updates.rs b/codex-rs/core/src/context_manager/updates.rs index e92aad3d77..ed0c3e8df3 100644 --- a/codex-rs/core/src/context_manager/updates.rs +++ b/codex-rs/core/src/context_manager/updates.rs @@ -56,6 +56,7 @@ fn build_permissions_update_item( &next.cwd, next.features.enabled(Feature::ExecPermissionApprovals), next.features.enabled(Feature::RequestPermissionsTool), + next.features.enabled(Feature::RequestPermissionPresetTool), )) } diff --git a/codex-rs/core/src/state/mod.rs b/codex-rs/core/src/state/mod.rs index f3ebc7225d..68bd609cc4 100644 --- a/codex-rs/core/src/state/mod.rs +++ b/codex-rs/core/src/state/mod.rs @@ -8,4 +8,5 @@ pub(crate) use turn::ActiveTurn; pub(crate) use turn::MailboxDeliveryPhase; pub(crate) use turn::RunningTask; pub(crate) use turn::TaskKind; +pub(crate) use turn::TurnPermissionPresetOverride; pub(crate) use turn::TurnState; diff --git a/codex-rs/core/src/state/turn.rs b/codex-rs/core/src/state/turn.rs index 214fd8be15..00b82ad3d6 100644 --- a/codex-rs/core/src/state/turn.rs +++ b/codex-rs/core/src/state/turn.rs @@ -9,8 +9,11 @@ use tokio::sync::Notify; use tokio_util::sync::CancellationToken; use tokio_util::task::AbortOnDropHandle; +use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::dynamic_tools::DynamicToolResponse; use codex_protocol::models::ResponseInputItem; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::request_permission_preset::RequestPermissionPresetResponse; use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_rmcp_client::ElicitationResponse; @@ -21,6 +24,7 @@ use crate::codex::TurnContext; use crate::tasks::AnySessionTask; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::ReviewDecision; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::TokenUsage; /// Metadata about the currently running turn. @@ -97,6 +101,8 @@ impl ActiveTurn { #[derive(Default)] pub(crate) struct TurnState { pending_approvals: HashMap>, + pending_request_permission_presets: + HashMap>, pending_request_permissions: HashMap>, pending_user_input: HashMap>, pending_elicitations: HashMap<(String, RequestId), oneshot::Sender>, @@ -104,10 +110,18 @@ pub(crate) struct TurnState { pending_input: Vec, mailbox_delivery_phase: MailboxDeliveryPhase, granted_permissions: Option, + permission_preset_override: Option, pub(crate) tool_calls: u64, pub(crate) token_usage_at_turn_start: TokenUsage, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct TurnPermissionPresetOverride { + pub(crate) approval_policy: AskForApproval, + pub(crate) approvals_reviewer: ApprovalsReviewer, + pub(crate) sandbox_policy: SandboxPolicy, +} + impl TurnState { pub(crate) fn insert_pending_approval( &mut self, @@ -126,6 +140,7 @@ impl TurnState { pub(crate) fn clear_pending(&mut self) { self.pending_approvals.clear(); + self.pending_request_permission_presets.clear(); self.pending_request_permissions.clear(); self.pending_user_input.clear(); self.pending_elicitations.clear(); @@ -133,6 +148,21 @@ impl TurnState { self.pending_input.clear(); } + pub(crate) fn insert_pending_request_permission_preset( + &mut self, + key: String, + tx: oneshot::Sender, + ) -> Option> { + self.pending_request_permission_presets.insert(key, tx) + } + + pub(crate) fn remove_pending_request_permission_preset( + &mut self, + key: &str, + ) -> Option> { + self.pending_request_permission_presets.remove(key) + } + pub(crate) fn insert_pending_request_permissions( &mut self, key: String, @@ -244,6 +274,17 @@ impl TurnState { pub(crate) fn granted_permissions(&self) -> Option { self.granted_permissions.clone() } + + pub(crate) fn record_permission_preset_override( + &mut self, + preset_override: TurnPermissionPresetOverride, + ) { + self.permission_preset_override = Some(preset_override); + } + + pub(crate) fn permission_preset_override(&self) -> Option { + self.permission_preset_override.clone() + } } impl ActiveTurn { diff --git a/codex-rs/core/src/tools/handlers/apply_patch.rs b/codex-rs/core/src/tools/handlers/apply_patch.rs index 281c3aa6ec..df4e6c5fc8 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch.rs @@ -166,6 +166,12 @@ impl ToolHandler for ApplyPatchHandler { } }; + let effective_permissions = session.effective_permission_settings(turn.as_ref()).await; + let turn = Arc::new( + turn.with_effective_permission_settings(effective_permissions) + .map_err(|err| FunctionCallError::RespondToModel(err.to_string()))?, + ); + // Re-parse and verify the patch so we can compute changes and approval. // Avoid building temporary ExecParams/command vectors; derive directly from inputs. let cwd = turn.cwd.clone(); diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index 022e02c39e..ee5bdd8f5e 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -9,6 +9,7 @@ pub(crate) mod multi_agents; pub(crate) mod multi_agents_common; pub(crate) mod multi_agents_v2; mod plan; +mod request_permission_preset; mod request_permissions; mod request_user_input; mod shell; @@ -42,6 +43,7 @@ pub use list_dir::ListDirHandler; pub use mcp::McpHandler; pub use mcp_resource::McpResourceHandler; pub use plan::PlanHandler; +pub use request_permission_preset::RequestPermissionPresetHandler; pub use request_permissions::RequestPermissionsHandler; pub use request_user_input::RequestUserInputHandler; pub use shell::ShellCommandHandler; diff --git a/codex-rs/core/src/tools/handlers/request_permission_preset.rs b/codex-rs/core/src/tools/handlers/request_permission_preset.rs new file mode 100644 index 0000000000..2778bb5274 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/request_permission_preset.rs @@ -0,0 +1,50 @@ +use codex_protocol::request_permission_preset::RequestPermissionPresetArgs; + +use crate::function_tool::FunctionCallError; +use crate::tools::context::FunctionToolOutput; +use crate::tools::context::ToolInvocation; +use crate::tools::context::ToolPayload; +use crate::tools::handlers::parse_arguments; +use crate::tools::registry::ToolHandler; +use crate::tools::registry::ToolKind; + +pub struct RequestPermissionPresetHandler; + +impl ToolHandler for RequestPermissionPresetHandler { + type Output = FunctionToolOutput; + + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + async fn handle(&self, invocation: ToolInvocation) -> Result { + let ToolInvocation { + session, + turn, + call_id, + payload, + .. + } = invocation; + + let arguments = match payload { + ToolPayload::Function { arguments } => arguments, + _ => { + return Err(FunctionCallError::RespondToModel( + "request_permission_preset handler received unsupported payload".to_string(), + )); + } + }; + + let args: RequestPermissionPresetArgs = parse_arguments(&arguments)?; + let response = session + .request_permission_preset(turn.as_ref(), call_id, args) + .await; + let content = serde_json::to_string(&response).map_err(|err| { + FunctionCallError::Fatal(format!( + "failed to serialize request_permission_preset response: {err}" + )) + })?; + + Ok(FunctionToolOutput::from_text(content, Some(true))) + } +} diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index 3ed21bd2ed..5bd20a4227 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -395,6 +395,11 @@ impl ShellHandler { } = args; let mut exec_params = exec_params; + let effective_permissions = session.effective_permission_settings(turn.as_ref()).await; + let turn = Arc::new( + turn.with_effective_permission_settings(effective_permissions) + .map_err(|err| FunctionCallError::RespondToModel(err.to_string()))?, + ); let Some(environment) = turn.environment.as_ref() else { return Err(FunctionCallError::RespondToModel( "shell is unavailable in this session".to_string(), diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index a604e9762c..c40f562d95 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -178,6 +178,12 @@ impl ToolHandler for UnifiedExecHandler { } }; + let effective_permissions = session.effective_permission_settings(turn.as_ref()).await; + let turn = Arc::new( + turn.with_effective_permission_settings(effective_permissions) + .map_err(|err| FunctionCallError::RespondToModel(err.to_string()))?, + ); + let Some(environment) = turn.environment.as_ref() else { return Err(FunctionCallError::RespondToModel( "unified exec is unavailable in this session".to_string(), diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 188be42ba1..aa04212386 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -72,6 +72,7 @@ pub(crate) fn build_specs_with_discoverable_tools( use crate::tools::handlers::McpHandler; use crate::tools::handlers::McpResourceHandler; use crate::tools::handlers::PlanHandler; + use crate::tools::handlers::RequestPermissionPresetHandler; use crate::tools::handlers::RequestPermissionsHandler; use crate::tools::handlers::RequestUserInputHandler; use crate::tools::handlers::ShellCommandHandler; @@ -139,6 +140,7 @@ pub(crate) fn build_specs_with_discoverable_tools( 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_permission_preset_handler = Arc::new(RequestPermissionPresetHandler); let request_user_input_handler = Arc::new(RequestUserInputHandler { default_mode_request_user_input: config.default_mode_request_user_input, }); @@ -209,6 +211,9 @@ pub(crate) fn build_specs_with_discoverable_tools( ToolHandlerKind::RequestPermissions => { builder.register_handler(handler.name, request_permissions_handler.clone()); } + ToolHandlerKind::RequestPermissionPreset => { + builder.register_handler(handler.name, request_permission_preset_handler.clone()); + } ToolHandlerKind::RequestUserInput => { builder.register_handler(handler.name, request_user_input_handler.clone()); } diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index a553cbced5..8ec6c627cb 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -119,6 +119,7 @@ mod realtime_conversation; mod remote_env; mod remote_models; mod request_compression; +mod request_permission_preset_tool; #[cfg(not(target_os = "windows"))] mod request_permissions; #[cfg(not(target_os = "windows"))] diff --git a/codex-rs/core/tests/suite/permissions_messages.rs b/codex-rs/core/tests/suite/permissions_messages.rs index ec6b7d67d2..81b273ae67 100644 --- a/codex-rs/core/tests/suite/permissions_messages.rs +++ b/codex-rs/core/tests/suite/permissions_messages.rs @@ -556,6 +556,7 @@ async fn permissions_message_includes_writable_roots() -> Result<()> { test.config.cwd.as_path(), /*exec_permission_approvals_enabled*/ false, /*request_permissions_tool_enabled*/ false, + /*request_permission_preset_tool_enabled*/ false, ) .into_text(); // Normalize line endings to handle Windows vs Unix differences diff --git a/codex-rs/core/tests/suite/request_permission_preset_tool.rs b/codex-rs/core/tests/suite/request_permission_preset_tool.rs new file mode 100644 index 0000000000..8a8dcae19a --- /dev/null +++ b/codex-rs/core/tests/suite/request_permission_preset_tool.rs @@ -0,0 +1,152 @@ +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use anyhow::Result; +use codex_core::config::Constrained; +use codex_features::Feature; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::request_permission_preset::PermissionPresetId; +use codex_protocol::request_permission_preset::RequestPermissionPresetDecision; +use codex_protocol::request_permission_preset::RequestPermissionPresetResponse; +use codex_protocol::user_input::UserInput; +use core_test_support::responses::ev_assistant_message; +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_sequence; +use core_test_support::responses::sse; +use core_test_support::responses::start_mock_server; +use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event; +use pretty_assertions::assert_eq; +use serde_json::Value; +use serde_json::json; + +fn request_permission_preset_tool_event( + call_id: &str, + preset: PermissionPresetId, + reason: &str, +) -> Result { + let args = json!({ + "preset": preset, + "reason": reason, + }); + let args_str = serde_json::to_string(&args)?; + Ok(ev_function_call( + call_id, + "request_permission_preset", + &args_str, + )) +} + +#[tokio::test(flavor = "current_thread")] +async fn accepted_permission_preset_request_returns_model_output() -> Result<()> { + let server = start_mock_server().await; + let mut builder = test_codex().with_config(|config| { + config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest); + config.permissions.sandbox_policy = Constrained::allow_any(SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + read_only_access: Default::default(), + network_access: false, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }); + config + .features + .enable(Feature::RequestPermissionPresetTool) + .expect("test config should allow feature update"); + }); + let test = builder.build(&server).await?; + + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-request-preset-1"), + request_permission_preset_tool_event( + "preset-call", + PermissionPresetId::FullAccess, + "User asked for full access", + )?, + ev_completed("resp-request-preset-1"), + ]), + sse(vec![ + ev_response_created("resp-request-preset-2"), + ev_assistant_message("msg-request-preset-1", "done"), + ev_completed("resp-request-preset-2"), + ]), + ], + ) + .await; + + test.codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "switch to full access".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd: test.cwd.path().to_path_buf(), + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: None, + sandbox_policy: SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + read_only_access: Default::default(), + network_access: false, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }, + model: test.session_configured.model.clone(), + effort: None, + summary: None, + service_tier: None, + collaboration_mode: None, + personality: None, + }) + .await?; + + let event = wait_for_event(&test.codex, |event| { + matches!( + event, + EventMsg::RequestPermissionPreset(_) | EventMsg::TurnComplete(_) + ) + }) + .await; + let EventMsg::RequestPermissionPreset(request) = event else { + panic!("expected request_permission_preset before completion"); + }; + assert_eq!(request.call_id, "preset-call"); + assert_eq!(request.preset, PermissionPresetId::FullAccess); + + test.codex + .submit(Op::RequestPermissionPresetResponse { + id: "preset-call".to_string(), + response: RequestPermissionPresetResponse { + decision: RequestPermissionPresetDecision::Accepted, + preset: PermissionPresetId::FullAccess, + message: "Permissions updated to Full access.".to_string(), + }, + }) + .await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + let output_text = responses + .function_call_output_text("preset-call") + .expect("expected request_permission_preset output"); + let output: RequestPermissionPresetResponse = serde_json::from_str(&output_text)?; + assert_eq!( + output, + RequestPermissionPresetResponse { + decision: RequestPermissionPresetDecision::Accepted, + preset: PermissionPresetId::FullAccess, + message: "Permissions updated to Full access.".to_string(), + } + ); + + Ok(()) +} diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 31099e8d65..f7b8b936ef 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -1509,6 +1509,18 @@ async fn handle_server_request( ) .await } + ServerRequest::PermissionPresetRequestApproval { request_id, params } => { + reject_server_request( + client, + request_id, + &method, + format!( + "permission preset approval is not supported in exec mode for thread `{}`", + params.thread_id + ), + ) + .await + } }; if let Err(err) = handle_result { diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 10d5fdf884..4cad3847a0 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -97,6 +97,8 @@ pub enum Feature { CodexHooks, /// Expose the built-in request_permissions tool. RequestPermissionsTool, + /// Expose the built-in request_permission_preset tool. + RequestPermissionPresetTool, /// Allow the model to request web searches that fetch live content. WebSearchRequest, /// Allow the model to request web searches that fetch cached content. @@ -709,6 +711,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::UnderDevelopment, default_enabled: false, }, + FeatureSpec { + id: Feature::RequestPermissionPresetTool, + key: "request_permission_preset_tool", + stage: Stage::Stable, + default_enabled: true, + }, FeatureSpec { id: Feature::UseLinuxSandboxBwrap, key: "use_linux_sandbox_bwrap", diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index bc357a7fb4..7a2ade226d 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -115,6 +115,12 @@ fn request_permissions_tool_is_under_development() { assert_eq!(Feature::RequestPermissionsTool.default_enabled(), false); } +#[test] +fn request_permission_preset_tool_is_stable_and_enabled_by_default() { + assert_eq!(Feature::RequestPermissionPresetTool.stage(), Stage::Stable); + assert_eq!(Feature::RequestPermissionPresetTool.default_enabled(), true); +} + #[test] fn tool_suggest_is_stable_and_enabled_by_default() { assert_eq!(Feature::ToolSuggest.stage(), Stage::Stable); diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 91fcc8a278..a34ab033ff 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -374,6 +374,7 @@ async fn run_codex_tool_session_inner( | EventMsg::ExitedReviewMode(_) | EventMsg::RequestUserInput(_) | EventMsg::RequestPermissions(_) + | EventMsg::RequestPermissionPreset(_) | EventMsg::DynamicToolCallRequest(_) | EventMsg::DynamicToolCallResponse(_) | EventMsg::ContextCompacted(_) diff --git a/codex-rs/protocol/src/lib.rs b/codex-rs/protocol/src/lib.rs index de580de6f3..4b41bd6db7 100644 --- a/codex-rs/protocol/src/lib.rs +++ b/codex-rs/protocol/src/lib.rs @@ -21,6 +21,7 @@ pub mod parse_command; pub mod permissions; pub mod plan_tool; pub mod protocol; +pub mod request_permission_preset; pub mod request_permissions; pub mod request_user_input; pub mod user_input; diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 0f18b68393..850b8b4b52 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -391,6 +391,7 @@ struct PermissionsPromptConfig<'a> { exec_policy: &'a Policy, exec_permission_approvals_enabled: bool, request_permissions_tool_enabled: bool, + request_permission_preset_tool_enabled: bool, } impl DeveloperInstructions { @@ -404,13 +405,17 @@ impl DeveloperInstructions { exec_policy: &Policy, exec_permission_approvals_enabled: bool, request_permissions_tool_enabled: bool, + request_permission_preset_tool_enabled: bool, ) -> DeveloperInstructions { - let with_request_permissions_tool = |text: &str| { + let append_permission_tool_sections = |text: &str| { + let mut sections = vec![text.to_string()]; if request_permissions_tool_enabled { - format!("{text}\n\n{}", request_permissions_tool_prompt_section()) - } else { - text.to_string() + sections.push(request_permissions_tool_prompt_section().to_string()); } + if request_permission_preset_tool_enabled { + sections.push(request_permission_preset_tool_prompt_section().to_string()); + } + sections.join("\n\n") }; let on_request_instructions = || { let on_request_rule = if exec_permission_approvals_enabled { @@ -422,6 +427,9 @@ impl DeveloperInstructions { if request_permissions_tool_enabled { sections.push(request_permissions_tool_prompt_section().to_string()); } + if request_permission_preset_tool_enabled { + sections.push(request_permission_preset_tool_prompt_section().to_string()); + } if let Some(prefixes) = approved_command_prefixes_text(exec_policy) { sections.push(format!( "## Approved command prefixes\nThe following prefix rules have already been approved: {prefixes}" @@ -432,15 +440,18 @@ impl DeveloperInstructions { let text = match approval_policy { AskForApproval::Never => APPROVAL_POLICY_NEVER.to_string(), AskForApproval::UnlessTrusted => { - with_request_permissions_tool(APPROVAL_POLICY_UNLESS_TRUSTED) + append_permission_tool_sections(APPROVAL_POLICY_UNLESS_TRUSTED) + } + AskForApproval::OnFailure => { + append_permission_tool_sections(APPROVAL_POLICY_ON_FAILURE) } - AskForApproval::OnFailure => with_request_permissions_tool(APPROVAL_POLICY_ON_FAILURE), AskForApproval::OnRequest => on_request_instructions(), AskForApproval::Granular(granular_config) => granular_instructions( granular_config, exec_policy, exec_permission_approvals_enabled, request_permissions_tool_enabled, + request_permission_preset_tool_enabled, ), }; @@ -506,6 +517,7 @@ impl DeveloperInstructions { cwd: &Path, exec_permission_approvals_enabled: bool, request_permissions_tool_enabled: bool, + request_permission_preset_tool_enabled: bool, ) -> Self { let network_access = if sandbox_policy.has_full_network_access() { NetworkAccess::Enabled @@ -532,6 +544,7 @@ impl DeveloperInstructions { exec_policy, exec_permission_approvals_enabled, request_permissions_tool_enabled, + request_permission_preset_tool_enabled, }, writable_roots, ) @@ -570,6 +583,7 @@ impl DeveloperInstructions { config.exec_policy, config.exec_permission_approvals_enabled, config.request_permissions_tool_enabled, + config.request_permission_preset_tool_enabled, )) .concat(DeveloperInstructions::from_writable_roots(writable_roots)) .concat(end_tag) @@ -624,17 +638,24 @@ fn request_permissions_tool_prompt_section() -> &'static str { "# request_permissions Tool\n\nThe built-in `request_permissions` tool is available in this session. Invoke it when you need to request additional `network` or `file_system` permissions before later shell-like commands need them. Request only the specific permissions required for the task." } +fn request_permission_preset_tool_prompt_section() -> &'static str { + "# request_permission_preset Tool\n\nThe built-in `request_permission_preset` tool is available in this session. Invoke it immediately when the user asks conversationally to change sandboxing, approval, or permission mode, for example \"make this session full access\", \"switch to full access\", \"make the session read-only\", or \"use guardian approvals\". The tool opens the permission-mode picker with the requested preset pre-selected; the session only changes if the user selects a mode there. Do not claim the mode changed until the tool returns an accepted decision. Use `request_permissions` instead for narrow filesystem or network grants required by later shell-like commands." +} + fn granular_instructions( granular_config: GranularApprovalConfig, exec_policy: &Policy, exec_permission_approvals_enabled: bool, request_permissions_tool_enabled: bool, + request_permission_preset_tool_enabled: bool, ) -> String { let sandbox_approval_prompts_allowed = granular_config.allows_sandbox_approval(); let shell_permission_requests_available = exec_permission_approvals_enabled && sandbox_approval_prompts_allowed; let request_permissions_tool_prompts_allowed = request_permissions_tool_enabled && granular_config.allows_request_permissions(); + let request_permission_preset_tool_prompts_allowed = + request_permission_preset_tool_enabled && granular_config.allows_request_permissions(); let categories = [ Some(( granular_config.allows_sandbox_approval(), @@ -646,6 +667,10 @@ fn granular_instructions( granular_config.allows_request_permissions(), "`request_permissions`", )), + request_permission_preset_tool_enabled.then_some(( + granular_config.allows_request_permissions(), + "`request_permission_preset`", + )), Some(( granular_config.allows_mcp_elicitations(), "`mcp_elicitations`", @@ -686,6 +711,9 @@ fn granular_instructions( if request_permissions_tool_prompts_allowed { sections.push(request_permissions_tool_prompt_section().to_string()); } + if request_permission_preset_tool_prompts_allowed { + sections.push(request_permission_preset_tool_prompt_section().to_string()); + } if let Some(prefixes) = approved_command_prefixes_text(exec_policy) { sections.push(format!( @@ -1693,6 +1721,7 @@ mod tests { exec_policy: &Policy::empty(), exec_permission_approvals_enabled: false, request_permissions_tool_enabled: false, + request_permission_preset_tool_enabled: false, }, /*writable_roots*/ None, ); @@ -1726,6 +1755,7 @@ mod tests { &PathBuf::from("/tmp"), /*exec_permission_approvals_enabled*/ false, /*request_permissions_tool_enabled*/ false, + /*request_permission_preset_tool_enabled*/ false, ); let text = instructions.into_text(); assert!(text.contains("Network access is enabled.")); @@ -1750,6 +1780,7 @@ mod tests { exec_policy: &exec_policy, exec_permission_approvals_enabled: false, request_permissions_tool_enabled: false, + request_permission_preset_tool_enabled: false, }, /*writable_roots*/ None, ); @@ -1771,6 +1802,7 @@ mod tests { exec_policy: &Policy::empty(), exec_permission_approvals_enabled: false, request_permissions_tool_enabled: true, + request_permission_preset_tool_enabled: false, }, /*writable_roots*/ None, ); @@ -1791,6 +1823,7 @@ mod tests { exec_policy: &Policy::empty(), exec_permission_approvals_enabled: false, request_permissions_tool_enabled: true, + request_permission_preset_tool_enabled: false, }, /*writable_roots*/ None, ); @@ -1811,6 +1844,7 @@ mod tests { exec_policy: &Policy::empty(), exec_permission_approvals_enabled: true, request_permissions_tool_enabled: false, + request_permission_preset_tool_enabled: false, }, /*writable_roots*/ None, ); @@ -1831,6 +1865,7 @@ mod tests { exec_policy: &Policy::empty(), exec_permission_approvals_enabled: false, request_permissions_tool_enabled: true, + request_permission_preset_tool_enabled: false, }, /*writable_roots*/ None, ); @@ -1842,6 +1877,29 @@ mod tests { ); } + #[test] + fn includes_request_permission_preset_tool_instructions_when_enabled() { + let instructions = DeveloperInstructions::from_permissions_with_network( + SandboxMode::WorkspaceWrite, + NetworkAccess::Enabled, + PermissionsPromptConfig { + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::User, + exec_policy: &Policy::empty(), + exec_permission_approvals_enabled: false, + request_permissions_tool_enabled: false, + request_permission_preset_tool_enabled: true, + }, + /*writable_roots*/ None, + ); + + let text = instructions.into_text(); + assert!(text.contains("# request_permission_preset Tool")); + assert!(text.contains( + "Invoke it immediately when the user asks conversationally to change sandboxing" + )); + } + #[test] fn on_request_includes_tool_guidance_alongside_inline_permission_guidance_when_both_exist() { let instructions = DeveloperInstructions::from_permissions_with_network( @@ -1853,6 +1911,7 @@ mod tests { exec_policy: &Policy::empty(), exec_permission_approvals_enabled: true, request_permissions_tool_enabled: true, + request_permission_preset_tool_enabled: false, }, /*writable_roots*/ None, ); @@ -1870,6 +1929,7 @@ mod tests { &Policy::empty(), /*exec_permission_approvals_enabled*/ false, /*request_permissions_tool_enabled*/ false, + /*request_permission_preset_tool_enabled*/ false, ) .into_text(); @@ -1885,6 +1945,7 @@ mod tests { &Policy::empty(), /*exec_permission_approvals_enabled*/ false, /*request_permissions_tool_enabled*/ false, + /*request_permission_preset_tool_enabled*/ false, ) .into_text(); @@ -1900,6 +1961,7 @@ mod tests { rejected_categories: &[&str], include_shell_permission_request_instructions: bool, include_request_permissions_tool_section: bool, + include_request_permission_preset_tool_section: bool, ) -> String { let mut sections = vec![granular_prompt_intro_text().to_string()]; if !prompted_categories.is_empty() { @@ -1920,6 +1982,9 @@ mod tests { if include_request_permissions_tool_section { sections.push(request_permissions_tool_prompt_section().to_string()); } + if include_request_permission_preset_tool_section { + sections.push(request_permission_preset_tool_prompt_section().to_string()); + } sections.join("\n\n") } @@ -1937,6 +2002,7 @@ mod tests { &Policy::empty(), /*exec_permission_approvals_enabled*/ true, /*request_permissions_tool_enabled*/ false, + /*request_permission_preset_tool_enabled*/ false, ) .into_text(); @@ -1971,6 +2037,7 @@ mod tests { &Policy::empty(), /*exec_permission_approvals_enabled*/ true, /*request_permissions_tool_enabled*/ false, + /*request_permission_preset_tool_enabled*/ false, ) .into_text(); @@ -1986,6 +2053,7 @@ mod tests { &[], /*include_shell_permission_request_instructions*/ true, /*include_request_permissions_tool_section*/ false, + /*include_request_permission_preset_tool_section*/ false, ) ); } @@ -2004,6 +2072,7 @@ mod tests { &Policy::empty(), /*exec_permission_approvals_enabled*/ false, /*request_permissions_tool_enabled*/ false, + /*request_permission_preset_tool_enabled*/ false, ) .into_text(); @@ -2019,6 +2088,7 @@ mod tests { &[], /*include_shell_permission_request_instructions*/ false, /*include_request_permissions_tool_section*/ false, + /*include_request_permission_preset_tool_section*/ false, ) ); } @@ -2037,6 +2107,7 @@ mod tests { &Policy::empty(), /*exec_permission_approvals_enabled*/ true, /*request_permissions_tool_enabled*/ true, + /*request_permission_preset_tool_enabled*/ false, ) .into_text(); assert!(allowed.contains("# request_permissions Tool")); @@ -2053,11 +2124,51 @@ mod tests { &Policy::empty(), /*exec_permission_approvals_enabled*/ true, /*request_permissions_tool_enabled*/ true, + /*request_permission_preset_tool_enabled*/ false, ) .into_text(); assert!(!rejected.contains("# request_permissions Tool")); } + #[test] + fn granular_policy_includes_request_permission_preset_tool_only_when_that_prompt_can_still_fire() + { + let allowed = DeveloperInstructions::from( + AskForApproval::Granular(GranularApprovalConfig { + sandbox_approval: true, + rules: true, + skill_approval: true, + request_permissions: true, + mcp_elicitations: true, + }), + ApprovalsReviewer::User, + &Policy::empty(), + /*exec_permission_approvals_enabled*/ true, + /*request_permissions_tool_enabled*/ false, + /*request_permission_preset_tool_enabled*/ true, + ) + .into_text(); + assert!(allowed.contains("# request_permission_preset Tool")); + + let rejected = DeveloperInstructions::from( + AskForApproval::Granular(GranularApprovalConfig { + sandbox_approval: true, + rules: true, + skill_approval: true, + request_permissions: false, + mcp_elicitations: true, + }), + ApprovalsReviewer::User, + &Policy::empty(), + /*exec_permission_approvals_enabled*/ true, + /*request_permissions_tool_enabled*/ false, + /*request_permission_preset_tool_enabled*/ true, + ) + .into_text(); + assert!(!rejected.contains("# request_permission_preset Tool")); + assert!(rejected.contains("- `request_permission_preset`")); + } + #[test] fn granular_policy_lists_request_permissions_category_without_tool_section_when_tool_is_unavailable() { @@ -2073,6 +2184,7 @@ mod tests { &Policy::empty(), /*exec_permission_approvals_enabled*/ true, /*request_permissions_tool_enabled*/ false, + /*request_permission_preset_tool_enabled*/ false, ) .into_text(); diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 65d6dab04c..d662d63078 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -45,6 +45,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_permission_preset::RequestPermissionPresetEvent; +use crate::request_permission_preset::RequestPermissionPresetResponse; use crate::request_permissions::RequestPermissionsEvent; use crate::request_permissions::RequestPermissionsResponse; use crate::request_user_input::RequestUserInputResponse; @@ -574,6 +576,14 @@ pub enum Op { response: RequestPermissionsResponse, }, + /// Resolve a request_permission_preset tool call. + RequestPermissionPresetResponse { + /// Call id for the in-flight request. + id: String, + /// User decision for the requested permission preset. + response: RequestPermissionPresetResponse, + }, + /// Resolve a dynamic tool call request. DynamicToolResponse { /// Call id for the in-flight request. @@ -744,6 +754,7 @@ impl Op { Self::ResolveElicitation { .. } => "resolve_elicitation", Self::UserInputAnswer { .. } => "user_input_answer", Self::RequestPermissionsResponse { .. } => "request_permissions_response", + Self::RequestPermissionPresetResponse { .. } => "request_permission_preset_response", Self::DynamicToolResponse { .. } => "dynamic_tool_response", Self::AddToHistory { .. } => "add_to_history", Self::GetHistoryEntryRequest { .. } => "get_history_entry_request", @@ -1471,6 +1482,8 @@ pub enum EventMsg { RequestPermissions(RequestPermissionsEvent), + RequestPermissionPreset(RequestPermissionPresetEvent), + RequestUserInput(RequestUserInputEvent), DynamicToolCallRequest(DynamicToolCallRequest), diff --git a/codex-rs/protocol/src/request_permission_preset.rs b/codex-rs/protocol/src/request_permission_preset.rs new file mode 100644 index 0000000000..15c05d8643 --- /dev/null +++ b/codex-rs/protocol/src/request_permission_preset.rs @@ -0,0 +1,77 @@ +use crate::config_types::ApprovalsReviewer; +use crate::protocol::AskForApproval; +use crate::protocol::SandboxPolicy; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "kebab-case")] +#[ts(rename_all = "kebab-case")] +pub enum PermissionPresetId { + Auto, + FullAccess, + ReadOnly, + GuardianApprovals, +} + +impl PermissionPresetId { + pub fn as_str(self) -> &'static str { + match self { + Self::Auto => "auto", + Self::FullAccess => "full-access", + Self::ReadOnly => "read-only", + Self::GuardianApprovals => "guardian-approvals", + } + } + + pub fn from_id(id: &str) -> Option { + match id { + "auto" => Some(Self::Auto), + "full-access" => Some(Self::FullAccess), + "read-only" => Some(Self::ReadOnly), + "guardian-approvals" => Some(Self::GuardianApprovals), + _ => None, + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +pub struct RequestPermissionPresetArgs { + pub preset: PermissionPresetId, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, +} + +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum RequestPermissionPresetDecision { + Accepted, + Declined, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +pub struct RequestPermissionPresetResponse { + pub decision: RequestPermissionPresetDecision, + pub preset: PermissionPresetId, + pub message: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +pub struct RequestPermissionPresetEvent { + /// 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, + pub preset: PermissionPresetId, + pub label: String, + pub description: String, + pub approval_policy: AskForApproval, + pub approvals_reviewer: ApprovalsReviewer, + pub sandbox_policy: SandboxPolicy, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, +} diff --git a/codex-rs/rollout/src/policy.rs b/codex-rs/rollout/src/policy.rs index 4b50781e76..4ce2770749 100644 --- a/codex-rs/rollout/src/policy.rs +++ b/codex-rs/rollout/src/policy.rs @@ -150,6 +150,7 @@ fn event_msg_persistence_mode(ev: &EventMsg) -> Option { | EventMsg::ExecCommandOutputDelta(_) | EventMsg::ExecApprovalRequest(_) | EventMsg::RequestPermissions(_) + | EventMsg::RequestPermissionPreset(_) | EventMsg::RequestUserInput(_) | EventMsg::ElicitationRequest(_) | EventMsg::ApplyPatchApprovalRequest(_) diff --git a/codex-rs/tools/Cargo.toml b/codex-rs/tools/Cargo.toml index 179681003c..de821d4908 100644 --- a/codex-rs/tools/Cargo.toml +++ b/codex-rs/tools/Cargo.toml @@ -13,6 +13,7 @@ codex-code-mode = { workspace = true } codex-features = { workspace = true } codex-protocol = { workspace = true } codex-utils-absolute-path = { workspace = true } +codex-utils-approval-presets = { workspace = true } codex-utils-pty = { workspace = true } rmcp = { workspace = true, default-features = false, features = [ "base64", diff --git a/codex-rs/tools/src/lib.rs b/codex-rs/tools/src/lib.rs index c310222a0f..4bbcc243d1 100644 --- a/codex-rs/tools/src/lib.rs +++ b/codex-rs/tools/src/lib.rs @@ -63,10 +63,12 @@ pub use json_schema::parse_tool_input_schema; pub use local_tool::CommandToolOptions; pub use local_tool::ShellToolOptions; pub use local_tool::create_exec_command_tool; +pub use local_tool::create_request_permission_preset_tool; pub use local_tool::create_request_permissions_tool; pub use local_tool::create_shell_command_tool; pub use local_tool::create_shell_tool; pub use local_tool::create_write_stdin_tool; +pub use local_tool::request_permission_preset_tool_description; pub use local_tool::request_permissions_tool_description; pub use mcp_resource_tool::create_list_mcp_resource_templates_tool; pub use mcp_resource_tool::create_list_mcp_resources_tool; diff --git a/codex-rs/tools/src/local_tool.rs b/codex-rs/tools/src/local_tool.rs index 3e369ab1e3..fe37984fb0 100644 --- a/codex-rs/tools/src/local_tool.rs +++ b/codex-rs/tools/src/local_tool.rs @@ -296,6 +296,45 @@ pub fn request_permissions_tool_description() -> String { .to_string() } +pub fn create_request_permission_preset_tool(available_preset_ids: Vec<&'static str>) -> ToolSpec { + let properties = BTreeMap::from([ + ( + "preset".to_string(), + JsonSchema::string_enum( + available_preset_ids + .into_iter() + .map(|id| json!(id)) + .collect(), + Some("Built-in permission preset to request from the user.".to_string()), + ), + ), + ( + "reason".to_string(), + JsonSchema::string(Some( + "Optional short explanation for why the permission mode should change.".to_string(), + )), + ), + ]); + + ToolSpec::Function(ResponsesApiTool { + name: "request_permission_preset".to_string(), + description: request_permission_preset_tool_description(), + strict: false, + defer_loading: None, + parameters: JsonSchema::object( + properties, + Some(vec!["preset".to_string()]), + Some(false.into()), + ), + output_schema: None, + }) +} + +pub fn request_permission_preset_tool_description() -> String { + "Open the permission-mode picker with a built-in preset such as Default or Full Access pre-selected. Use this immediately when the user says things like \"make this session full access\", \"switch to read-only\", or otherwise conversationally asks to change sandboxing, approval, or permission mode. The session only changes if the user selects a permission mode in the picker." + .to_string() +} + fn unified_exec_output_schema() -> Value { json!({ "type": "object", diff --git a/codex-rs/tools/src/tool_config.rs b/codex-rs/tools/src/tool_config.rs index 912542d9f6..b17b9628ac 100644 --- a/codex-rs/tools/src/tool_config.rs +++ b/codex-rs/tools/src/tool_config.rs @@ -97,6 +97,8 @@ pub struct ToolsConfig { pub tool_suggest: bool, pub exec_permission_approvals_enabled: bool, pub request_permissions_tool_enabled: bool, + pub request_permission_preset_tool_enabled: bool, + pub guardian_approval_enabled: bool, pub code_mode_enabled: bool, pub code_mode_only_enabled: bool, pub js_repl_enabled: bool, @@ -161,6 +163,9 @@ impl ToolsConfig { && supports_image_generation(model_info); let exec_permission_approvals_enabled = features.enabled(Feature::ExecPermissionApprovals); let request_permissions_tool_enabled = features.enabled(Feature::RequestPermissionsTool); + let request_permission_preset_tool_enabled = + features.enabled(Feature::RequestPermissionPresetTool); + let guardian_approval_enabled = features.enabled(Feature::GuardianApproval); let shell_command_backend = if features.enabled(Feature::ShellTool) && features.enabled(Feature::ShellZshFork) { ShellCommandBackendConfig::ZshFork @@ -218,6 +223,8 @@ impl ToolsConfig { tool_suggest: include_tool_suggest, exec_permission_approvals_enabled, request_permissions_tool_enabled, + request_permission_preset_tool_enabled, + guardian_approval_enabled, code_mode_enabled: include_code_mode, code_mode_only_enabled: include_code_mode_only, js_repl_enabled: include_js_repl, diff --git a/codex-rs/tools/src/tool_registry_plan.rs b/codex-rs/tools/src/tool_registry_plan.rs index 82b4c3a142..efa93405e2 100644 --- a/codex-rs/tools/src/tool_registry_plan.rs +++ b/codex-rs/tools/src/tool_registry_plan.rs @@ -34,6 +34,7 @@ use crate::create_list_mcp_resources_tool; use crate::create_local_shell_tool; use crate::create_read_mcp_resource_tool; use crate::create_report_agent_job_result_tool; +use crate::create_request_permission_preset_tool; use crate::create_request_permissions_tool; use crate::create_request_user_input_tool; use crate::create_resume_agent_tool; @@ -61,6 +62,7 @@ use crate::request_user_input_tool_description; use crate::tool_registry_plan_types::agent_type_description; use codex_protocol::openai_models::ApplyPatchToolType; use codex_protocol::openai_models::ConfigShellToolType; +use codex_utils_approval_presets::builtin_permission_presets; use rmcp::model::Tool as McpTool; use std::collections::BTreeMap; @@ -247,6 +249,25 @@ pub fn build_tool_registry_plan( plan.register_handler("request_permissions", ToolHandlerKind::RequestPermissions); } + if config.request_permission_preset_tool_enabled { + let available_preset_ids = builtin_permission_presets( + cfg!(target_os = "windows"), + config.guardian_approval_enabled, + ) + .into_iter() + .map(|preset| preset.id) + .collect(); + plan.push_spec( + create_request_permission_preset_tool(available_preset_ids), + /*supports_parallel_tool_calls*/ false, + config.code_mode_enabled, + ); + plan.register_handler( + "request_permission_preset", + ToolHandlerKind::RequestPermissionPreset, + ); + } + if config.search_tool && let Some(deferred_mcp_tools) = params.deferred_mcp_tools { diff --git a/codex-rs/tools/src/tool_registry_plan_tests.rs b/codex-rs/tools/src/tool_registry_plan_tests.rs index 9da58849c1..37e4560c10 100644 --- a/codex-rs/tools/src/tool_registry_plan_tests.rs +++ b/codex-rs/tools/src/tool_registry_plan_tests.rs @@ -127,6 +127,15 @@ fn test_full_toolset_specs_for_gpt5_codex_unified_exec_web_search() { let spec = create_request_permissions_tool(request_permissions_tool_description()); expected.insert(spec.name().to_string(), spec); } + if config.request_permission_preset_tool_enabled { + let available_preset_ids = if cfg!(target_os = "windows") { + vec!["auto", "full-access", "read-only"] + } else { + vec!["auto", "full-access"] + }; + let spec = create_request_permission_preset_tool(available_preset_ids); + expected.insert(spec.name().to_string(), spec); + } assert_eq!( actual.keys().collect::>(), @@ -607,6 +616,60 @@ fn request_permissions_requires_feature_flag() { ); } +#[test] +fn request_permission_preset_requires_feature_flag() { + let model_info = model_info(); + let mut features = Features::with_defaults(); + features.disable(Feature::RequestPermissionPresetTool); + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + image_generation_tool_auth_allowed: true, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + sandbox_policy: &SandboxPolicy::DangerFullAccess, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + }); + let (tools, _) = build_specs( + &tools_config, + /*mcp_tools*/ None, + /*deferred_mcp_tools*/ None, + &[], + ); + assert_lacks_tool_name(&tools, "request_permission_preset"); + + let mut features = Features::with_defaults(); + features.enable(Feature::RequestPermissionPresetTool); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + image_generation_tool_auth_allowed: true, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + sandbox_policy: &SandboxPolicy::DangerFullAccess, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + }); + let (tools, _) = build_specs( + &tools_config, + /*mcp_tools*/ None, + /*deferred_mcp_tools*/ None, + &[], + ); + let request_permission_preset_tool = find_tool(&tools, "request_permission_preset"); + let available_preset_ids = if cfg!(target_os = "windows") { + vec!["auto", "full-access", "read-only"] + } else { + vec!["auto", "full-access"] + }; + assert_eq!( + request_permission_preset_tool.spec, + create_request_permission_preset_tool(available_preset_ids) + ); +} + #[test] fn request_permissions_tool_is_independent_from_additional_permissions() { let model_info = model_info(); diff --git a/codex-rs/tools/src/tool_registry_plan_types.rs b/codex-rs/tools/src/tool_registry_plan_types.rs index 7459954dcd..fa802be433 100644 --- a/codex-rs/tools/src/tool_registry_plan_types.rs +++ b/codex-rs/tools/src/tool_registry_plan_types.rs @@ -26,6 +26,7 @@ pub enum ToolHandlerKind { Mcp, McpResource, Plan, + RequestPermissionPreset, RequestPermissions, RequestUserInput, ResumeAgentV1, diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index a8db00dc21..f060f4c932 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -125,6 +125,7 @@ use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillErrorInfo; use codex_protocol::protocol::TokenUsage; +use codex_protocol::request_permission_preset::RequestPermissionPresetEvent; use codex_terminal_detection::user_agent; use codex_utils_absolute_path::AbsolutePathBuf; use color_eyre::eyre::Result; @@ -174,6 +175,7 @@ const THREAD_EVENT_CHANNEL_CAPACITY: usize = 32768; enum ThreadInteractiveRequest { Approval(ApprovalRequest), McpServerElicitation(McpServerElicitationFormRequest), + PermissionPreset(RequestPermissionPresetEvent), } fn app_server_request_id_to_mcp_request_id( @@ -1842,6 +1844,19 @@ impl App { permissions: params.permissions.clone().into(), }), ), + ServerRequest::PermissionPresetRequestApproval { params, .. } => Some( + ThreadInteractiveRequest::PermissionPreset(RequestPermissionPresetEvent { + turn_id: params.turn_id.clone(), + call_id: params.item_id.clone(), + reason: params.reason.clone(), + preset: params.preset.to_core(), + label: params.label.clone(), + description: params.description.clone(), + approval_policy: params.approval_policy.to_core(), + approvals_reviewer: params.approvals_reviewer.to_core(), + sandbox_policy: params.sandbox_policy.to_core(), + }), + ), _ => None, } } @@ -2683,6 +2698,10 @@ impl App { self.chat_widget .push_mcp_server_elicitation_request(request); } + ThreadInteractiveRequest::PermissionPreset(request) => { + self.chat_widget + .handle_request_permission_preset_now(request); + } } } self.refresh_pending_thread_approvals().await; @@ -4610,9 +4629,13 @@ impl App { AppEvent::OpenFullAccessConfirmation { preset, return_to_permissions, + permission_preset_context, } => { - self.chat_widget - .open_full_access_confirmation(preset, return_to_permissions); + self.chat_widget.open_full_access_confirmation( + preset, + return_to_permissions, + permission_preset_context, + ); } AppEvent::OpenWorldWritableWarningConfirmation { preset, @@ -5486,6 +5509,13 @@ impl App { AppEvent::OpenPermissionsPopup => { self.chat_widget.open_permissions_popup(); } + AppEvent::OpenPermissionsPopupForRequest { + preset, + response_context, + } => { + self.chat_widget + .open_permissions_popup_for_request(preset, response_context); + } AppEvent::OpenReviewBranchPicker(cwd) => { self.chat_widget.show_review_branch_picker(&cwd).await; } diff --git a/codex-rs/tui/src/app/app_server_adapter.rs b/codex-rs/tui/src/app/app_server_adapter.rs index 0bd78f353a..18727f051d 100644 --- a/codex-rs/tui/src/app/app_server_adapter.rs +++ b/codex-rs/tui/src/app/app_server_adapter.rs @@ -298,6 +298,9 @@ fn server_request_thread_id(request: &ServerRequest) -> Option { ServerRequest::PermissionsRequestApproval { params, .. } => { ThreadId::from_string(¶ms.thread_id).ok() } + ServerRequest::PermissionPresetRequestApproval { params, .. } => { + ThreadId::from_string(¶ms.thread_id).ok() + } ServerRequest::DynamicToolCall { params, .. } => { ThreadId::from_string(¶ms.thread_id).ok() } diff --git a/codex-rs/tui/src/app/app_server_requests.rs b/codex-rs/tui/src/app/app_server_requests.rs index b1393e95e0..24f75d65ba 100644 --- a/codex-rs/tui/src/app/app_server_requests.rs +++ b/codex-rs/tui/src/app/app_server_requests.rs @@ -8,6 +8,7 @@ use codex_app_server_protocol::FileChangeApprovalDecision; use codex_app_server_protocol::FileChangeRequestApprovalResponse; use codex_app_server_protocol::McpServerElicitationAction; use codex_app_server_protocol::McpServerElicitationRequestResponse; +use codex_app_server_protocol::PermissionPresetRequestApprovalResponse; use codex_app_server_protocol::PermissionsRequestApprovalResponse; use codex_app_server_protocol::RequestId as AppServerRequestId; use codex_app_server_protocol::ServerRequest; @@ -32,6 +33,7 @@ pub(super) struct PendingAppServerRequests { exec_approvals: HashMap, file_change_approvals: HashMap, permissions_approvals: HashMap, + permission_preset_approvals: HashMap, user_inputs: HashMap, mcp_requests: HashMap, } @@ -41,6 +43,7 @@ impl PendingAppServerRequests { self.exec_approvals.clear(); self.file_change_approvals.clear(); self.permissions_approvals.clear(); + self.permission_preset_approvals.clear(); self.user_inputs.clear(); self.mcp_requests.clear(); } @@ -68,6 +71,11 @@ impl PendingAppServerRequests { .insert(params.item_id.clone(), request_id.clone()); None } + ServerRequest::PermissionPresetRequestApproval { request_id, params } => { + self.permission_preset_approvals + .insert(params.item_id.clone(), request_id.clone()); + None + } ServerRequest::ToolRequestUserInput { request_id, params } => { self.user_inputs .insert(params.turn_id.clone(), request_id.clone()); @@ -164,6 +172,24 @@ impl PendingAppServerRequests { }) }) .transpose()?, + AppCommandView::RequestPermissionPresetResponse { id, response } => self + .permission_preset_approvals + .remove(id) + .map(|request_id| { + Ok::(AppServerRequestResolution { + request_id, + result: serde_json::to_value(PermissionPresetRequestApprovalResponse { + decision: response.decision.into(), + preset: response.preset.into(), + }) + .map_err(|err| { + format!( + "failed to serialize permission preset approval response: {err}" + ) + })?, + }) + }) + .transpose()?, AppCommandView::UserInputAnswer { id, response } => self .user_inputs .remove(id) diff --git a/codex-rs/tui/src/app/pending_interactive_replay.rs b/codex-rs/tui/src/app/pending_interactive_replay.rs index 6bae217362..5ac6fca238 100644 --- a/codex-rs/tui/src/app/pending_interactive_replay.rs +++ b/codex-rs/tui/src/app/pending_interactive_replay.rs @@ -44,6 +44,8 @@ pub(super) struct PendingInteractiveReplayState { elicitation_requests: HashSet, request_permissions_call_ids: HashSet, request_permissions_call_ids_by_turn_id: HashMap>, + request_permission_preset_call_ids: HashSet, + request_permission_preset_call_ids_by_turn_id: HashMap>, request_user_input_call_ids: HashSet, request_user_input_call_ids_by_turn_id: HashMap>, pending_requests_by_request_id: HashMap, @@ -64,6 +66,10 @@ enum PendingInteractiveRequest { turn_id: String, item_id: String, }, + RequestPermissionPreset { + turn_id: String, + item_id: String, + }, RequestUserInput { turn_id: String, item_id: String, @@ -82,6 +88,7 @@ impl PendingInteractiveReplayState { | AppCommandView::PatchApproval { .. } | AppCommandView::ResolveElicitation { .. } | AppCommandView::RequestPermissionsResponse { .. } + | AppCommandView::RequestPermissionPresetResponse { .. } | AppCommandView::UserInputAnswer { .. } | AppCommandView::Shutdown ) @@ -142,6 +149,18 @@ impl PendingInteractiveReplayState { }, ); } + AppCommandView::RequestPermissionPresetResponse { id, .. } => { + self.request_permission_preset_call_ids.remove(id); + Self::remove_call_id_from_turn_map( + &mut self.request_permission_preset_call_ids_by_turn_id, + id, + ); + self.pending_requests_by_request_id.retain( + |_, pending| { + !matches!(pending, PendingInteractiveRequest::RequestPermissionPreset { item_id, .. } if item_id == id) + }, + ); + } // `Op::UserInputAnswer` identifies the turn, not the prompt call_id. The UI // answers queued prompts for the same turn in FIFO order, so remove the oldest // queued call_id for that turn. @@ -248,6 +267,21 @@ impl PendingInteractiveReplayState { }, ); } + ServerRequest::PermissionPresetRequestApproval { request_id, params } => { + self.request_permission_preset_call_ids + .insert(params.item_id.clone()); + self.request_permission_preset_call_ids_by_turn_id + .entry(params.turn_id.clone()) + .or_default() + .push(params.item_id.clone()); + self.pending_requests_by_request_id.insert( + request_id.clone(), + PendingInteractiveRequest::RequestPermissionPreset { + turn_id: params.turn_id.clone(), + item_id: params.item_id.clone(), + }, + ); + } _ => {} } } @@ -275,6 +309,7 @@ impl PendingInteractiveReplayState { self.clear_exec_approval_turn(¬ification.turn.id); self.clear_patch_approval_turn(¬ification.turn.id); self.clear_request_permissions_turn(¬ification.turn.id); + self.clear_request_permission_preset_turn(¬ification.turn.id); self.clear_request_user_input_turn(¬ification.turn.id); } ServerNotification::ServerRequestResolved(notification) => { @@ -348,6 +383,24 @@ impl PendingInteractiveReplayState { .remove(¶ms.turn_id); } } + ServerRequest::PermissionPresetRequestApproval { params, .. } => { + self.request_permission_preset_call_ids + .remove(¶ms.item_id); + let mut remove_turn_entry = false; + if let Some(call_ids) = self + .request_permission_preset_call_ids_by_turn_id + .get_mut(¶ms.turn_id) + { + call_ids.retain(|call_id| call_id != ¶ms.item_id); + if call_ids.is_empty() { + remove_turn_entry = true; + } + } + if remove_turn_entry { + self.request_permission_preset_call_ids_by_turn_id + .remove(¶ms.turn_id); + } + } _ => {} } self.pending_requests_by_request_id @@ -374,6 +427,9 @@ impl PendingInteractiveReplayState { ServerRequest::PermissionsRequestApproval { params, .. } => { self.request_permissions_call_ids.contains(¶ms.item_id) } + ServerRequest::PermissionPresetRequestApproval { params, .. } => self + .request_permission_preset_call_ids + .contains(¶ms.item_id), _ => true, } } @@ -383,6 +439,7 @@ impl PendingInteractiveReplayState { || !self.patch_approval_call_ids.is_empty() || !self.elicitation_requests.is_empty() || !self.request_permissions_call_ids.is_empty() + || !self.request_permission_preset_call_ids.is_empty() } fn clear_request_user_input_turn(&mut self, turn_id: &str) { @@ -411,6 +468,22 @@ impl PendingInteractiveReplayState { ); } + fn clear_request_permission_preset_turn(&mut self, turn_id: &str) { + if let Some(call_ids) = self + .request_permission_preset_call_ids_by_turn_id + .remove(turn_id) + { + for call_id in call_ids { + self.request_permission_preset_call_ids.remove(&call_id); + } + } + self.pending_requests_by_request_id.retain( + |_, pending| { + !matches!(pending, PendingInteractiveRequest::RequestPermissionPreset { turn_id: pending_turn_id, .. } if pending_turn_id == turn_id) + }, + ); + } + fn clear_exec_approval_turn(&mut self, turn_id: &str) { if let Some(call_ids) = self.exec_approval_call_ids_by_turn_id.remove(turn_id) { for call_id in call_ids { @@ -472,6 +545,8 @@ impl PendingInteractiveReplayState { self.elicitation_requests.clear(); self.request_permissions_call_ids.clear(); self.request_permissions_call_ids_by_turn_id.clear(); + self.request_permission_preset_call_ids.clear(); + self.request_permission_preset_call_ids_by_turn_id.clear(); self.request_user_input_call_ids.clear(); self.request_user_input_call_ids_by_turn_id.clear(); self.pending_requests_by_request_id.clear(); @@ -512,6 +587,14 @@ impl PendingInteractiveReplayState { &item_id, ); } + PendingInteractiveRequest::RequestPermissionPreset { turn_id, item_id } => { + self.request_permission_preset_call_ids.remove(&item_id); + Self::remove_call_id_from_turn_map_entry( + &mut self.request_permission_preset_call_ids_by_turn_id, + &turn_id, + &item_id, + ); + } PendingInteractiveRequest::RequestUserInput { turn_id, item_id } => { self.request_user_input_call_ids.remove(&item_id); Self::remove_call_id_from_turn_map_entry( @@ -553,6 +636,10 @@ impl PendingInteractiveReplayState { PendingInteractiveRequest::RequestPermissions { turn_id, item_id }, ServerRequest::PermissionsRequestApproval { params, .. }, ) => turn_id == ¶ms.turn_id && item_id == ¶ms.item_id, + ( + PendingInteractiveRequest::RequestPermissionPreset { turn_id, item_id }, + ServerRequest::PermissionPresetRequestApproval { params, .. }, + ) => turn_id == ¶ms.turn_id && item_id == ¶ms.item_id, ( PendingInteractiveRequest::RequestUserInput { turn_id, item_id }, ServerRequest::ToolRequestUserInput { params, .. }, diff --git a/codex-rs/tui/src/app_command.rs b/codex-rs/tui/src/app_command.rs index 0646cc297d..a827879715 100644 --- a/codex-rs/tui/src/app_command.rs +++ b/codex-rs/tui/src/app_command.rs @@ -17,6 +17,7 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::request_permission_preset::RequestPermissionPresetResponse; use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_protocol::user_input::UserInput; @@ -89,6 +90,10 @@ pub(crate) enum AppCommandView<'a> { id: &'a str, response: &'a RequestPermissionsResponse, }, + RequestPermissionPresetResponse { + id: &'a str, + response: &'a RequestPermissionPresetResponse, + }, ReloadUserConfig, ListSkills { cwds: &'a [PathBuf], @@ -241,6 +246,13 @@ impl AppCommand { Self(Op::RequestPermissionsResponse { id, response }) } + pub(crate) fn request_permission_preset_response( + id: String, + response: RequestPermissionPresetResponse, + ) -> Self { + Self(Op::RequestPermissionPresetResponse { id, response }) + } + pub(crate) fn reload_user_config() -> Self { Self(Op::ReloadUserConfig) } @@ -379,6 +391,9 @@ impl AppCommand { Op::RequestPermissionsResponse { id, response } => { AppCommandView::RequestPermissionsResponse { id, response } } + Op::RequestPermissionPresetResponse { id, response } => { + AppCommandView::RequestPermissionPresetResponse { id, response } + } Op::ReloadUserConfig => AppCommandView::ReloadUserConfig, Op::ListSkills { cwds, force_reload } => AppCommandView::ListSkills { cwds, diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 0e71e1a432..d46b446a12 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -39,6 +39,7 @@ use codex_protocol::config_types::ServiceTier; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::request_permission_preset::PermissionPresetId; use codex_realtime_webrtc::RealtimeWebrtcEvent; use codex_realtime_webrtc::RealtimeWebrtcSessionHandle; @@ -71,6 +72,12 @@ pub(crate) enum WindowsSandboxEnableMode { Legacy, } +#[derive(Debug, Clone)] +pub(crate) struct PermissionPresetSelectionContext { + pub(crate) thread_id: ThreadId, + pub(crate) call_id: String, +} + #[derive(Debug, Clone)] #[cfg_attr(not(target_os = "windows"), allow(dead_code))] pub(crate) struct ConnectorsSnapshot { @@ -377,6 +384,7 @@ pub(crate) enum AppEvent { OpenFullAccessConfirmation { preset: ApprovalPreset, return_to_permissions: bool, + permission_preset_context: Option, }, /// Open the Windows world-writable directories warning. @@ -518,6 +526,12 @@ pub(crate) enum AppEvent { /// Re-open the permissions presets popup. OpenPermissionsPopup, + /// Open the permissions presets popup for a model-requested change. + OpenPermissionsPopupForRequest { + preset: PermissionPresetId, + response_context: PermissionPresetSelectionContext, + }, + /// Live update for the in-progress voice recording placeholder. Carries /// the placeholder `id` and the text to display (e.g., an ASCII meter). #[cfg(not(target_os = "linux"))] diff --git a/codex-rs/tui/src/app_event_sender.rs b/codex-rs/tui/src/app_event_sender.rs index af95fd7dec..ccf6f810e4 100644 --- a/codex-rs/tui/src/app_event_sender.rs +++ b/codex-rs/tui/src/app_event_sender.rs @@ -7,6 +7,7 @@ use codex_protocol::mcp::RequestId as McpRequestId; use codex_protocol::protocol::ConversationAudioParams; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewRequest; +use codex_protocol::request_permission_preset::RequestPermissionPresetResponse; use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputResponse; use tokio::sync::mpsc::UnboundedSender; @@ -95,6 +96,18 @@ impl AppEventSender { }); } + pub(crate) fn request_permission_preset_response( + &self, + thread_id: ThreadId, + id: String, + response: RequestPermissionPresetResponse, + ) { + self.send(AppEvent::SubmitThreadOp { + thread_id, + op: AppCommand::request_permission_preset_response(id, response).into_core(), + }); + } + pub(crate) fn patch_approval(&self, thread_id: ThreadId, id: String, decision: ReviewDecision) { self.send(AppEvent::SubmitThreadOp { thread_id, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index e7ef6a0606..f254efa1e4 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -216,6 +216,10 @@ use codex_protocol::protocol::ViewImageToolCallEvent; use codex_protocol::protocol::WarningEvent; use codex_protocol::protocol::WebSearchBeginEvent; use codex_protocol::protocol::WebSearchEndEvent; +use codex_protocol::request_permission_preset::PermissionPresetId; +use codex_protocol::request_permission_preset::RequestPermissionPresetDecision; +use codex_protocol::request_permission_preset::RequestPermissionPresetEvent; +use codex_protocol::request_permission_preset::RequestPermissionPresetResponse; use codex_protocol::request_permissions::RequestPermissionsEvent; use codex_protocol::request_user_input::RequestUserInputEvent; use codex_protocol::request_user_input::RequestUserInputQuestionOption; @@ -298,6 +302,7 @@ fn queued_message_edit_binding_for_terminal(terminal_info: TerminalInfo) -> KeyB use crate::app_event::AppEvent; use crate::app_event::ConnectorsSnapshot; use crate::app_event::ExitMode; +use crate::app_event::PermissionPresetSelectionContext; use crate::app_event::RateLimitRefreshOrigin; #[cfg(target_os = "windows")] use crate::app_event::WindowsSandboxEnableMode; @@ -1575,6 +1580,22 @@ fn request_permissions_from_params( } } +fn request_permission_preset_from_params( + params: codex_app_server_protocol::PermissionPresetRequestApprovalParams, +) -> RequestPermissionPresetEvent { + RequestPermissionPresetEvent { + turn_id: params.turn_id, + call_id: params.item_id, + reason: params.reason, + preset: params.preset.to_core(), + label: params.label, + description: params.description, + approval_policy: params.approval_policy.to_core(), + approvals_reviewer: params.approvals_reviewer.to_core(), + sandbox_policy: params.sandbox_policy.to_core(), + } +} + fn request_user_input_from_params(params: ToolRequestUserInputParams) -> RequestUserInputEvent { RequestUserInputEvent { turn_id: params.turn_id, @@ -3493,6 +3514,14 @@ impl ChatWidget { ); } + fn on_request_permission_preset(&mut self, ev: RequestPermissionPresetEvent) { + let ev2 = ev.clone(); + self.defer_or_handle( + |q| q.push_request_permission_preset(ev), + |s| s.handle_request_permission_preset_now(ev2), + ); + } + fn on_exec_command_begin(&mut self, ev: ExecCommandBeginEvent) { self.flush_answer_stream_with_separator(); if is_unified_exec_source(ev.source) { @@ -4587,6 +4616,19 @@ impl ChatWidget { self.request_redraw(); } + pub(crate) fn handle_request_permission_preset_now( + &mut self, + ev: RequestPermissionPresetEvent, + ) { + self.flush_answer_stream_with_separator(); + let response_context = PermissionPresetSelectionContext { + thread_id: self.thread_id.unwrap_or_default(), + call_id: ev.call_id, + }; + self.open_permissions_popup_for_request(ev.preset, response_context); + self.request_redraw(); + } + pub(crate) fn handle_exec_begin_now(&mut self, ev: ExecCommandBeginEvent) { // Ensure the status indicator is visible while the command runs. self.bottom_pane.ensure_status_indicator(); @@ -5987,6 +6029,9 @@ impl ChatWidget { ServerRequest::PermissionsRequestApproval { params, .. } => { self.on_request_permissions(request_permissions_from_params(params)); } + ServerRequest::PermissionPresetRequestApproval { params, .. } => { + self.on_request_permission_preset(request_permission_preset_from_params(params)); + } ServerRequest::ToolRequestUserInput { params, .. } => { self.on_request_user_input(request_user_input_from_params(params)); } @@ -6691,6 +6736,9 @@ impl ChatWidget { EventMsg::RequestPermissions(ev) => { self.on_request_permissions(ev); } + EventMsg::RequestPermissionPreset(ev) => { + self.on_request_permission_preset(ev); + } 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), @@ -8262,13 +8310,30 @@ impl ChatWidget { /// Open a popup to choose the permissions mode (approval policy + sandbox policy). pub(crate) fn open_permissions_popup(&mut self) { + self.open_permissions_popup_with_request(/*request*/ None); + } + + pub(crate) fn open_permissions_popup_for_request( + &mut self, + preset: PermissionPresetId, + response_context: PermissionPresetSelectionContext, + ) { + self.open_permissions_popup_with_request(Some((preset, response_context))); + } + + fn open_permissions_popup_with_request( + &mut self, + request: Option<(PermissionPresetId, PermissionPresetSelectionContext)>, + ) { let include_read_only = cfg!(target_os = "windows"); let current_approval = self.config.permissions.approval_policy.value(); let current_sandbox = self.config.permissions.sandbox_policy.get(); let guardian_approval_enabled = self.config.features.enabled(Feature::GuardianApproval); let current_review_policy = self.config.approvals_reviewer; let mut items: Vec = Vec::new(); + let mut initial_selected_idx = None; let presets: Vec = builtin_approval_presets(); + let requested_preset = request.as_ref().map(|(preset, _)| *preset); #[cfg(target_os = "windows")] let windows_sandbox_level = WindowsSandboxLevel::from_config(&self.config); @@ -8293,10 +8358,19 @@ impl ChatWidget { .map(|err| err.to_string()) }; + let mut record_initial_selection = |item_id: PermissionPresetId, idx: usize| { + if requested_preset == Some(item_id) { + initial_selected_idx = Some(idx); + } + }; + for preset in presets.into_iter() { if !include_read_only && preset.id == "read-only" { continue; } + let Some(preset_id) = PermissionPresetId::from_id(preset.id) else { + continue; + }; let base_name = if preset.id == "auto" && windows_degraded_sandbox_enabled { "Default (non-admin sandbox)".to_string() } else { @@ -8322,12 +8396,15 @@ impl ChatWidget { .notices .hide_full_access_warning .unwrap_or(false); + let response_context = request.as_ref().map(|(_, context)| context.clone()); let default_actions: Vec = if requires_confirmation { let preset_clone = preset.clone(); + let permission_preset_context = response_context.clone(); vec![Box::new(move |tx| { tx.send(AppEvent::OpenFullAccessConfirmation { preset: preset_clone.clone(), return_to_permissions: !include_read_only, + permission_preset_context: permission_preset_context.clone(), }); })] } else if preset.id == "auto" { @@ -8368,32 +8445,37 @@ impl ChatWidget { }); })] } else { - Self::approval_preset_actions( + Self::permission_preset_actions( preset.approval, preset.sandbox.clone(), base_name.clone(), ApprovalsReviewer::User, + response_context.clone().map(|context| (context, preset_id)), ) } } #[cfg(not(target_os = "windows"))] { - Self::approval_preset_actions( + Self::permission_preset_actions( preset.approval, preset.sandbox.clone(), base_name.clone(), ApprovalsReviewer::User, + response_context.clone().map(|context| (context, preset_id)), ) } } else { - Self::approval_preset_actions( + Self::permission_preset_actions( preset.approval, preset.sandbox.clone(), base_name.clone(), ApprovalsReviewer::User, + response_context.clone().map(|context| (context, preset_id)), ) }; if preset.id == "auto" { + let idx = items.len(); + record_initial_selection(PermissionPresetId::Auto, idx); items.push(SelectionItem { name: base_name.clone(), description: base_description.clone(), @@ -8406,6 +8488,8 @@ impl ChatWidget { }); if guardian_approval_enabled { + let idx = items.len(); + record_initial_selection(PermissionPresetId::GuardianApprovals, idx); items.push(SelectionItem { name: "Guardian Approvals".to_string(), description: Some( @@ -8417,12 +8501,15 @@ impl ChatWidget { current_approval, current_sandbox, &preset, - ), - actions: Self::approval_preset_actions( + ), + actions: Self::permission_preset_actions( preset.approval, preset.sandbox.clone(), "Guardian Approvals".to_string(), ApprovalsReviewer::GuardianSubagent, + response_context + .clone() + .map(|context| (context, PermissionPresetId::GuardianApprovals)), ), dismiss_on_select: true, disabled_reason: approval_disabled_reason @@ -8431,6 +8518,8 @@ impl ChatWidget { }); } } else { + let idx = items.len(); + record_initial_selection(preset_id, idx); items.push(SelectionItem { name: base_name, description: base_description, @@ -8462,6 +8551,21 @@ impl ChatWidget { footer_hint: Some(standard_popup_hint_line()), items, header: Box::new(()), + initial_selected_idx, + on_cancel: request.map(|(_, context)| { + Box::new(move |tx: &AppEventSender| { + let message = "permission preset picker was dismissed".to_string(); + tx.request_permission_preset_response( + context.thread_id, + context.call_id.clone(), + RequestPermissionPresetResponse { + decision: RequestPermissionPresetDecision::Declined, + preset: requested_preset.unwrap_or(PermissionPresetId::Auto), + message, + }, + ); + }) as Box + }), ..Default::default() }); } @@ -8485,14 +8589,32 @@ impl ChatWidget { self.bottom_pane.show_view(Box::new(view)); } + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] fn approval_preset_actions( approval: AskForApproval, sandbox: SandboxPolicy, label: String, approvals_reviewer: ApprovalsReviewer, + ) -> Vec { + Self::permission_preset_actions( + approval, + sandbox, + label, + approvals_reviewer, + /*response*/ None, + ) + } + + fn permission_preset_actions( + approval: AskForApproval, + sandbox: SandboxPolicy, + label: String, + approvals_reviewer: ApprovalsReviewer, + response: Option<(PermissionPresetSelectionContext, PermissionPresetId)>, ) -> Vec { vec![Box::new(move |tx| { let sandbox_clone = sandbox.clone(); + let message = format!("Permissions updated to {label}"); tx.send(AppEvent::CodexOp( AppCommand::override_turn_context( /*cwd*/ None, @@ -8513,11 +8635,19 @@ impl ChatWidget { tx.send(AppEvent::UpdateSandboxPolicy(sandbox_clone)); tx.send(AppEvent::UpdateApprovalsReviewer(approvals_reviewer)); tx.send(AppEvent::InsertHistoryCell(Box::new( - history_cell::new_info_event( - format!("Permissions updated to {label}"), - /*hint*/ None, - ), + history_cell::new_info_event(message.clone(), /*hint*/ None), ))); + if let Some((context, preset)) = &response { + tx.request_permission_preset_response( + context.thread_id, + context.call_id.clone(), + RequestPermissionPresetResponse { + decision: RequestPermissionPresetDecision::Accepted, + preset: *preset, + message: message.clone(), + }, + ); + } })] } @@ -8590,6 +8720,7 @@ impl ChatWidget { &mut self, preset: ApprovalPreset, return_to_permissions: bool, + permission_preset_context: Option, ) { let selected_name = preset.label.to_string(); let approval = preset.approval; @@ -8608,21 +8739,26 @@ impl ChatWidget { )); let header = ColumnRenderable::with(header_children); - let mut accept_actions = Self::approval_preset_actions( + let response_context = permission_preset_context + .clone() + .map(|context| (context, PermissionPresetId::FullAccess)); + let mut accept_actions = Self::permission_preset_actions( approval, sandbox.clone(), selected_name.clone(), ApprovalsReviewer::User, + response_context.clone(), ); accept_actions.push(Box::new(|tx| { tx.send(AppEvent::UpdateFullAccessWarningAcknowledged(true)); })); - let mut accept_and_remember_actions = Self::approval_preset_actions( + let mut accept_and_remember_actions = Self::permission_preset_actions( approval, sandbox, selected_name, ApprovalsReviewer::User, + response_context, ); accept_and_remember_actions.push(Box::new(|tx| { tx.send(AppEvent::UpdateFullAccessWarningAcknowledged(true)); @@ -8631,8 +8767,26 @@ impl ChatWidget { let deny_actions: Vec = vec![Box::new(move |tx| { if return_to_permissions { - tx.send(AppEvent::OpenPermissionsPopup); + if let Some(context) = permission_preset_context.clone() { + tx.send(AppEvent::OpenPermissionsPopupForRequest { + preset: PermissionPresetId::FullAccess, + response_context: context, + }); + } else { + tx.send(AppEvent::OpenPermissionsPopup); + } } else { + if let Some(context) = permission_preset_context.clone() { + tx.request_permission_preset_response( + context.thread_id, + context.call_id, + RequestPermissionPresetResponse { + decision: RequestPermissionPresetDecision::Declined, + preset: PermissionPresetId::FullAccess, + message: "permission preset picker was dismissed".to_string(), + }, + ); + } tx.send(AppEvent::OpenApprovalsPopup); } })]; diff --git a/codex-rs/tui/src/chatwidget/interrupts.rs b/codex-rs/tui/src/chatwidget/interrupts.rs index 0a3fc80016..fe16835ad0 100644 --- a/codex-rs/tui/src/chatwidget/interrupts.rs +++ b/codex-rs/tui/src/chatwidget/interrupts.rs @@ -8,6 +8,7 @@ use codex_protocol::protocol::ExecCommandEndEvent; use codex_protocol::protocol::McpToolCallBeginEvent; use codex_protocol::protocol::McpToolCallEndEvent; use codex_protocol::protocol::PatchApplyEndEvent; +use codex_protocol::request_permission_preset::RequestPermissionPresetEvent; use codex_protocol::request_permissions::RequestPermissionsEvent; use codex_protocol::request_user_input::RequestUserInputEvent; @@ -19,6 +20,7 @@ pub(crate) enum QueuedInterrupt { ApplyPatchApproval(ApplyPatchApprovalRequestEvent), Elicitation(ElicitationRequestEvent), RequestPermissions(RequestPermissionsEvent), + RequestPermissionPreset(RequestPermissionPresetEvent), RequestUserInput(RequestUserInputEvent), ExecBegin(ExecCommandBeginEvent), ExecEnd(ExecCommandEndEvent), @@ -62,6 +64,11 @@ impl InterruptManager { .push_back(QueuedInterrupt::RequestPermissions(ev)); } + pub(crate) fn push_request_permission_preset(&mut self, ev: RequestPermissionPresetEvent) { + self.queue + .push_back(QueuedInterrupt::RequestPermissionPreset(ev)); + } + pub(crate) fn push_user_input(&mut self, ev: RequestUserInputEvent) { self.queue.push_back(QueuedInterrupt::RequestUserInput(ev)); } @@ -93,6 +100,9 @@ impl InterruptManager { QueuedInterrupt::ApplyPatchApproval(ev) => chat.handle_apply_patch_approval_now(ev), QueuedInterrupt::Elicitation(ev) => chat.handle_elicitation_request_now(ev), QueuedInterrupt::RequestPermissions(ev) => chat.handle_request_permissions_now(ev), + QueuedInterrupt::RequestPermissionPreset(ev) => { + chat.handle_request_permission_preset_now(ev); + } QueuedInterrupt::RequestUserInput(ev) => chat.handle_request_user_input_now(ev), QueuedInterrupt::ExecBegin(ev) => chat.handle_exec_begin_now(ev), QueuedInterrupt::ExecEnd(ev) => chat.handle_exec_end_now(ev), diff --git a/codex-rs/tui/src/chatwidget/tests/approval_requests.rs b/codex-rs/tui/src/chatwidget/tests/approval_requests.rs index ee686e004b..33edd2370e 100644 --- a/codex-rs/tui/src/chatwidget/tests/approval_requests.rs +++ b/codex-rs/tui/src/chatwidget/tests/approval_requests.rs @@ -2,6 +2,10 @@ //! to keep the primary module under blob-size policy limits. use super::*; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::request_permission_preset::PermissionPresetId; +use codex_protocol::request_permission_preset::RequestPermissionPresetEvent; use pretty_assertions::assert_eq; #[tokio::test] @@ -51,6 +55,72 @@ async fn exec_approval_emits_proposed_command_and_decision_history() { ); } +#[tokio::test] +async fn permission_preset_request_opens_permissions_picker_with_requested_preset_selected() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + #[cfg(target_os = "windows")] + { + chat.config.notices.hide_world_writable_warning = Some(true); + chat.set_windows_sandbox_mode(Some(WindowsSandboxModeToml::Unelevated)); + } + chat.set_feature_enabled(Feature::GuardianApproval, /*enabled*/ false); + + let ev = RequestPermissionPresetEvent { + call_id: "preset-call".into(), + turn_id: "preset-turn".into(), + preset: PermissionPresetId::FullAccess, + label: "Full access".into(), + description: "Run commands without filesystem sandboxing; network access enabled.".into(), + approval_policy: AskForApproval::Never, + approvals_reviewer: ApprovalsReviewer::User, + sandbox_policy: SandboxPolicy::DangerFullAccess, + reason: Some("the user asked to switch into full access mode".into()), + }; + chat.handle_codex_event(Event { + id: "sub-preset".into(), + msg: EventMsg::RequestPermissionPreset(ev), + }); + + let proposed_cells = drain_insert_history(&mut rx); + assert!( + proposed_cells.is_empty(), + "expected preset request to open the picker without emitting history cells" + ); + + let popup = render_bottom_popup(&chat, /*width*/ 100); + assert!( + popup + .lines() + .any(|line| line.contains('›') && line.contains("Full Access")), + "expected Full Access to be pre-selected in the permissions picker: {popup}" + ); + assert_snapshot!("permission_preset_request_picker", popup); + + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); + assert!( + events.iter().any(|event| matches!( + event, + AppEvent::OpenFullAccessConfirmation { + permission_preset_context: Some(_), + .. + } + )), + "expected selecting Full Access to invoke the existing confirmation flow: {events:?}" + ); + assert!( + !events.iter().any(|event| matches!( + event, + AppEvent::SubmitThreadOp { + op: Op::RequestPermissionPresetResponse { .. }, + .. + } + )), + "expected no permission preset response until the user confirms Full Access: {events:?}" + ); +} + #[test] fn app_server_exec_approval_request_splits_shell_wrapped_command() { let script = r#"python3 -c 'print("Hello, world!")'"#; diff --git a/codex-rs/tui/src/chatwidget/tests/permissions.rs b/codex-rs/tui/src/chatwidget/tests/permissions.rs index d9ad9d9a32..ca8ccc1998 100644 --- a/codex-rs/tui/src/chatwidget/tests/permissions.rs +++ b/codex-rs/tui/src/chatwidget/tests/permissions.rs @@ -77,7 +77,9 @@ async fn full_access_confirmation_popup_snapshot() { .into_iter() .find(|preset| preset.id == "full-access") .expect("full access preset"); - chat.open_full_access_confirmation(preset, /*return_to_permissions*/ false); + chat.open_full_access_confirmation( + preset, /*return_to_permissions*/ false, /*permission_preset_context*/ None, + ); let popup = render_bottom_popup(&chat, /*width*/ 80); assert_chatwidget_snapshot!("full_access_confirmation_popup", popup); @@ -669,8 +671,10 @@ async fn permissions_full_access_history_cell_emitted_only_after_confirmation() AppEvent::OpenFullAccessConfirmation { preset, return_to_permissions, + permission_preset_context, } => { - open_confirmation_event = Some((preset, return_to_permissions)); + open_confirmation_event = + Some((preset, return_to_permissions, permission_preset_context)); } _ => {} } @@ -681,9 +685,9 @@ async fn permissions_full_access_history_cell_emitted_only_after_confirmation() "did not expect history cell before confirming full access" ); } - let (preset, return_to_permissions) = + let (preset, return_to_permissions, permission_preset_context) = open_confirmation_event.expect("expected full access confirmation event"); - chat.open_full_access_confirmation(preset, return_to_permissions); + chat.open_full_access_confirmation(preset, return_to_permissions, permission_preset_context); let popup = render_bottom_popup(&chat, /*width*/ 80); assert!( diff --git a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__approval_requests__permission_preset_request_picker.snap b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__approval_requests__permission_preset_request_picker.snap new file mode 100644 index 0000000000..fc02f764b9 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__approval_requests__permission_preset_request_picker.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/chatwidget/tests/approval_requests.rs +assertion_line: 97 +expression: popup +--- + Update Model Permissions + + 1. Default Codex can read and edit files in the current workspace, and run commands. + Approval is required to access the internet or edit other files. +› 2. Full Access Codex can edit files outside this workspace and access the internet without + asking for approval. Exercise caution when using. + + Press enter to confirm or esc to go back diff --git a/codex-rs/utils/approval-presets/src/lib.rs b/codex-rs/utils/approval-presets/src/lib.rs index fbfa120e61..525e6fdaf5 100644 --- a/codex-rs/utils/approval-presets/src/lib.rs +++ b/codex-rs/utils/approval-presets/src/lib.rs @@ -1,3 +1,4 @@ +use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; @@ -16,6 +17,23 @@ pub struct ApprovalPreset { pub sandbox: SandboxPolicy, } +/// A built-in permission mode the user can select. +#[derive(Debug, Clone)] +pub struct PermissionPreset { + /// Stable identifier for the preset. + pub id: &'static str, + /// Display label shown in UIs. + pub label: &'static str, + /// Short human description shown next to the label in UIs. + pub description: &'static str, + /// Approval policy to apply. + pub approval: AskForApproval, + /// Sandbox policy to apply. + pub sandbox: SandboxPolicy, + /// Approval reviewer to apply. + pub approvals_reviewer: ApprovalsReviewer, +} + /// Built-in list of approval presets that pair approval and sandbox policy. /// /// Keep this UI-agnostic so it can be reused by both TUI and MCP server. @@ -44,3 +62,50 @@ pub fn builtin_approval_presets() -> Vec { }, ] } + +/// Built-in permission presets exposed by permission-mode selection surfaces. +/// +/// `include_read_only` and `include_guardian` let each client match the set of +/// presets it already exposes without duplicating the preset definitions. +pub fn builtin_permission_presets( + include_read_only: bool, + include_guardian: bool, +) -> Vec { + let mut presets = Vec::new(); + for preset in builtin_approval_presets() { + if !include_read_only && preset.id == "read-only" { + continue; + } + + presets.push(PermissionPreset { + id: preset.id, + label: preset.label, + description: preset.description, + approval: preset.approval, + sandbox: preset.sandbox.clone(), + approvals_reviewer: ApprovalsReviewer::User, + }); + + if include_guardian && preset.id == "auto" { + presets.push(PermissionPreset { + id: "guardian-approvals", + label: "Guardian Approvals", + description: "Same workspace-write permissions as Default, but eligible `on-request` approvals are routed through the guardian reviewer subagent.", + approval: preset.approval, + sandbox: preset.sandbox, + approvals_reviewer: ApprovalsReviewer::GuardianSubagent, + }); + } + } + presets +} + +pub fn find_builtin_permission_preset( + id: &str, + include_read_only: bool, + include_guardian: bool, +) -> Option { + builtin_permission_presets(include_read_only, include_guardian) + .into_iter() + .find(|preset| preset.id == id) +}