diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index f165dd7c45..cff20b30ab 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -64,6 +64,57 @@ }, "type": "object" }, + "AdditionalFileSystemPermissions": { + "properties": { + "entries": { + "items": { + "$ref": "#/definitions/FileSystemSandboxEntry" + }, + "type": [ + "array", + "null" + ] + }, + "globScanMaxDepth": { + "format": "uint", + "minimum": 1.0, + "type": [ + "integer", + "null" + ] + }, + "read": { + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": [ + "array", + "null" + ] + }, + "write": { + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": [ + "array", + "null" + ] + } + }, + "type": "object" + }, + "AdditionalNetworkPermissions": { + "properties": { + "enabled": { + "type": [ + "boolean", + "null" + ] + } + }, + "type": "object" + }, "AgentMessageDeltaNotification": { "properties": { "delta": { @@ -1019,6 +1070,217 @@ ], "type": "object" }, + "FileSystemAccessMode": { + "enum": [ + "read", + "write", + "none" + ], + "type": "string" + }, + "FileSystemPath": { + "oneOf": [ + { + "properties": { + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "path" + ], + "title": "PathFileSystemPathType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "PathFileSystemPath", + "type": "object" + }, + { + "properties": { + "pattern": { + "type": "string" + }, + "type": { + "enum": [ + "glob_pattern" + ], + "title": "GlobPatternFileSystemPathType", + "type": "string" + } + }, + "required": [ + "pattern", + "type" + ], + "title": "GlobPatternFileSystemPath", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "special" + ], + "title": "SpecialFileSystemPathType", + "type": "string" + }, + "value": { + "$ref": "#/definitions/FileSystemSpecialPath" + } + }, + "required": [ + "type", + "value" + ], + "title": "SpecialFileSystemPath", + "type": "object" + } + ] + }, + "FileSystemSandboxEntry": { + "properties": { + "access": { + "$ref": "#/definitions/FileSystemAccessMode" + }, + "path": { + "$ref": "#/definitions/FileSystemPath" + } + }, + "required": [ + "access", + "path" + ], + "type": "object" + }, + "FileSystemSpecialPath": { + "oneOf": [ + { + "properties": { + "kind": { + "enum": [ + "root" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "RootFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "minimal" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "MinimalFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "current_working_directory" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "CurrentWorkingDirectoryFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "project_roots" + ], + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind" + ], + "title": "KindFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "tmpdir" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "TmpdirFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "slash_tmp" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "SlashTmpFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "unknown" + ], + "type": "string" + }, + "path": { + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind", + "path" + ], + "type": "object" + } + ] + }, "FileUpdateChange": { "properties": { "diff": { @@ -1368,6 +1630,32 @@ ], "title": "McpToolCallGuardianApprovalReviewAction", "type": "object" + }, + { + "properties": { + "permissions": { + "$ref": "#/definitions/RequestPermissionProfile" + }, + "reason": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "requestPermissions" + ], + "title": "RequestPermissionsGuardianApprovalReviewActionType", + "type": "string" + } + }, + "required": [ + "permissions", + "type" + ], + "title": "RequestPermissionsGuardianApprovalReviewAction", + "type": "object" } ] }, @@ -2285,6 +2573,32 @@ } ] }, + "RequestPermissionProfile": { + "additionalProperties": false, + "properties": { + "fileSystem": { + "anyOf": [ + { + "$ref": "#/definitions/AdditionalFileSystemPermissions" + }, + { + "type": "null" + } + ] + }, + "network": { + "anyOf": [ + { + "$ref": "#/definitions/AdditionalNetworkPermissions" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, "ServerRequestResolvedNotification": { "properties": { "requestId": { 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 3f02938281..4d884204e2 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 @@ -5,63 +5,12 @@ "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", "type": "string" }, - "AdditionalFileSystemPermissions": { - "properties": { - "entries": { - "items": { - "$ref": "#/definitions/FileSystemSandboxEntry" - }, - "type": [ - "array", - "null" - ] - }, - "globScanMaxDepth": { - "format": "uint", - "minimum": 1.0, - "type": [ - "integer", - "null" - ] - }, - "read": { - "items": { - "$ref": "#/definitions/v2/AbsolutePathBuf" - }, - "type": [ - "array", - "null" - ] - }, - "write": { - "items": { - "$ref": "#/definitions/v2/AbsolutePathBuf" - }, - "type": [ - "array", - "null" - ] - } - }, - "type": "object" - }, - "AdditionalNetworkPermissions": { - "properties": { - "enabled": { - "type": [ - "boolean", - "null" - ] - } - }, - "type": "object" - }, "AdditionalPermissionProfile": { "properties": { "fileSystem": { "anyOf": [ { - "$ref": "#/definitions/AdditionalFileSystemPermissions" + "$ref": "#/definitions/v2/AdditionalFileSystemPermissions" }, { "type": "null" @@ -71,7 +20,7 @@ "network": { "anyOf": [ { - "$ref": "#/definitions/AdditionalNetworkPermissions" + "$ref": "#/definitions/v2/AdditionalNetworkPermissions" }, { "type": "null" @@ -2268,217 +2217,6 @@ "title": "FileChangeRequestApprovalResponse", "type": "object" }, - "FileSystemAccessMode": { - "enum": [ - "read", - "write", - "none" - ], - "type": "string" - }, - "FileSystemPath": { - "oneOf": [ - { - "properties": { - "path": { - "$ref": "#/definitions/v2/AbsolutePathBuf" - }, - "type": { - "enum": [ - "path" - ], - "title": "PathFileSystemPathType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "PathFileSystemPath", - "type": "object" - }, - { - "properties": { - "pattern": { - "type": "string" - }, - "type": { - "enum": [ - "glob_pattern" - ], - "title": "GlobPatternFileSystemPathType", - "type": "string" - } - }, - "required": [ - "pattern", - "type" - ], - "title": "GlobPatternFileSystemPath", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "special" - ], - "title": "SpecialFileSystemPathType", - "type": "string" - }, - "value": { - "$ref": "#/definitions/FileSystemSpecialPath" - } - }, - "required": [ - "type", - "value" - ], - "title": "SpecialFileSystemPath", - "type": "object" - } - ] - }, - "FileSystemSandboxEntry": { - "properties": { - "access": { - "$ref": "#/definitions/FileSystemAccessMode" - }, - "path": { - "$ref": "#/definitions/FileSystemPath" - } - }, - "required": [ - "access", - "path" - ], - "type": "object" - }, - "FileSystemSpecialPath": { - "oneOf": [ - { - "properties": { - "kind": { - "enum": [ - "root" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "RootFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "minimal" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "MinimalFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "current_working_directory" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "CurrentWorkingDirectoryFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "project_roots" - ], - "type": "string" - }, - "subpath": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "kind" - ], - "title": "KindFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "tmpdir" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "TmpdirFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "slash_tmp" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "SlashTmpFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "unknown" - ], - "type": "string" - }, - "path": { - "type": "string" - }, - "subpath": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "kind", - "path" - ], - "type": "object" - } - ] - }, "FuzzyFileSearchMatchType": { "enum": [ "file", @@ -2611,7 +2349,7 @@ "fileSystem": { "anyOf": [ { - "$ref": "#/definitions/AdditionalFileSystemPermissions" + "$ref": "#/definitions/v2/AdditionalFileSystemPermissions" }, { "type": "null" @@ -2621,7 +2359,7 @@ "network": { "anyOf": [ { - "$ref": "#/definitions/AdditionalNetworkPermissions" + "$ref": "#/definitions/v2/AdditionalNetworkPermissions" }, { "type": "null" @@ -3620,7 +3358,7 @@ "type": "string" }, "permissions": { - "$ref": "#/definitions/RequestPermissionProfile" + "$ref": "#/definitions/v2/RequestPermissionProfile" }, "reason": { "type": [ @@ -3678,32 +3416,6 @@ ], "title": "RequestId" }, - "RequestPermissionProfile": { - "additionalProperties": false, - "properties": { - "fileSystem": { - "anyOf": [ - { - "$ref": "#/definitions/AdditionalFileSystemPermissions" - }, - { - "type": "null" - } - ] - }, - "network": { - "anyOf": [ - { - "$ref": "#/definitions/AdditionalNetworkPermissions" - }, - { - "type": "null" - } - ] - } - }, - "type": "object" - }, "ReviewDecision": { "description": "User's decision in response to an ExecApprovalRequest.", "oneOf": [ @@ -5413,6 +5125,57 @@ ], "type": "string" }, + "AdditionalFileSystemPermissions": { + "properties": { + "entries": { + "items": { + "$ref": "#/definitions/v2/FileSystemSandboxEntry" + }, + "type": [ + "array", + "null" + ] + }, + "globScanMaxDepth": { + "format": "uint", + "minimum": 1.0, + "type": [ + "integer", + "null" + ] + }, + "read": { + "items": { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + "type": [ + "array", + "null" + ] + }, + "write": { + "items": { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + "type": [ + "array", + "null" + ] + } + }, + "type": "object" + }, + "AdditionalNetworkPermissions": { + "properties": { + "enabled": { + "type": [ + "boolean", + "null" + ] + } + }, + "type": "object" + }, "AgentMessageDeltaNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -8041,6 +7804,217 @@ "title": "FileChangePatchUpdatedNotification", "type": "object" }, + "FileSystemAccessMode": { + "enum": [ + "read", + "write", + "none" + ], + "type": "string" + }, + "FileSystemPath": { + "oneOf": [ + { + "properties": { + "path": { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + "type": { + "enum": [ + "path" + ], + "title": "PathFileSystemPathType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "PathFileSystemPath", + "type": "object" + }, + { + "properties": { + "pattern": { + "type": "string" + }, + "type": { + "enum": [ + "glob_pattern" + ], + "title": "GlobPatternFileSystemPathType", + "type": "string" + } + }, + "required": [ + "pattern", + "type" + ], + "title": "GlobPatternFileSystemPath", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "special" + ], + "title": "SpecialFileSystemPathType", + "type": "string" + }, + "value": { + "$ref": "#/definitions/v2/FileSystemSpecialPath" + } + }, + "required": [ + "type", + "value" + ], + "title": "SpecialFileSystemPath", + "type": "object" + } + ] + }, + "FileSystemSandboxEntry": { + "properties": { + "access": { + "$ref": "#/definitions/v2/FileSystemAccessMode" + }, + "path": { + "$ref": "#/definitions/v2/FileSystemPath" + } + }, + "required": [ + "access", + "path" + ], + "type": "object" + }, + "FileSystemSpecialPath": { + "oneOf": [ + { + "properties": { + "kind": { + "enum": [ + "root" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "RootFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "minimal" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "MinimalFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "current_working_directory" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "CurrentWorkingDirectoryFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "project_roots" + ], + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind" + ], + "title": "KindFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "tmpdir" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "TmpdirFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "slash_tmp" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "SlashTmpFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "unknown" + ], + "type": "string" + }, + "path": { + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind", + "path" + ], + "type": "object" + } + ] + }, "FileUpdateChange": { "properties": { "diff": { @@ -8834,6 +8808,32 @@ ], "title": "McpToolCallGuardianApprovalReviewAction", "type": "object" + }, + { + "properties": { + "permissions": { + "$ref": "#/definitions/v2/RequestPermissionProfile" + }, + "reason": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "requestPermissions" + ], + "title": "RequestPermissionsGuardianApprovalReviewActionType", + "type": "string" + } + }, + "required": [ + "permissions", + "type" + ], + "title": "RequestPermissionsGuardianApprovalReviewAction", + "type": "object" } ] }, @@ -11641,6 +11641,32 @@ } ] }, + "RequestPermissionProfile": { + "additionalProperties": false, + "properties": { + "fileSystem": { + "anyOf": [ + { + "$ref": "#/definitions/v2/AdditionalFileSystemPermissions" + }, + { + "type": "null" + } + ] + }, + "network": { + "anyOf": [ + { + "$ref": "#/definitions/v2/AdditionalNetworkPermissions" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, "ResidencyRequirement": { "enum": [ "us" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index db253edfc8..ec729125c4 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -128,6 +128,57 @@ ], "type": "string" }, + "AdditionalFileSystemPermissions": { + "properties": { + "entries": { + "items": { + "$ref": "#/definitions/FileSystemSandboxEntry" + }, + "type": [ + "array", + "null" + ] + }, + "globScanMaxDepth": { + "format": "uint", + "minimum": 1.0, + "type": [ + "integer", + "null" + ] + }, + "read": { + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": [ + "array", + "null" + ] + }, + "write": { + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": [ + "array", + "null" + ] + } + }, + "type": "object" + }, + "AdditionalNetworkPermissions": { + "properties": { + "enabled": { + "type": [ + "boolean", + "null" + ] + } + }, + "type": "object" + }, "AgentMessageDeltaNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -4370,6 +4421,217 @@ "title": "FileChangePatchUpdatedNotification", "type": "object" }, + "FileSystemAccessMode": { + "enum": [ + "read", + "write", + "none" + ], + "type": "string" + }, + "FileSystemPath": { + "oneOf": [ + { + "properties": { + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "path" + ], + "title": "PathFileSystemPathType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "PathFileSystemPath", + "type": "object" + }, + { + "properties": { + "pattern": { + "type": "string" + }, + "type": { + "enum": [ + "glob_pattern" + ], + "title": "GlobPatternFileSystemPathType", + "type": "string" + } + }, + "required": [ + "pattern", + "type" + ], + "title": "GlobPatternFileSystemPath", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "special" + ], + "title": "SpecialFileSystemPathType", + "type": "string" + }, + "value": { + "$ref": "#/definitions/FileSystemSpecialPath" + } + }, + "required": [ + "type", + "value" + ], + "title": "SpecialFileSystemPath", + "type": "object" + } + ] + }, + "FileSystemSandboxEntry": { + "properties": { + "access": { + "$ref": "#/definitions/FileSystemAccessMode" + }, + "path": { + "$ref": "#/definitions/FileSystemPath" + } + }, + "required": [ + "access", + "path" + ], + "type": "object" + }, + "FileSystemSpecialPath": { + "oneOf": [ + { + "properties": { + "kind": { + "enum": [ + "root" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "RootFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "minimal" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "MinimalFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "current_working_directory" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "CurrentWorkingDirectoryFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "project_roots" + ], + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind" + ], + "title": "KindFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "tmpdir" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "TmpdirFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "slash_tmp" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "SlashTmpFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "unknown" + ], + "type": "string" + }, + "path": { + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind", + "path" + ], + "type": "object" + } + ] + }, "FileUpdateChange": { "properties": { "diff": { @@ -5274,6 +5536,32 @@ ], "title": "McpToolCallGuardianApprovalReviewAction", "type": "object" + }, + { + "properties": { + "permissions": { + "$ref": "#/definitions/RequestPermissionProfile" + }, + "reason": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "requestPermissions" + ], + "title": "RequestPermissionsGuardianApprovalReviewActionType", + "type": "string" + } + }, + "required": [ + "permissions", + "type" + ], + "title": "RequestPermissionsGuardianApprovalReviewAction", + "type": "object" } ] }, @@ -8125,6 +8413,32 @@ } ] }, + "RequestPermissionProfile": { + "additionalProperties": false, + "properties": { + "fileSystem": { + "anyOf": [ + { + "$ref": "#/definitions/AdditionalFileSystemPermissions" + }, + { + "type": "null" + } + ] + }, + "network": { + "anyOf": [ + { + "$ref": "#/definitions/AdditionalNetworkPermissions" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, "ResidencyRequirement": { "enum": [ "us" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewCompletedNotification.json index cc8b2a6832..27716e59b0 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewCompletedNotification.json @@ -5,6 +5,57 @@ "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", "type": "string" }, + "AdditionalFileSystemPermissions": { + "properties": { + "entries": { + "items": { + "$ref": "#/definitions/FileSystemSandboxEntry" + }, + "type": [ + "array", + "null" + ] + }, + "globScanMaxDepth": { + "format": "uint", + "minimum": 1.0, + "type": [ + "integer", + "null" + ] + }, + "read": { + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": [ + "array", + "null" + ] + }, + "write": { + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": [ + "array", + "null" + ] + } + }, + "type": "object" + }, + "AdditionalNetworkPermissions": { + "properties": { + "enabled": { + "type": [ + "boolean", + "null" + ] + } + }, + "type": "object" + }, "AutoReviewDecisionSource": { "description": "[UNSTABLE] Source that produced a terminal approval auto-review decision.", "enum": [ @@ -12,6 +63,217 @@ ], "type": "string" }, + "FileSystemAccessMode": { + "enum": [ + "read", + "write", + "none" + ], + "type": "string" + }, + "FileSystemPath": { + "oneOf": [ + { + "properties": { + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "path" + ], + "title": "PathFileSystemPathType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "PathFileSystemPath", + "type": "object" + }, + { + "properties": { + "pattern": { + "type": "string" + }, + "type": { + "enum": [ + "glob_pattern" + ], + "title": "GlobPatternFileSystemPathType", + "type": "string" + } + }, + "required": [ + "pattern", + "type" + ], + "title": "GlobPatternFileSystemPath", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "special" + ], + "title": "SpecialFileSystemPathType", + "type": "string" + }, + "value": { + "$ref": "#/definitions/FileSystemSpecialPath" + } + }, + "required": [ + "type", + "value" + ], + "title": "SpecialFileSystemPath", + "type": "object" + } + ] + }, + "FileSystemSandboxEntry": { + "properties": { + "access": { + "$ref": "#/definitions/FileSystemAccessMode" + }, + "path": { + "$ref": "#/definitions/FileSystemPath" + } + }, + "required": [ + "access", + "path" + ], + "type": "object" + }, + "FileSystemSpecialPath": { + "oneOf": [ + { + "properties": { + "kind": { + "enum": [ + "root" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "RootFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "minimal" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "MinimalFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "current_working_directory" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "CurrentWorkingDirectoryFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "project_roots" + ], + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind" + ], + "title": "KindFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "tmpdir" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "TmpdirFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "slash_tmp" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "SlashTmpFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "unknown" + ], + "type": "string" + }, + "path": { + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind", + "path" + ], + "type": "object" + } + ] + }, "GuardianApprovalReview": { "description": "[UNSTABLE] Temporary approval auto-review payload used by `item/autoApprovalReview/*` notifications. This shape is expected to change soon.", "properties": { @@ -217,6 +479,32 @@ ], "title": "McpToolCallGuardianApprovalReviewAction", "type": "object" + }, + { + "properties": { + "permissions": { + "$ref": "#/definitions/RequestPermissionProfile" + }, + "reason": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "requestPermissions" + ], + "title": "RequestPermissionsGuardianApprovalReviewActionType", + "type": "string" + } + }, + "required": [ + "permissions", + "type" + ], + "title": "RequestPermissionsGuardianApprovalReviewAction", + "type": "object" } ] }, @@ -266,6 +554,32 @@ "socks5Udp" ], "type": "string" + }, + "RequestPermissionProfile": { + "additionalProperties": false, + "properties": { + "fileSystem": { + "anyOf": [ + { + "$ref": "#/definitions/AdditionalFileSystemPermissions" + }, + { + "type": "null" + } + ] + }, + "network": { + "anyOf": [ + { + "$ref": "#/definitions/AdditionalNetworkPermissions" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" } }, "description": "[UNSTABLE] Temporary notification payload for approval auto-review. This shape is expected to change soon.", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewStartedNotification.json index 1cd6b30160..c039f2191a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewStartedNotification.json @@ -5,6 +5,268 @@ "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", "type": "string" }, + "AdditionalFileSystemPermissions": { + "properties": { + "entries": { + "items": { + "$ref": "#/definitions/FileSystemSandboxEntry" + }, + "type": [ + "array", + "null" + ] + }, + "globScanMaxDepth": { + "format": "uint", + "minimum": 1.0, + "type": [ + "integer", + "null" + ] + }, + "read": { + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": [ + "array", + "null" + ] + }, + "write": { + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": [ + "array", + "null" + ] + } + }, + "type": "object" + }, + "AdditionalNetworkPermissions": { + "properties": { + "enabled": { + "type": [ + "boolean", + "null" + ] + } + }, + "type": "object" + }, + "FileSystemAccessMode": { + "enum": [ + "read", + "write", + "none" + ], + "type": "string" + }, + "FileSystemPath": { + "oneOf": [ + { + "properties": { + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "path" + ], + "title": "PathFileSystemPathType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "PathFileSystemPath", + "type": "object" + }, + { + "properties": { + "pattern": { + "type": "string" + }, + "type": { + "enum": [ + "glob_pattern" + ], + "title": "GlobPatternFileSystemPathType", + "type": "string" + } + }, + "required": [ + "pattern", + "type" + ], + "title": "GlobPatternFileSystemPath", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "special" + ], + "title": "SpecialFileSystemPathType", + "type": "string" + }, + "value": { + "$ref": "#/definitions/FileSystemSpecialPath" + } + }, + "required": [ + "type", + "value" + ], + "title": "SpecialFileSystemPath", + "type": "object" + } + ] + }, + "FileSystemSandboxEntry": { + "properties": { + "access": { + "$ref": "#/definitions/FileSystemAccessMode" + }, + "path": { + "$ref": "#/definitions/FileSystemPath" + } + }, + "required": [ + "access", + "path" + ], + "type": "object" + }, + "FileSystemSpecialPath": { + "oneOf": [ + { + "properties": { + "kind": { + "enum": [ + "root" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "RootFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "minimal" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "MinimalFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "current_working_directory" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "CurrentWorkingDirectoryFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "project_roots" + ], + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind" + ], + "title": "KindFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "tmpdir" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "TmpdirFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "slash_tmp" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "SlashTmpFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "unknown" + ], + "type": "string" + }, + "path": { + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind", + "path" + ], + "type": "object" + } + ] + }, "GuardianApprovalReview": { "description": "[UNSTABLE] Temporary approval auto-review payload used by `item/autoApprovalReview/*` notifications. This shape is expected to change soon.", "properties": { @@ -210,6 +472,32 @@ ], "title": "McpToolCallGuardianApprovalReviewAction", "type": "object" + }, + { + "properties": { + "permissions": { + "$ref": "#/definitions/RequestPermissionProfile" + }, + "reason": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "requestPermissions" + ], + "title": "RequestPermissionsGuardianApprovalReviewActionType", + "type": "string" + } + }, + "required": [ + "permissions", + "type" + ], + "title": "RequestPermissionsGuardianApprovalReviewAction", + "type": "object" } ] }, @@ -259,6 +547,32 @@ "socks5Udp" ], "type": "string" + }, + "RequestPermissionProfile": { + "additionalProperties": false, + "properties": { + "fileSystem": { + "anyOf": [ + { + "$ref": "#/definitions/AdditionalFileSystemPermissions" + }, + { + "type": "null" + } + ] + }, + "network": { + "anyOf": [ + { + "$ref": "#/definitions/AdditionalNetworkPermissions" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" } }, "description": "[UNSTABLE] Temporary notification payload for approval auto-review. This shape is expected to change soon.", diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/GuardianApprovalReviewAction.ts b/codex-rs/app-server-protocol/schema/typescript/v2/GuardianApprovalReviewAction.ts index 4bbfe24190..4f00e37d20 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/GuardianApprovalReviewAction.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/GuardianApprovalReviewAction.ts @@ -4,5 +4,6 @@ import type { AbsolutePathBuf } from "../AbsolutePathBuf"; import type { GuardianCommandSource } from "./GuardianCommandSource"; import type { NetworkApprovalProtocol } from "./NetworkApprovalProtocol"; +import type { RequestPermissionProfile } from "./RequestPermissionProfile"; -export type GuardianApprovalReviewAction = { "type": "command", source: GuardianCommandSource, command: string, cwd: AbsolutePathBuf, } | { "type": "execve", source: GuardianCommandSource, program: string, argv: Array, cwd: AbsolutePathBuf, } | { "type": "applyPatch", cwd: AbsolutePathBuf, files: Array, } | { "type": "networkAccess", target: string, host: string, protocol: NetworkApprovalProtocol, port: number, } | { "type": "mcpToolCall", server: string, toolName: string, connectorId: string | null, connectorName: string | null, toolTitle: string | null, }; +export type GuardianApprovalReviewAction = { "type": "command", source: GuardianCommandSource, command: string, cwd: AbsolutePathBuf, } | { "type": "execve", source: GuardianCommandSource, program: string, argv: Array, cwd: AbsolutePathBuf, } | { "type": "applyPatch", cwd: AbsolutePathBuf, files: Array, } | { "type": "networkAccess", target: string, host: string, protocol: NetworkApprovalProtocol, port: number, } | { "type": "mcpToolCall", server: string, toolName: string, connectorId: string | null, connectorName: string | null, toolTitle: string | null, } | { "type": "requestPermissions", reason: string | null, permissions: RequestPermissionProfile, }; diff --git a/codex-rs/app-server-protocol/src/protocol/item_builders.rs b/codex-rs/app-server-protocol/src/protocol/item_builders.rs index f69c414b02..546fb1b679 100644 --- a/codex-rs/app-server-protocol/src/protocol/item_builders.rs +++ b/codex-rs/app-server-protocol/src/protocol/item_builders.rs @@ -199,7 +199,8 @@ pub fn build_item_from_guardian_event( } GuardianAssessmentAction::ApplyPatch { .. } | GuardianAssessmentAction::NetworkAccess { .. } - | GuardianAssessmentAction::McpToolCall { .. } => None, + | GuardianAssessmentAction::McpToolCall { .. } + | GuardianAssessmentAction::RequestPermissions { .. } => None, } } diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 0bdaaa3672..d87e25c253 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -5142,6 +5142,14 @@ pub struct GuardianMcpToolCallReviewAction { pub tool_title: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GuardianRequestPermissionsReviewAction { + pub reason: Option, + pub permissions: RequestPermissionProfile, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(tag = "type", rename_all = "camelCase")] #[ts(tag = "type", rename_all = "camelCase")] @@ -5185,6 +5193,12 @@ pub enum GuardianApprovalReviewAction { connector_name: Option, tool_title: Option, }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + RequestPermissions { + reason: Option, + permissions: RequestPermissionProfile, + }, } impl From for GuardianApprovalReviewAction { @@ -5237,6 +5251,13 @@ impl From for GuardianApprovalReviewAction { connector_name, tool_title, }, + CoreGuardianAssessmentAction::RequestPermissions { + reason, + permissions, + } => Self::RequestPermissions { + reason, + permissions: permissions.into(), + }, } } } @@ -5291,6 +5312,13 @@ impl From for CoreGuardianAssessmentAction { connector_name, tool_title, }, + GuardianApprovalReviewAction::RequestPermissions { + reason, + permissions, + } => Self::RequestPermissions { + reason, + permissions: permissions.into(), + }, } } } diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 5d67223b33..be021fe6aa 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -26,15 +26,14 @@ use codex_protocol::user_input::UserInput; use serde_json::Value; use std::time::Duration; use tokio::sync::Mutex; -use tokio::sync::oneshot; use tokio::time::timeout; use tokio_util::sync::CancellationToken; use crate::config::Config; use crate::guardian::GuardianApprovalRequest; use crate::guardian::new_guardian_review_id; -use crate::guardian::review_approval_request_with_cancel; use crate::guardian::routes_approval_to_guardian; +use crate::guardian::spawn_approval_request_review; use crate::mcp_tool_call::MCP_TOOL_APPROVAL_ACCEPT; use crate::mcp_tool_call::MCP_TOOL_APPROVAL_ACCEPT_FOR_SESSION; use crate::mcp_tool_call::MCP_TOOL_APPROVAL_DECLINE_SYNTHETIC; @@ -457,7 +456,7 @@ async fn handle_exec_approval( } = event; let decision = if routes_approval_to_guardian(parent_ctx) { let review_cancel = cancel_token.child_token(); - let review_rx = spawn_guardian_review( + let review_rx = spawn_approval_request_review( Arc::clone(parent_session), Arc::clone(parent_ctx), new_guardian_review_id(), @@ -565,7 +564,7 @@ async fn handle_patch_approval( }) .collect::>() .join("\n"); - let review_rx = spawn_guardian_review( + let review_rx = spawn_approval_request_review( Arc::clone(parent_session), Arc::clone(parent_ctx), new_guardian_review_id(), @@ -686,7 +685,7 @@ async fn maybe_auto_review_mcp_request_user_input( ) .await; let review_cancel = cancel_token.child_token(); - let review_rx = spawn_guardian_review( + let review_rx = spawn_approval_request_review( Arc::clone(parent_session), Arc::clone(parent_ctx), new_guardian_review_id(), @@ -730,36 +729,6 @@ async fn maybe_auto_review_mcp_request_user_input( }) } -fn spawn_guardian_review( - session: Arc, - turn: Arc, - review_id: String, - request: GuardianApprovalRequest, - retry_reason: Option, - cancel_token: CancellationToken, -) -> oneshot::Receiver { - let (tx, rx) = oneshot::channel(); - std::thread::spawn(move || { - let Ok(runtime) = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - else { - let _ = tx.send(ReviewDecision::Denied); - return; - }; - let decision = runtime.block_on(review_approval_request_with_cancel( - &session, - &turn, - review_id, - request, - retry_reason, - cancel_token, - )); - let _ = tx.send(decision); - }); - rx -} - async fn handle_request_permissions( codex: &Codex, parent_session: &Arc, @@ -772,7 +741,8 @@ async fn handle_request_permissions( reason: event.reason, permissions: event.permissions, }; - let response_fut = parent_session.request_permissions(parent_ctx, call_id.clone(), args); + let response_fut = + parent_session.request_permissions(parent_ctx, call_id.clone(), args, cancel_token.clone()); let response = await_request_permissions_with_cancel(response_fut, parent_session, &call_id, cancel_token) .await; diff --git a/codex-rs/core/src/guardian/approval_request.rs b/codex-rs/core/src/guardian/approval_request.rs index 6d1d3f76af..7478377381 100644 --- a/codex-rs/core/src/guardian/approval_request.rs +++ b/codex-rs/core/src/guardian/approval_request.rs @@ -4,6 +4,7 @@ use codex_protocol::approvals::GuardianAssessmentAction; use codex_protocol::approvals::GuardianCommandSource; use codex_protocol::approvals::NetworkApprovalProtocol; use codex_protocol::models::PermissionProfile; +use codex_protocol::request_permissions::RequestPermissionProfile; use codex_utils_absolute_path::AbsolutePathBuf; use serde::Serialize; use serde_json::Value; @@ -65,6 +66,12 @@ pub(crate) enum GuardianApprovalRequest { tool_description: Option, annotations: Option, }, + RequestPermissions { + id: String, + turn_id: String, + reason: Option, + permissions: RequestPermissionProfile, + }, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] @@ -123,6 +130,15 @@ struct McpToolCallApprovalAction<'a> { annotations: Option<&'a GuardianMcpAnnotations>, } +#[derive(Serialize)] +struct RequestPermissionsApprovalAction<'a> { + tool: &'static str, + turn_id: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + reason: Option<&'a String>, + permissions: &'a RequestPermissionProfile, +} + fn serialize_guardian_action(value: impl Serialize) -> serde_json::Result { serde_json::to_value(value) } @@ -293,6 +309,17 @@ pub(crate) fn guardian_approval_request_to_json( tool_description: tool_description.as_ref(), annotations: annotations.as_ref(), }), + GuardianApprovalRequest::RequestPermissions { + id: _, + turn_id, + reason, + permissions, + } => serialize_guardian_action(RequestPermissionsApprovalAction { + tool: "request_permissions", + turn_id, + reason: reason.as_ref(), + permissions, + }), } } @@ -352,6 +379,14 @@ pub(crate) fn guardian_assessment_action( connector_name: connector_name.clone(), tool_title: tool_title.clone(), }, + GuardianApprovalRequest::RequestPermissions { + reason, + permissions, + .. + } => GuardianAssessmentAction::RequestPermissions { + reason: reason.clone(), + permissions: permissions.clone(), + }, } } @@ -360,7 +395,8 @@ pub(crate) fn guardian_request_target_item_id(request: &GuardianApprovalRequest) GuardianApprovalRequest::Shell { id, .. } | GuardianApprovalRequest::ExecCommand { id, .. } | GuardianApprovalRequest::ApplyPatch { id, .. } - | GuardianApprovalRequest::McpToolCall { id, .. } => Some(id), + | GuardianApprovalRequest::McpToolCall { id, .. } + | GuardianApprovalRequest::RequestPermissions { id, .. } => Some(id), GuardianApprovalRequest::NetworkAccess { .. } => None, #[cfg(unix)] GuardianApprovalRequest::Execve { id, .. } => Some(id), @@ -372,7 +408,8 @@ pub(crate) fn guardian_request_turn_id<'a>( default_turn_id: &'a str, ) -> &'a str { match request { - GuardianApprovalRequest::NetworkAccess { turn_id, .. } => turn_id, + GuardianApprovalRequest::NetworkAccess { turn_id, .. } + | GuardianApprovalRequest::RequestPermissions { turn_id, .. } => turn_id, GuardianApprovalRequest::Shell { .. } | GuardianApprovalRequest::ExecCommand { .. } | GuardianApprovalRequest::ApplyPatch { .. } diff --git a/codex-rs/core/src/guardian/mod.rs b/codex-rs/core/src/guardian/mod.rs index f31f21344c..db0b7eec38 100644 --- a/codex-rs/core/src/guardian/mod.rs +++ b/codex-rs/core/src/guardian/mod.rs @@ -31,8 +31,10 @@ pub(crate) use review::guardian_timeout_message; pub(crate) use review::is_guardian_reviewer_source; pub(crate) use review::new_guardian_review_id; pub(crate) use review::review_approval_request; +#[cfg(test)] pub(crate) use review::review_approval_request_with_cancel; pub(crate) use review::routes_approval_to_guardian; +pub(crate) use review::spawn_approval_request_review; pub(crate) use review_session::GuardianReviewSessionManager; const GUARDIAN_PREFERRED_MODEL: &str = "codex-auto-review"; diff --git a/codex-rs/core/src/guardian/review.rs b/codex-rs/core/src/guardian/review.rs index ea52114a09..486c079f31 100644 --- a/codex-rs/core/src/guardian/review.rs +++ b/codex-rs/core/src/guardian/review.rs @@ -11,6 +11,7 @@ use codex_protocol::protocol::GuardianUserAuthorization; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::WarningEvent; +use tokio::sync::oneshot; use tokio_util::sync::CancellationToken; use crate::session::session::Session; @@ -89,12 +90,14 @@ fn guardian_risk_level_str(level: GuardianRiskLevel) -> &'static str { } } -/// Whether this turn should route `on-request` approval prompts through the -/// guardian reviewer instead of surfacing them to the user. ARC may still -/// block actions earlier in the flow. +/// Whether this turn should route allowed approval prompts through the guardian +/// reviewer instead of surfacing them to the user. ARC may still block actions +/// earlier in the flow. pub(crate) fn routes_approval_to_guardian(turn: &TurnContext) -> bool { - turn.approval_policy.value() == AskForApproval::OnRequest - && turn.config.approvals_reviewer == ApprovalsReviewer::GuardianSubagent + matches!( + turn.approval_policy.value(), + AskForApproval::OnRequest | AskForApproval::Granular(_) + ) && turn.config.approvals_reviewer == ApprovalsReviewer::GuardianSubagent } pub(crate) fn is_guardian_reviewer_source( @@ -335,6 +338,36 @@ pub(crate) async fn review_approval_request_with_cancel( .await } +pub(crate) fn spawn_approval_request_review( + session: Arc, + turn: Arc, + review_id: String, + request: GuardianApprovalRequest, + retry_reason: Option, + cancel_token: CancellationToken, +) -> oneshot::Receiver { + let (tx, rx) = oneshot::channel(); + std::thread::spawn(move || { + let Ok(runtime) = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + else { + let _ = tx.send(ReviewDecision::Denied); + return; + }; + let decision = runtime.block_on(review_approval_request_with_cancel( + &session, + &turn, + review_id, + request, + retry_reason, + cancel_token, + )); + let _ = tx.send(decision); + }); + rx +} + /// Runs the guardian in a locked-down reusable review session. /// /// The guardian itself should not mutate state or trigger further approvals, so diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index 6f6d23eb41..d1ec5ae889 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -28,6 +28,7 @@ use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::GranularApprovalConfig; use codex_protocol::protocol::GuardianAssessmentStatus; use codex_protocol::protocol::GuardianRiskLevel; use codex_protocol::protocol::GuardianUserAuthorization; @@ -741,7 +742,7 @@ fn guardian_timeout_message_distinguishes_timeout_from_policy_denial() { } #[tokio::test] -async fn routes_approval_to_guardian_requires_auto_only_review_policy() { +async fn routes_approval_to_guardian_requires_guardian_reviewer() { let (_session, mut turn) = crate::session::tests::make_session_and_context().await; let mut config = (*turn.config).clone(); config.approvals_reviewer = ApprovalsReviewer::User; @@ -755,6 +756,25 @@ async fn routes_approval_to_guardian_requires_auto_only_review_policy() { assert!(routes_approval_to_guardian(&turn)); } +#[tokio::test] +async fn routes_approval_to_guardian_allows_granular_review_policy() { + let (_session, mut turn) = crate::session::tests::make_session_and_context().await; + let mut config = (*turn.config).clone(); + config.approvals_reviewer = ApprovalsReviewer::GuardianSubagent; + turn.config = Arc::new(config); + turn.approval_policy + .set(AskForApproval::Granular(GranularApprovalConfig { + sandbox_approval: true, + rules: true, + skill_approval: true, + request_permissions: true, + mcp_elicitations: true, + })) + .expect("test setup should allow updating approval policy"); + + assert!(routes_approval_to_guardian(&turn)); +} + #[test] fn build_guardian_transcript_reserves_separate_budget_for_tool_evidence() { let repeated = "signal ".repeat(8_000); diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index f159546575..f1fea3517a 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -1894,12 +1894,13 @@ impl Session { } pub async fn request_permissions( - &self, - turn_context: &TurnContext, + self: &Arc, + turn_context: &Arc, call_id: String, args: RequestPermissionsArgs, + cancellation_token: CancellationToken, ) -> Option { - match turn_context.approval_policy.value() { + match turn_context.as_ref().approval_policy.value() { AskForApproval::Never => { return Some(RequestPermissionsResponse { permissions: RequestPermissionProfile::default(), @@ -1920,6 +1921,72 @@ impl Session { | AskForApproval::Granular(_) => {} } + if crate::guardian::routes_approval_to_guardian(turn_context.as_ref()) { + let requested_permissions = args.permissions; + let originating_turn_state = { + let active = self.active_turn.lock().await; + active.as_ref().map(|active| Arc::clone(&active.turn_state)) + }; + let review_id = crate::guardian::new_guardian_review_id(); + let session = Arc::clone(self); + let turn = Arc::clone(turn_context); + let request = crate::guardian::GuardianApprovalRequest::RequestPermissions { + id: call_id, + turn_id: turn_context.sub_id.clone(), + reason: args.reason, + permissions: requested_permissions.clone(), + }; + let review_rx = crate::guardian::spawn_approval_request_review( + session, + turn, + review_id, + request, + /*retry_reason*/ None, + cancellation_token.clone(), + ); + let decision = tokio::select! { + biased; + _ = cancellation_token.cancelled() => return None, + decision = review_rx => decision.unwrap_or(ReviewDecision::Denied), + }; + let response = match decision { + ReviewDecision::Approved | ReviewDecision::ApprovedExecpolicyAmendment { .. } => { + RequestPermissionsResponse { + permissions: requested_permissions, + scope: PermissionGrantScope::Turn, + } + } + ReviewDecision::ApprovedForSession => RequestPermissionsResponse { + permissions: requested_permissions, + scope: PermissionGrantScope::Session, + }, + ReviewDecision::NetworkPolicyAmendment { + network_policy_amendment, + } => match network_policy_amendment.action { + NetworkPolicyRuleAction::Allow => RequestPermissionsResponse { + permissions: requested_permissions, + scope: PermissionGrantScope::Turn, + }, + NetworkPolicyRuleAction::Deny => RequestPermissionsResponse { + permissions: RequestPermissionProfile::default(), + scope: PermissionGrantScope::Turn, + }, + }, + ReviewDecision::Abort | ReviewDecision::Denied | ReviewDecision::TimedOut => { + RequestPermissionsResponse { + permissions: RequestPermissionProfile::default(), + scope: PermissionGrantScope::Turn, + } + } + }; + self.record_granted_request_permissions_for_turn( + &response, + originating_turn_state.as_ref(), + ) + .await; + return Some(response); + } + let (tx_response, rx_response) = oneshot::channel(); let prev_entry = { let mut active = self.active_turn.lock().await; @@ -1935,17 +2002,25 @@ impl Session { warn!("Overwriting existing pending request_permissions for call_id: {call_id}"); } - // TODO(ccunningham): Support auto-review for request_permissions / - // with_additional_permissions. V0 still routes this surface through - // the existing manual RequestPermissions event flow. let event = EventMsg::RequestPermissions(RequestPermissionsEvent { - call_id, + call_id: call_id.clone(), turn_id: turn_context.sub_id.clone(), reason: args.reason, permissions: args.permissions, }); - self.send_event(turn_context, event).await; - rx_response.await.ok() + self.send_event(turn_context.as_ref(), event).await; + tokio::select! { + biased; + _ = cancellation_token.cancelled() => { + let mut active = self.active_turn.lock().await; + if let Some(at) = active.as_mut() { + let mut ts = at.turn_state.lock().await; + let _ = ts.remove_pending_request_permissions(&call_id); + } + None + } + response = rx_response => response.ok(), + } } pub async fn request_user_input( @@ -2010,31 +2085,24 @@ impl Session { call_id: &str, response: RequestPermissionsResponse, ) { - let mut granted_for_session = None; - let entry = { + let (entry, originating_turn_state) = { let mut active = self.active_turn.lock().await; match active.as_mut() { Some(at) => { let mut ts = at.turn_state.lock().await; let entry = ts.remove_pending_request_permissions(call_id); - if entry.is_some() && !response.permissions.is_empty() { - match response.scope { - PermissionGrantScope::Turn => { - ts.record_granted_permissions(response.permissions.clone().into()); - } - PermissionGrantScope::Session => { - granted_for_session = Some(response.permissions.clone()); - } - } - } - entry + let originating_turn_state = entry.as_ref().map(|_| Arc::clone(&at.turn_state)); + (entry, originating_turn_state) } - None => None, + None => (None, None), } }; - if let Some(permissions) = granted_for_session { - let mut state = self.state.lock().await; - state.record_granted_permissions(permissions.into()); + if entry.is_some() { + self.record_granted_request_permissions_for_turn( + &response, + originating_turn_state.as_ref(), + ) + .await; } match entry { Some(tx_response) => { @@ -2046,6 +2114,28 @@ impl Session { } } + async fn record_granted_request_permissions_for_turn( + &self, + response: &RequestPermissionsResponse, + originating_turn_state: Option<&Arc>>, + ) { + if response.permissions.is_empty() { + return; + } + match response.scope { + PermissionGrantScope::Turn => { + if let Some(turn_state) = originating_turn_state { + let mut ts = turn_state.lock().await; + ts.record_granted_permissions(response.permissions.clone().into()); + } + } + PermissionGrantScope::Session => { + let mut state = self.state.lock().await; + state.record_granted_permissions(response.permissions.clone().into()); + } + } + } + pub(crate) async fn granted_turn_permissions(&self) -> Option { let active = self.active_turn.lock().await; let active = active.as_ref()?; diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index bd41da9f20..f901e5bee4 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -3438,6 +3438,41 @@ async fn notify_request_permissions_response_ignores_unmatched_call_id() { assert_eq!(session.granted_turn_permissions().await, None); } +#[tokio::test] +async fn record_granted_request_permissions_for_turn_uses_originating_turn() { + let (session, _turn_context) = make_session_and_context().await; + let originating_active_turn = ActiveTurn::default(); + let originating_turn_state = Arc::clone(&originating_active_turn.turn_state); + *session.active_turn.lock().await = Some(originating_active_turn); + + let current_active_turn = ActiveTurn::default(); + let current_turn_state = Arc::clone(¤t_active_turn.turn_state); + *session.active_turn.lock().await = Some(current_active_turn); + + let requested_permissions = RequestPermissionProfile { + network: Some(codex_protocol::models::NetworkPermissions { + enabled: Some(true), + }), + ..RequestPermissionProfile::default() + }; + session + .record_granted_request_permissions_for_turn( + &codex_protocol::request_permissions::RequestPermissionsResponse { + permissions: requested_permissions.clone(), + scope: PermissionGrantScope::Turn, + }, + Some(&originating_turn_state), + ) + .await; + + assert_eq!( + originating_turn_state.lock().await.granted_permissions(), + Some(requested_permissions.into()) + ); + assert_eq!(current_turn_state.lock().await.granted_permissions(), None); + assert_eq!(session.granted_turn_permissions().await, None); +} + #[tokio::test] async fn request_permissions_emits_event_when_granular_policy_allows_requests() { let (session, mut turn_context, rx) = make_session_and_context_with_rx().await; @@ -3474,7 +3509,7 @@ async fn request_permissions_emits_event_when_granular_policy_allows_requests() async move { session .request_permissions( - turn_context.as_ref(), + &turn_context, call_id, codex_protocol::request_permissions::RequestPermissionsArgs { reason: Some("need network".to_string()), @@ -3485,6 +3520,7 @@ async fn request_permissions_emits_event_when_granular_policy_allows_requests() ..RequestPermissionProfile::default() }, }, + CancellationToken::new(), ) .await } @@ -3532,7 +3568,7 @@ async fn request_permissions_is_auto_denied_when_granular_policy_blocks_tool_req let call_id = "call-1".to_string(); let response = session .request_permissions( - turn_context.as_ref(), + &turn_context, call_id, codex_protocol::request_permissions::RequestPermissionsArgs { reason: Some("need network".to_string()), @@ -3543,6 +3579,7 @@ async fn request_permissions_is_auto_denied_when_granular_policy_blocks_tool_req ..RequestPermissionProfile::default() }, }, + CancellationToken::new(), ) .await; @@ -6360,6 +6397,7 @@ async fn fatal_tool_error_stops_turn_and_reports_error() { .dispatch_tool_call_with_code_mode_result( Arc::clone(&session), Arc::clone(&turn_context), + CancellationToken::new(), tracker, call, ToolCallSource::Direct, @@ -6602,6 +6640,7 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() { .handle(ToolInvocation { session: Arc::clone(&session), turn: Arc::clone(&turn_context), + cancellation_token: CancellationToken::new(), tracker: Arc::clone(&turn_diff_tracker), call_id, tool_name: codex_tools::ToolName::plain(tool_name), @@ -6680,6 +6719,7 @@ async fn unified_exec_rejects_escalated_permissions_when_policy_not_on_request() .handle(ToolInvocation { session: Arc::clone(&session), turn: Arc::clone(&turn_context), + cancellation_token: CancellationToken::new(), tracker: Arc::clone(&tracker), call_id: "exec-call".to_string(), tool_name: codex_tools::ToolName::plain("exec_command"), diff --git a/codex-rs/core/src/session/tests/guardian_tests.rs b/codex-rs/core/src/session/tests/guardian_tests.rs index 9ccd6eb4e0..108d095756 100644 --- a/codex-rs/core/src/session/tests/guardian_tests.rs +++ b/codex-rs/core/src/session/tests/guardian_tests.rs @@ -17,6 +17,7 @@ use codex_execpolicy::Evaluation; use codex_execpolicy::RuleMatch; use codex_features::Feature; use codex_model_provider::create_model_provider; +use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::models::ContentItem; use codex_protocol::models::NetworkPermissions; use codex_protocol::models::PermissionProfile; @@ -25,26 +26,214 @@ use codex_protocol::models::function_call_output_content_items_to_text; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::AskForApproval; +use codex_protocol::request_permissions::PermissionGrantScope; +use codex_protocol::request_permissions::RequestPermissionProfile; +use codex_protocol::request_permissions::RequestPermissionsArgs; +use codex_protocol::request_permissions::RequestPermissionsResponse; use core_test_support::PathExt; use core_test_support::TempDirExt; use core_test_support::codex_linux_sandbox_exe_or_skip; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_response_once; use core_test_support::responses::mount_sse_once; use core_test_support::responses::sse; +use core_test_support::responses::sse_response; use core_test_support::responses::start_mock_server; use pretty_assertions::assert_eq; use serde::Deserialize; use std::collections::HashMap; use std::fs; use std::sync::Arc; +use std::time::Duration; use tempfile::tempdir; +use tokio_util::sync::CancellationToken; fn expect_text_output(output: &FunctionToolOutput) -> String { function_call_output_content_items_to_text(&output.body).unwrap_or_default() } +#[tokio::test] +async fn request_permissions_routes_to_guardian_when_reviewer_is_enabled() { + let server = start_mock_server().await; + let guardian_request_log = mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-guardian"), + ev_assistant_message( + "msg-guardian", + &serde_json::json!({ + "risk_level": "low", + "user_authorization": "high", + "outcome": "allow", + "rationale": "The request grants narrowly scoped network access for this turn.", + }) + .to_string(), + ), + ev_completed("resp-guardian"), + ]), + ) + .await; + + let (mut session, mut turn_context_raw) = make_session_and_context().await; + *session.active_turn.lock().await = Some(ActiveTurn::default()); + turn_context_raw + .approval_policy + .set(AskForApproval::OnRequest) + .expect("test setup should allow updating approval policy"); + turn_context_raw + .features + .enable(Feature::GuardianApproval) + .expect("test setup should allow enabling guardian approvals"); + let mut config = (*turn_context_raw.config).clone(); + config.approvals_reviewer = ApprovalsReviewer::GuardianSubagent; + config.model_provider.base_url = Some(format!("{}/v1", server.uri())); + let config = Arc::new(config); + let models_manager = Arc::new(crate::test_support::models_manager_with_provider( + config.codex_home.to_path_buf(), + Arc::clone(&session.services.auth_manager), + config.model_provider.clone(), + )); + session.services.models_manager = models_manager; + turn_context_raw.config = Arc::clone(&config); + turn_context_raw.provider = create_model_provider( + config.model_provider.clone(), + turn_context_raw.auth_manager.clone(), + ); + let session = Arc::new(session); + let turn_context = Arc::new(turn_context_raw); + + let requested_permissions = RequestPermissionProfile { + network: Some(NetworkPermissions { + enabled: Some(true), + }), + ..RequestPermissionProfile::default() + }; + let response = tokio::time::timeout( + Duration::from_secs(45), + session.request_permissions( + &turn_context, + "perm-call-1".to_string(), + RequestPermissionsArgs { + reason: Some("need network".to_string()), + permissions: requested_permissions.clone(), + }, + CancellationToken::new(), + ), + ) + .await + .expect("request_permissions should not wait for a client approval"); + + assert_eq!( + response, + Some(RequestPermissionsResponse { + permissions: requested_permissions.clone(), + scope: PermissionGrantScope::Turn, + }) + ); + assert_eq!( + session.granted_turn_permissions().await, + Some(requested_permissions.into()) + ); + + let guardian_request = guardian_request_log.single_request(); + assert_eq!(guardian_request.path(), "/v1/responses"); + assert!(guardian_request.body_contains_text("request_permissions")); + assert!(guardian_request.body_contains_text("need network")); +} + +#[tokio::test] +async fn request_permissions_guardian_review_stops_when_cancelled() { + let server = start_mock_server().await; + let _guardian_request_log = mount_response_once( + &server, + sse_response(sse(vec![ev_response_created("resp-guardian-delayed")])) + .set_delay(Duration::from_secs(60)), + ) + .await; + + let (mut session, mut turn_context, rx_event) = make_session_and_context_with_rx().await; + *session.active_turn.lock().await = Some(ActiveTurn::default()); + let turn_context_raw = Arc::get_mut(&mut turn_context).expect("single turn context ref"); + turn_context_raw + .approval_policy + .set(AskForApproval::OnRequest) + .expect("test setup should allow updating approval policy"); + turn_context_raw + .features + .enable(Feature::GuardianApproval) + .expect("test setup should allow enabling guardian approvals"); + let mut config = (*turn_context_raw.config).clone(); + config.approvals_reviewer = ApprovalsReviewer::GuardianSubagent; + config.model_provider.base_url = Some(format!("{}/v1", server.uri())); + let config = Arc::new(config); + let models_manager = Arc::new(crate::test_support::models_manager_with_provider( + config.codex_home.to_path_buf(), + Arc::clone(&session.services.auth_manager), + config.model_provider.clone(), + )); + Arc::get_mut(&mut session) + .expect("single session ref") + .services + .models_manager = models_manager; + turn_context_raw.config = Arc::clone(&config); + turn_context_raw.provider = create_model_provider( + config.model_provider.clone(), + turn_context_raw.auth_manager.clone(), + ); + + let requested_permissions = RequestPermissionProfile { + network: Some(NetworkPermissions { + enabled: Some(true), + }), + ..RequestPermissionProfile::default() + }; + let cancellation_token = CancellationToken::new(); + let request_handle = tokio::spawn({ + let session = Arc::clone(&session); + let turn_context = Arc::clone(&turn_context); + let requested_permissions = requested_permissions.clone(); + let cancellation_token = cancellation_token.clone(); + async move { + session + .request_permissions( + &turn_context, + "perm-call-cancelled".to_string(), + RequestPermissionsArgs { + reason: Some("need network".to_string()), + permissions: requested_permissions, + }, + cancellation_token, + ) + .await + } + }); + + timeout(Duration::from_secs(5), async { + loop { + let event = rx_event.recv().await.expect("event channel should be open"); + if matches!( + event.msg, + codex_protocol::protocol::EventMsg::GuardianAssessment(_) + ) { + break; + } + } + }) + .await + .expect("guardian review should start before cancellation"); + + cancellation_token.cancel(); + + let response = timeout(Duration::from_secs(5), request_handle) + .await + .expect("request_permissions should stop when cancelled") + .expect("request_permissions task should not panic"); + assert_eq!(response, None); + assert_eq!(session.granted_turn_permissions().await, None); +} + #[tokio::test] async fn guardian_allows_shell_additional_permissions_requests_past_policy_validation() { let server = start_mock_server().await; @@ -146,6 +335,7 @@ async fn guardian_allows_shell_additional_permissions_requests_past_policy_valid .handle(ToolInvocation { session: Arc::clone(&session), turn: Arc::clone(&turn_context), + cancellation_token: CancellationToken::new(), tracker: Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())), call_id: "test-call".to_string(), tool_name: codex_tools::ToolName::plain("shell"), @@ -212,6 +402,7 @@ async fn guardian_allows_unified_exec_additional_permissions_requests_past_polic .handle(ToolInvocation { session: Arc::clone(&session), turn: Arc::clone(&turn_context), + cancellation_token: CancellationToken::new(), tracker: Arc::clone(&tracker), call_id: "exec-call".to_string(), tool_name: codex_tools::ToolName::plain("exec_command"), @@ -325,6 +516,7 @@ async fn shell_handler_allows_sticky_turn_permissions_without_inline_request_per .handle(ToolInvocation { session: Arc::clone(&session), turn: Arc::clone(&turn_context), + cancellation_token: CancellationToken::new(), tracker: Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())), call_id: "sticky-turn-grant".to_string(), tool_name: codex_tools::ToolName::plain("shell"), diff --git a/codex-rs/core/src/tools/context.rs b/codex-rs/core/src/tools/context.rs index 4e144b5507..102eb49a99 100644 --- a/codex-rs/core/src/tools/context.rs +++ b/codex-rs/core/src/tools/context.rs @@ -26,6 +26,7 @@ use std::borrow::Cow; use std::sync::Arc; use std::time::Duration; use tokio::sync::Mutex; +use tokio_util::sync::CancellationToken; pub type SharedTurnDiffTracker = Arc>; @@ -40,6 +41,7 @@ pub enum ToolCallSource { pub struct ToolInvocation { pub session: Arc, pub turn: Arc, + pub cancellation_token: CancellationToken, pub tracker: SharedTurnDiffTracker, pub call_id: String, pub tool_name: ToolName, diff --git a/codex-rs/core/src/tools/handlers/js_repl.rs b/codex-rs/core/src/tools/handlers/js_repl.rs index 64e6ea29fe..906e1bb637 100644 --- a/codex-rs/core/src/tools/handlers/js_repl.rs +++ b/codex-rs/core/src/tools/handlers/js_repl.rs @@ -109,6 +109,7 @@ impl ToolHandler for JsReplHandler { let ToolInvocation { session, turn, + cancellation_token, tracker, payload, call_id, @@ -134,7 +135,13 @@ impl ToolHandler for JsReplHandler { let started_at = Instant::now(); emit_js_repl_exec_begin(session.as_ref(), turn.as_ref(), &call_id).await; let result = manager - .execute(Arc::clone(&session), Arc::clone(&turn), tracker, args) + .execute_with_cancellation( + Arc::clone(&session), + Arc::clone(&turn), + cancellation_token, + tracker, + args, + ) .await; let result = match result { Ok(result) => result, diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 3a525e69eb..c46e98bce2 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -68,6 +68,7 @@ fn invocation( ToolInvocation { session, turn, + cancellation_token: CancellationToken::new(), tracker: Arc::new(Mutex::new(TurnDiffTracker::default())), call_id: "call-1".to_string(), tool_name: codex_tools::ToolName::plain(tool_name), diff --git a/codex-rs/core/src/tools/handlers/request_permissions.rs b/codex-rs/core/src/tools/handlers/request_permissions.rs index 440bb18ca1..56facee658 100644 --- a/codex-rs/core/src/tools/handlers/request_permissions.rs +++ b/codex-rs/core/src/tools/handlers/request_permissions.rs @@ -22,6 +22,7 @@ impl ToolHandler for RequestPermissionsHandler { let ToolInvocation { session, turn, + cancellation_token, call_id, payload, .. @@ -48,7 +49,7 @@ impl ToolHandler for RequestPermissionsHandler { } let response = session - .request_permissions(turn.as_ref(), call_id, args) + .request_permissions(&turn, call_id, args, cancellation_token) .await .ok_or_else(|| { FunctionCallError::RespondToModel( diff --git a/codex-rs/core/src/tools/handlers/request_user_input_tests.rs b/codex-rs/core/src/tools/handlers/request_user_input_tests.rs index 1f9db34b26..e0a0907668 100644 --- a/codex-rs/core/src/tools/handlers/request_user_input_tests.rs +++ b/codex-rs/core/src/tools/handlers/request_user_input_tests.rs @@ -27,6 +27,7 @@ async fn multi_agent_v2_request_user_input_rejects_subagent_threads() { .handle(ToolInvocation { session: Arc::new(session), turn: Arc::new(turn), + cancellation_token: tokio_util::sync::CancellationToken::new(), tracker: Arc::new(Mutex::new(TurnDiffTracker::default())), call_id: "call-1".to_string(), tool_name: codex_tools::ToolName::plain(REQUEST_USER_INPUT_TOOL_NAME), diff --git a/codex-rs/core/src/tools/handlers/shell_tests.rs b/codex-rs/core/src/tools/handlers/shell_tests.rs index 652b305688..1cb79affb4 100644 --- a/codex-rs/core/src/tools/handlers/shell_tests.rs +++ b/codex-rs/core/src/tools/handlers/shell_tests.rs @@ -225,6 +225,7 @@ async fn shell_pre_tool_use_payload_uses_joined_command() { handler.pre_tool_use_payload(&ToolInvocation { session: session.into(), turn: turn.into(), + cancellation_token: tokio_util::sync::CancellationToken::new(), tracker: Arc::new(Mutex::new(TurnDiffTracker::new())), call_id: "call-41".to_string(), tool_name: codex_tools::ToolName::plain("shell"), @@ -250,6 +251,7 @@ async fn shell_command_pre_tool_use_payload_uses_raw_command() { handler.pre_tool_use_payload(&ToolInvocation { session: session.into(), turn: turn.into(), + cancellation_token: tokio_util::sync::CancellationToken::new(), tracker: Arc::new(Mutex::new(TurnDiffTracker::new())), call_id: "call-42".to_string(), tool_name: codex_tools::ToolName::plain("shell_command"), diff --git a/codex-rs/core/src/tools/handlers/unified_exec_tests.rs b/codex-rs/core/src/tools/handlers/unified_exec_tests.rs index da3308771c..988d224838 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec_tests.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec_tests.rs @@ -210,6 +210,7 @@ async fn exec_command_pre_tool_use_payload_uses_raw_command() { handler.pre_tool_use_payload(&ToolInvocation { session: session.into(), turn: turn.into(), + cancellation_token: tokio_util::sync::CancellationToken::new(), tracker: Arc::new(Mutex::new(TurnDiffTracker::new())), call_id: "call-43".to_string(), tool_name: codex_tools::ToolName::plain("exec_command"), @@ -233,6 +234,7 @@ async fn exec_command_pre_tool_use_payload_skips_write_stdin() { handler.pre_tool_use_payload(&ToolInvocation { session: session.into(), turn: turn.into(), + cancellation_token: tokio_util::sync::CancellationToken::new(), tracker: Arc::new(Mutex::new(TurnDiffTracker::new())), call_id: "call-44".to_string(), tool_name: codex_tools::ToolName::plain("write_stdin"), diff --git a/codex-rs/core/src/tools/js_repl/mod.rs b/codex-rs/core/src/tools/js_repl/mod.rs index 23f4906e5f..539c773852 100644 --- a/codex-rs/core/src/tools/js_repl/mod.rs +++ b/codex-rs/core/src/tools/js_repl/mod.rs @@ -132,6 +132,7 @@ struct KernelState { struct ExecContext { session: Arc, turn: Arc, + cancellation_token: CancellationToken, tracker: SharedTurnDiffTracker, } @@ -836,12 +837,25 @@ impl JsReplManager { } } + #[cfg(test)] pub async fn execute( &self, session: Arc, turn: Arc, tracker: SharedTurnDiffTracker, args: JsReplArgs, + ) -> Result { + self.execute_with_cancellation(session, turn, CancellationToken::new(), tracker, args) + .await + } + + pub async fn execute_with_cancellation( + &self, + session: Arc, + turn: Arc, + cancellation_token: CancellationToken, + tracker: SharedTurnDiffTracker, + args: JsReplArgs, ) -> Result { let _permit = self.exec_lock.clone().acquire_owned().await.map_err(|_| { FunctionCallError::RespondToModel("js_repl execution unavailable".to_string()) @@ -892,6 +906,7 @@ impl JsReplManager { ExecContext { session: Arc::clone(&session), turn: Arc::clone(&turn), + cancellation_token, tracker, }, ); @@ -1640,12 +1655,14 @@ impl JsReplManager { let session = Arc::clone(&exec.session); let turn = Arc::clone(&exec.turn); + let cancellation_token = exec.cancellation_token.clone(); let tracker = Arc::clone(&exec.tracker); match router .dispatch_tool_call_with_code_mode_result( session, turn, + cancellation_token, tracker, call, crate::tools::router::ToolCallSource::JsRepl, diff --git a/codex-rs/core/src/tools/parallel.rs b/codex-rs/core/src/tools/parallel.rs index 3852d24d5e..06cf7e5eed 100644 --- a/codex-rs/core/src/tools/parallel.rs +++ b/codex-rs/core/src/tools/parallel.rs @@ -92,6 +92,7 @@ impl ToolCallRuntime { let turn = Arc::clone(&self.turn_context); let tracker = Arc::clone(&self.tracker); let lock = Arc::clone(&self.parallel_execution); + let invocation_cancellation_token = cancellation_token.clone(); let started = Instant::now(); let display_name = call.tool_name.display(); @@ -122,6 +123,7 @@ impl ToolCallRuntime { .dispatch_tool_call_with_code_mode_result( session, turn, + invocation_cancellation_token, tracker, call.clone(), source, diff --git a/codex-rs/core/src/tools/router.rs b/codex-rs/core/src/tools/router.rs index fa2633bc31..c3b4b86d36 100644 --- a/codex-rs/core/src/tools/router.rs +++ b/codex-rs/core/src/tools/router.rs @@ -24,6 +24,7 @@ use codex_tools::ToolsConfig; use std::collections::HashMap; use std::collections::HashSet; use std::sync::Arc; +use tokio_util::sync::CancellationToken; use tracing::instrument; pub use crate::tools::context::ToolCallSource; @@ -267,6 +268,7 @@ impl ToolRouter { &self, session: Arc, turn: Arc, + cancellation_token: CancellationToken, tracker: SharedTurnDiffTracker, call: ToolCall, source: ToolCallSource, @@ -292,6 +294,7 @@ impl ToolRouter { let invocation = ToolInvocation { session, turn, + cancellation_token, tracker, call_id, tool_name, diff --git a/codex-rs/core/src/tools/router_tests.rs b/codex-rs/core/src/tools/router_tests.rs index 1a59f1e1ba..e466bdb1c7 100644 --- a/codex-rs/core/src/tools/router_tests.rs +++ b/codex-rs/core/src/tools/router_tests.rs @@ -7,6 +7,7 @@ use crate::tools::context::ToolPayload; use crate::turn_diff_tracker::TurnDiffTracker; use codex_protocol::models::ResponseItem; use codex_tools::ToolName; +use tokio_util::sync::CancellationToken; use super::ToolCall; use super::ToolCallSource; @@ -52,6 +53,7 @@ async fn js_repl_tools_only_blocks_direct_tool_calls() -> anyhow::Result<()> { .dispatch_tool_call_with_code_mode_result( session, turn, + CancellationToken::new(), tracker, call, ToolCallSource::Direct, @@ -106,6 +108,7 @@ async fn js_repl_tools_only_allows_js_repl_source_calls() -> anyhow::Result<()> .dispatch_tool_call_with_code_mode_result( session, turn, + CancellationToken::new(), tracker, call, ToolCallSource::JsRepl, @@ -155,6 +158,7 @@ async fn js_repl_tools_only_blocks_namespaced_js_repl_tool() -> anyhow::Result<( .dispatch_tool_call_with_code_mode_result( session, turn, + CancellationToken::new(), tracker, call, ToolCallSource::Direct, diff --git a/codex-rs/protocol/src/approvals.rs b/codex-rs/protocol/src/approvals.rs index b8839b51b6..8799db4a6e 100644 --- a/codex-rs/protocol/src/approvals.rs +++ b/codex-rs/protocol/src/approvals.rs @@ -6,6 +6,7 @@ use crate::permissions::NetworkSandboxPolicy; use crate::protocol::FileChange; use crate::protocol::ReviewDecision; use crate::protocol::SandboxPolicy; +use crate::request_permissions::RequestPermissionProfile; use codex_utils_absolute_path::AbsolutePathBuf; use schemars::JsonSchema; use serde::Deserialize; @@ -163,6 +164,10 @@ pub enum GuardianAssessmentAction { connector_name: Option, tool_title: Option, }, + RequestPermissions { + reason: Option, + permissions: RequestPermissionProfile, + }, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 9c54d322e1..860846f281 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -3607,6 +3607,14 @@ impl ChatWidget { /// render the final approved/denied history cell when guardian returns a /// decision. fn on_guardian_assessment(&mut self, ev: GuardianAssessmentEvent) { + let permission_request_summary = |subject: &str, reason: &Option| { + reason + .as_deref() + .map(str::trim) + .filter(|reason| !reason.is_empty()) + .map(|reason| format!("{subject}: {reason}")) + .unwrap_or_else(|| subject.to_string()) + }; let guardian_action_summary = |action: &GuardianAssessmentAction| match action { GuardianAssessmentAction::Command { command, .. } => Some(command.clone()), GuardianAssessmentAction::Execve { program, argv, .. } => { @@ -3636,6 +3644,9 @@ impl ChatWidget { let label = connector_name.as_deref().unwrap_or(server.as_str()); Some(format!("MCP {tool_name} on {label}")) } + GuardianAssessmentAction::RequestPermissions { reason, .. } => { + Some(permission_request_summary("permission request", reason)) + } }; let guardian_command = |action: &GuardianAssessmentAction| match action { GuardianAssessmentAction::Command { command, .. } => shlex::split(command) @@ -3649,7 +3660,8 @@ impl ChatWidget { .filter(|command| !command.is_empty()), GuardianAssessmentAction::ApplyPatch { .. } | GuardianAssessmentAction::NetworkAccess { .. } - | GuardianAssessmentAction::McpToolCall { .. } => None, + | GuardianAssessmentAction::McpToolCall { .. } + | GuardianAssessmentAction::RequestPermissions { .. } => None, }; if ev.status == GuardianAssessmentStatus::InProgress @@ -3740,6 +3752,11 @@ impl ChatWidget { "codex could access {target}" )) } + GuardianAssessmentAction::RequestPermissions { reason, .. } => { + history_cell::new_guardian_timed_out_action_request( + permission_request_summary("codex could request permissions", reason), + ) + } GuardianAssessmentAction::Command { .. } => unreachable!(), GuardianAssessmentAction::Execve { .. } => unreachable!(), } @@ -3778,6 +3795,12 @@ impl ChatWidget { "codex to access {target}" )) } + GuardianAssessmentAction::RequestPermissions { reason, .. } => { + history_cell::new_guardian_denied_action_request(permission_request_summary( + "codex to request permissions", + reason, + )) + } GuardianAssessmentAction::Command { .. } => unreachable!(), GuardianAssessmentAction::Execve { .. } => unreachable!(), } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__guardian_approved_request_permissions_renders_request_summary.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__guardian_approved_request_permissions_renders_request_summary.snap new file mode 100644 index 0000000000..4f0561af9e --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__guardian_approved_request_permissions_renders_request_summary.snap @@ -0,0 +1,16 @@ +--- +source: tui/src/chatwidget/tests/guardian.rs +expression: normalize_snapshot_paths(term.backend().vt100().screen().contents()) +--- + + + +✔ Request approved for permission request: Need write access for generated + report assets. + +• Working (0s • esc to interrupt) + + +› Ask Codex to do anything + + gpt-5.4 default · /tmp/project diff --git a/codex-rs/tui/src/chatwidget/tests/guardian.rs b/codex-rs/tui/src/chatwidget/tests/guardian.rs index 03e73ed883..ffdefa534b 100644 --- a/codex-rs/tui/src/chatwidget/tests/guardian.rs +++ b/codex-rs/tui/src/chatwidget/tests/guardian.rs @@ -121,6 +121,86 @@ async fn guardian_approved_exec_renders_approved_request() { ); } +#[tokio::test] +async fn guardian_approved_request_permissions_renders_request_summary() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.show_welcome_banner = false; + let action = GuardianAssessmentAction::RequestPermissions { + reason: Some("Need write access for generated report assets.".to_string()), + permissions: RequestPermissionProfile { + file_system: Some(FileSystemPermissions::from_read_write_roots( + /*read*/ None, + Some(vec![test_path_buf("/tmp/reports").abs()]), + )), + ..RequestPermissionProfile::default() + }, + }; + + chat.handle_codex_event(Event { + id: "guardian-in-progress".into(), + msg: EventMsg::GuardianAssessment(GuardianAssessmentEvent { + id: "guardian-request-permissions".into(), + target_item_id: None, + turn_id: "turn-1".into(), + status: GuardianAssessmentStatus::InProgress, + risk_level: None, + user_authorization: None, + rationale: None, + decision_source: None, + action: action.clone(), + }), + }); + + let status = chat + .bottom_pane + .status_widget() + .expect("status indicator should be visible"); + assert_eq!(status.header(), "Reviewing approval request"); + assert_eq!( + status.details(), + Some("permission request: Need write access for generated report assets.") + ); + + chat.handle_codex_event(Event { + id: "guardian-assessment".into(), + msg: EventMsg::GuardianAssessment(GuardianAssessmentEvent { + id: "guardian-request-permissions".into(), + target_item_id: None, + turn_id: "turn-1".into(), + status: GuardianAssessmentStatus::Approved, + risk_level: Some(GuardianRiskLevel::Low), + user_authorization: Some(GuardianUserAuthorization::High), + rationale: Some("Request is scoped to report output.".into()), + decision_source: Some(GuardianAssessmentDecisionSource::Agent), + action, + }), + }); + + let width: u16 = 110; + let ui_height: u16 = chat.desired_height(width); + let vt_height: u16 = 12; + let viewport = Rect::new(0, vt_height - ui_height - 1, width, ui_height); + + let backend = VT100Backend::new(width, vt_height); + let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal"); + term.set_viewport_area(viewport); + + for lines in drain_insert_history(&mut rx) { + crate::insert_history::insert_history_lines(&mut term, lines) + .expect("Failed to insert history lines in test"); + } + + term.draw(|f| { + chat.render(f.area(), f.buffer_mut()); + }) + .expect("draw guardian request permissions approval history"); + + assert_chatwidget_snapshot!( + "guardian_approved_request_permissions_renders_request_summary", + normalize_snapshot_paths(term.backend().vt100().screen().contents()) + ); +} + #[tokio::test] async fn guardian_timed_out_exec_renders_warning_and_timed_out_request() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;