From 20dfdc8a87bb5c5d534239dea4de889126e46fd9 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Wed, 15 Apr 2026 15:48:27 -0700 Subject: [PATCH] permissions: start using PermissionProfile as the canonical runtime model --- ...CommandExecutionRequestApprovalParams.json | 202 ++++++++++++++ .../PermissionsRequestApprovalParams.json | 202 ++++++++++++++ .../PermissionsRequestApprovalResponse.json | 202 ++++++++++++++ .../schema/json/ServerRequest.json | 202 ++++++++++++++ .../codex_app_server_protocol.schemas.json | 202 ++++++++++++++ .../schema/typescript/FileSystemAccessMode.ts | 12 + .../schema/typescript/FileSystemPath.ts | 7 + .../typescript/FileSystemSandboxEntry.ts | 7 + .../typescript/FileSystemSpecialPath.ts | 5 + .../schema/typescript/index.ts | 4 + .../v2/AdditionalFileSystemPermissions.ts | 3 +- .../src/protocol/common.rs | 1 + .../app-server-protocol/src/protocol/v2.rs | 126 ++++++++- .../app-server/src/bespoke_event_handling.rs | 205 ++++++++++++-- codex-rs/app-server/src/transport/mod.rs | 5 +- .../tests/suite/v2/request_permissions.rs | 1 + codex-rs/core/src/codex.rs | 11 + codex-rs/core/src/codex_thread.rs | 4 + codex-rs/core/src/config/config_tests.rs | 16 ++ codex-rs/core/src/config/mod.rs | 12 + .../core/src/tools/handlers/apply_patch.rs | 8 +- .../src/tools/handlers/apply_patch_tests.rs | 10 +- codex-rs/core/src/tools/handlers/mod.rs | 74 +++++- .../src/tools/handlers/unified_exec_tests.rs | 8 +- .../src/tools/runtimes/apply_patch_tests.rs | 8 +- .../runtimes/shell/unix_escalation_tests.rs | 8 +- .../core/tests/suite/request_permissions.rs | 144 +++++----- .../tests/suite/request_permissions_tool.rs | 16 +- codex-rs/exec-server/src/fs_sandbox.rs | 72 +++-- codex-rs/exec-server/tests/file_system.rs | 8 +- codex-rs/protocol/src/models.rs | 249 +++++++++++++++++- codex-rs/protocol/src/permissions.rs | 51 +++- codex-rs/sandboxing/src/manager_tests.rs | 16 +- codex-rs/sandboxing/src/policy_transforms.rs | 221 +++++++++------- .../sandboxing/src/policy_transforms_tests.rs | 248 +++++++++++++---- codex-rs/tui/src/app.rs | 36 +-- codex-rs/tui/src/app/app_server_requests.rs | 18 +- .../src/app_server_approval_conversions.rs | 27 +- .../tui/src/bottom_pane/approval_overlay.rs | 134 ++++++++-- ...al_permissions_special_entries_prompt.snap | 16 ++ .../src/chatwidget/tests/approval_requests.rs | 36 +-- 41 files changed, 2419 insertions(+), 418 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/FileSystemAccessMode.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/FileSystemPath.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/FileSystemSandboxEntry.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/FileSystemSpecialPath.ts create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__approval_overlay__tests__approval_overlay_additional_permissions_special_entries_prompt.snap diff --git a/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json b/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json index e5287e1c65eb..9941054c7e2f 100644 --- a/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json +++ b/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json @@ -7,6 +7,15 @@ }, "AdditionalFileSystemPermissions": { "properties": { + "entries": { + "items": { + "$ref": "#/definitions/FileSystemSandboxEntry" + }, + "type": [ + "array", + "null" + ] + }, "read": { "items": { "$ref": "#/definitions/AbsolutePathBuf" @@ -253,6 +262,199 @@ } ] }, + "FileSystemAccessMode": { + "description": "Access mode for a filesystem entry.\n\nWhen two equally specific entries target the same path, we compare these by conflict precedence rather than by capability breadth: `none` beats `write`, and `write` beats `read`.", + "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": { + "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" + }, + { + "description": "WARNING: `:special_path` tokens are part of config compatibility. Do not make older runtimes reject newly introduced tokens. New parser support should be additive, while unknown values must stay representable so config from a newer Codex degrades to warn-and-ignore instead of failing to load. Codex 0.112.0 rejected unknown values here, which broke forward compatibility for newer config. Preserves future special-path tokens so older runtimes can ignore them without rejecting config authored by a newer release.", + "properties": { + "kind": { + "enum": [ + "unknown" + ], + "type": "string" + }, + "path": { + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind", + "path" + ], + "type": "object" + } + ] + }, "NetworkApprovalContext": { "properties": { "host": { diff --git a/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json b/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json index ac8d5c4010c6..d05a6d332f8a 100644 --- a/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json +++ b/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json @@ -7,6 +7,15 @@ }, "AdditionalFileSystemPermissions": { "properties": { + "entries": { + "items": { + "$ref": "#/definitions/FileSystemSandboxEntry" + }, + "type": [ + "array", + "null" + ] + }, "read": { "items": { "$ref": "#/definitions/AbsolutePathBuf" @@ -39,6 +48,199 @@ }, "type": "object" }, + "FileSystemAccessMode": { + "description": "Access mode for a filesystem entry.\n\nWhen two equally specific entries target the same path, we compare these by conflict precedence rather than by capability breadth: `none` beats `write`, and `write` beats `read`.", + "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": { + "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" + }, + { + "description": "WARNING: `:special_path` tokens are part of config compatibility. Do not make older runtimes reject newly introduced tokens. New parser support should be additive, while unknown values must stay representable so config from a newer Codex degrades to warn-and-ignore instead of failing to load. Codex 0.112.0 rejected unknown values here, which broke forward compatibility for newer config. Preserves future special-path tokens so older runtimes can ignore them without rejecting config authored by a newer release.", + "properties": { + "kind": { + "enum": [ + "unknown" + ], + "type": "string" + }, + "path": { + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind", + "path" + ], + "type": "object" + } + ] + }, "RequestPermissionProfile": { "additionalProperties": false, "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalResponse.json b/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalResponse.json index 7b0c2b1a3bc4..eb3c65283ab3 100644 --- a/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalResponse.json +++ b/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalResponse.json @@ -7,6 +7,15 @@ }, "AdditionalFileSystemPermissions": { "properties": { + "entries": { + "items": { + "$ref": "#/definitions/FileSystemSandboxEntry" + }, + "type": [ + "array", + "null" + ] + }, "read": { "items": { "$ref": "#/definitions/AbsolutePathBuf" @@ -39,6 +48,199 @@ }, "type": "object" }, + "FileSystemAccessMode": { + "description": "Access mode for a filesystem entry.\n\nWhen two equally specific entries target the same path, we compare these by conflict precedence rather than by capability breadth: `none` beats `write`, and `write` beats `read`.", + "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": { + "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" + }, + { + "description": "WARNING: `:special_path` tokens are part of config compatibility. Do not make older runtimes reject newly introduced tokens. New parser support should be additive, while unknown values must stay representable so config from a newer Codex degrades to warn-and-ignore instead of failing to load. Codex 0.112.0 rejected unknown values here, which broke forward compatibility for newer config. Preserves future special-path tokens so older runtimes can ignore them without rejecting config authored by a newer release.", + "properties": { + "kind": { + "enum": [ + "unknown" + ], + "type": "string" + }, + "path": { + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind", + "path" + ], + "type": "object" + } + ] + }, "GrantedPermissionProfile": { "properties": { "fileSystem": { diff --git a/codex-rs/app-server-protocol/schema/json/ServerRequest.json b/codex-rs/app-server-protocol/schema/json/ServerRequest.json index b31e69f20387..f6c290880621 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ServerRequest.json @@ -7,6 +7,15 @@ }, "AdditionalFileSystemPermissions": { "properties": { + "entries": { + "items": { + "$ref": "#/definitions/FileSystemSandboxEntry" + }, + "type": [ + "array", + "null" + ] + }, "read": { "items": { "$ref": "#/definitions/AbsolutePathBuf" @@ -586,6 +595,199 @@ ], "type": "object" }, + "FileSystemAccessMode": { + "description": "Access mode for a filesystem entry.\n\nWhen two equally specific entries target the same path, we compare these by conflict precedence rather than by capability breadth: `none` beats `write`, and `write` beats `read`.", + "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": { + "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" + }, + { + "description": "WARNING: `:special_path` tokens are part of config compatibility. Do not make older runtimes reject newly introduced tokens. New parser support should be additive, while unknown values must stay representable so config from a newer Codex degrades to warn-and-ignore instead of failing to load. Codex 0.112.0 rejected unknown values here, which broke forward compatibility for newer config. Preserves future special-path tokens so older runtimes can ignore them without rejecting config authored by a newer release.", + "properties": { + "kind": { + "enum": [ + "unknown" + ], + "type": "string" + }, + "path": { + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind", + "path" + ], + "type": "object" + } + ] + }, "McpElicitationArrayType": { "enum": [ "array" 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 d0ce368b78d6..3e0674bee6c1 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 @@ -7,6 +7,15 @@ }, "AdditionalFileSystemPermissions": { "properties": { + "entries": { + "items": { + "$ref": "#/definitions/FileSystemSandboxEntry" + }, + "type": [ + "array", + "null" + ] + }, "read": { "items": { "$ref": "#/definitions/v2/AbsolutePathBuf" @@ -2179,6 +2188,199 @@ "title": "FileChangeRequestApprovalResponse", "type": "object" }, + "FileSystemAccessMode": { + "description": "Access mode for a filesystem entry.\n\nWhen two equally specific entries target the same path, we compare these by conflict precedence rather than by capability breadth: `none` beats `write`, and `write` beats `read`.", + "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": { + "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" + }, + { + "description": "WARNING: `:special_path` tokens are part of config compatibility. Do not make older runtimes reject newly introduced tokens. New parser support should be additive, while unknown values must stay representable so config from a newer Codex degrades to warn-and-ignore instead of failing to load. Codex 0.112.0 rejected unknown values here, which broke forward compatibility for newer config. Preserves future special-path tokens so older runtimes can ignore them without rejecting config authored by a newer release.", + "properties": { + "kind": { + "enum": [ + "unknown" + ], + "type": "string" + }, + "path": { + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind", + "path" + ], + "type": "object" + } + ] + }, "FuzzyFileSearchMatchType": { "enum": [ "file", diff --git a/codex-rs/app-server-protocol/schema/typescript/FileSystemAccessMode.ts b/codex-rs/app-server-protocol/schema/typescript/FileSystemAccessMode.ts new file mode 100644 index 000000000000..ccaa07a8bf5d --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/FileSystemAccessMode.ts @@ -0,0 +1,12 @@ +// 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. + +/** + * Access mode for a filesystem entry. + * + * When two equally specific entries target the same path, we compare these by + * conflict precedence rather than by capability breadth: `none` beats + * `write`, and `write` beats `read`. + */ +export type FileSystemAccessMode = "read" | "write" | "none"; diff --git a/codex-rs/app-server-protocol/schema/typescript/FileSystemPath.ts b/codex-rs/app-server-protocol/schema/typescript/FileSystemPath.ts new file mode 100644 index 000000000000..3b022f1744cd --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/FileSystemPath.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 { AbsolutePathBuf } from "./AbsolutePathBuf"; +import type { FileSystemSpecialPath } from "./FileSystemSpecialPath"; + +export type FileSystemPath = { "type": "path", path: AbsolutePathBuf, } | { "type": "special", value: FileSystemSpecialPath, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/FileSystemSandboxEntry.ts b/codex-rs/app-server-protocol/schema/typescript/FileSystemSandboxEntry.ts new file mode 100644 index 000000000000..f37cd0d63ee3 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/FileSystemSandboxEntry.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 { FileSystemAccessMode } from "./FileSystemAccessMode"; +import type { FileSystemPath } from "./FileSystemPath"; + +export type FileSystemSandboxEntry = { path: FileSystemPath, access: FileSystemAccessMode, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/FileSystemSpecialPath.ts b/codex-rs/app-server-protocol/schema/typescript/FileSystemSpecialPath.ts new file mode 100644 index 000000000000..8ba7b433a65a --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/FileSystemSpecialPath.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 FileSystemSpecialPath = { "kind": "root" } | { "kind": "minimal" } | { "kind": "current_working_directory" } | { "kind": "project_roots", subpath?: string, } | { "kind": "tmpdir" } | { "kind": "slash_tmp" } | { "kind": "unknown", path: string, subpath?: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 7bbb417fdc9f..50277418a3b3 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -16,6 +16,10 @@ export type { ExecCommandApprovalParams } from "./ExecCommandApprovalParams"; export type { ExecCommandApprovalResponse } from "./ExecCommandApprovalResponse"; export type { ExecPolicyAmendment } from "./ExecPolicyAmendment"; export type { FileChange } from "./FileChange"; +export type { FileSystemAccessMode } from "./FileSystemAccessMode"; +export type { FileSystemPath } from "./FileSystemPath"; +export type { FileSystemSandboxEntry } from "./FileSystemSandboxEntry"; +export type { FileSystemSpecialPath } from "./FileSystemSpecialPath"; export type { ForcedLoginMethod } from "./ForcedLoginMethod"; export type { FunctionCallOutputBody } from "./FunctionCallOutputBody"; export type { FunctionCallOutputContentItem } from "./FunctionCallOutputContentItem"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AdditionalFileSystemPermissions.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AdditionalFileSystemPermissions.ts index 0ed8a1b12f9d..be84d3a0934a 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/AdditionalFileSystemPermissions.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AdditionalFileSystemPermissions.ts @@ -2,5 +2,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { AbsolutePathBuf } from "../AbsolutePathBuf"; +import type { FileSystemSandboxEntry } from "../FileSystemSandboxEntry"; -export type AdditionalFileSystemPermissions = { read: Array | null, write: Array | null, }; +export type AdditionalFileSystemPermissions = { read: Array | null, write: Array | null, entries: Array | null, }; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 3d6bf9d872c4..509482613a03 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -2039,6 +2039,7 @@ mod tests { file_system: Some(v2::AdditionalFileSystemPermissions { read: Some(vec![absolute_path("/tmp/allowed")]), write: None, + entries: None, }), }), proposed_execpolicy_amendment: None, diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 5897625de176..60dbfaf21c6e 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -46,6 +46,9 @@ use codex_protocol::openai_models::ModelAvailabilityNux as CoreModelAvailability use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::openai_models::default_input_modalities; use codex_protocol::parse_command::ParsedCommand as CoreParsedCommand; +use codex_protocol::permissions::FileSystemAccessMode as CoreFileSystemAccessMode; +use codex_protocol::permissions::FileSystemPath as CoreFileSystemPath; +use codex_protocol::permissions::FileSystemSandboxEntry as CoreFileSystemSandboxEntry; use codex_protocol::plan_tool::PlanItemArg as CorePlanItemArg; use codex_protocol::plan_tool::StepStatus as CorePlanStepStatus; use codex_protocol::protocol::AgentStatus as CoreAgentStatus; @@ -1129,22 +1132,44 @@ impl From for NetworkApprovalContext { pub struct AdditionalFileSystemPermissions { pub read: Option>, pub write: Option>, + pub entries: Option>, } impl From for AdditionalFileSystemPermissions { fn from(value: CoreFileSystemPermissions) -> Self { + let entries = value + .entries + .iter() + .any(|entry| { + entry.access == CoreFileSystemAccessMode::None + || !matches!(entry.path, CoreFileSystemPath::Path { .. }) + }) + .then_some(value.entries.clone()); + let read = value + .explicit_path_entries() + .filter_map(|(path, access)| { + (access == CoreFileSystemAccessMode::Read).then_some(path.clone()) + }) + .collect::>(); + let write = value + .explicit_path_entries() + .filter_map(|(path, access)| { + (access == CoreFileSystemAccessMode::Write).then_some(path.clone()) + }) + .collect::>(); Self { - read: value.read, - write: value.write, + read: (!read.is_empty()).then_some(read), + write: (!write.is_empty()).then_some(write), + entries, } } } impl From for CoreFileSystemPermissions { fn from(value: AdditionalFileSystemPermissions) -> Self { - Self { - read: value.read, - write: value.write, + match value.entries { + Some(entries) => CoreFileSystemPermissions { entries }, + None => CoreFileSystemPermissions::from_read_write_roots(value.read, value.write), } } } @@ -6727,6 +6752,7 @@ mod tests { AbsolutePathBuf::try_from(PathBuf::from(read_write_path)) .expect("path must be absolute"), ]), + entries: None, }), } ); @@ -6737,16 +6763,16 @@ mod tests { network: Some(CoreNetworkPermissions { enabled: Some(true), }), - file_system: Some(CoreFileSystemPermissions { - read: Some(vec![ + file_system: Some(CoreFileSystemPermissions::from_read_write_roots( + Some(vec![ AbsolutePathBuf::try_from(PathBuf::from(read_only_path)) .expect("path must be absolute"), ]), - write: Some(vec![ + Some(vec![ AbsolutePathBuf::try_from(PathBuf::from(read_write_path)) .expect("path must be absolute"), ]), - }), + )), } ); } @@ -6820,6 +6846,7 @@ mod tests { AbsolutePathBuf::try_from(PathBuf::from(read_write_path)) .expect("path must be absolute"), ]), + entries: None, }), } ); @@ -6830,20 +6857,93 @@ mod tests { network: Some(CoreNetworkPermissions { enabled: Some(true), }), - file_system: Some(CoreFileSystemPermissions { - read: Some(vec![ + file_system: Some(CoreFileSystemPermissions::from_read_write_roots( + Some(vec![ AbsolutePathBuf::try_from(PathBuf::from(read_only_path)) .expect("path must be absolute"), ]), - write: Some(vec![ + Some(vec![ AbsolutePathBuf::try_from(PathBuf::from(read_write_path)) .expect("path must be absolute"), ]), - }), + )), } ); } + #[test] + fn additional_file_system_permissions_empty_entries_override_read_write_roots() { + let read_only_path = if cfg!(windows) { + r"C:\tmp\read-only" + } else { + "/tmp/read-only" + }; + let read_write_path = if cfg!(windows) { + r"C:\tmp\read-write" + } else { + "/tmp/read-write" + }; + + assert_eq!( + CoreFileSystemPermissions::from(AdditionalFileSystemPermissions { + read: Some(vec![ + AbsolutePathBuf::try_from(PathBuf::from(read_only_path)) + .expect("path must be absolute"), + ]), + write: Some(vec![ + AbsolutePathBuf::try_from(PathBuf::from(read_write_path)) + .expect("path must be absolute"), + ]), + entries: Some(Vec::new()), + }), + CoreFileSystemPermissions::default() + ); + } + + #[test] + fn additional_permission_profile_preserves_canonical_file_system_entries() { + let deny_path = if cfg!(windows) { + r"C:\tmp\secret.txt" + } else { + "/tmp/secret.txt" + }; + let file_system = CoreFileSystemPermissions { + entries: vec![ + CoreFileSystemSandboxEntry { + path: CoreFileSystemPath::Special { + value: codex_protocol::permissions::FileSystemSpecialPath::Root, + }, + access: CoreFileSystemAccessMode::Write, + }, + CoreFileSystemSandboxEntry { + path: CoreFileSystemPath::Path { + path: AbsolutePathBuf::try_from(PathBuf::from(deny_path)) + .expect("path must be absolute"), + }, + access: CoreFileSystemAccessMode::None, + }, + ], + }; + let permissions = CorePermissionProfile { + file_system: Some(file_system.clone()), + ..Default::default() + }; + + let additional_permissions = AdditionalPermissionProfile::from(permissions.clone()); + assert_eq!( + additional_permissions.file_system, + Some(AdditionalFileSystemPermissions { + read: None, + write: None, + entries: Some(file_system.entries), + }) + ); + assert_eq!( + CorePermissionProfile::from(additional_permissions), + permissions + ); + } + #[test] fn permissions_request_approval_response_defaults_scope_to_turn() { let response = serde_json::from_value::(json!({ diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 4fddee4a1128..d6feb5cde27d 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -118,6 +118,7 @@ use codex_protocol::ThreadId; use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem as CoreDynamicToolCallOutputContentItem; use codex_protocol::dynamic_tools::DynamicToolResponse as CoreDynamicToolResponse; use codex_protocol::items::parse_hook_prompt_message; +use codex_protocol::models::PermissionProfile as CorePermissionProfile; use codex_protocol::plan_tool::UpdatePlanArgs; use codex_protocol::protocol::CodexErrorInfo as CoreCodexErrorInfo; use codex_protocol::protocol::Event; @@ -2566,9 +2567,12 @@ async fn on_request_permissions_response( let response = receiver.await; resolve_server_request_on_thread_listener(&thread_state, pending_request_id).await; drop(request_permissions_guard); - let Some(response) = - request_permissions_response_from_client_result(requested_permissions, response) - else { + let cwd = conversation.cwd().await; + let Some(response) = request_permissions_response_from_client_result( + requested_permissions, + cwd.as_path(), + response, + ) else { return; }; @@ -2585,6 +2589,7 @@ async fn on_request_permissions_response( fn request_permissions_response_from_client_result( requested_permissions: CoreRequestPermissionProfile, + cwd: &Path, response: std::result::Result, ) -> Option { let value = match response { @@ -2615,15 +2620,25 @@ fn request_permissions_response_from_client_result( } }); Some(CoreRequestPermissionsResponse { - permissions: intersect_permission_profiles( - requested_permissions.into(), - response.permissions.into(), - ) - .into(), + permissions: granted_request_permissions_within_requested_scope( + requested_permissions, + CorePermissionProfile::from(response.permissions).into(), + cwd, + ), scope: response.scope.to_core(), }) } +fn granted_request_permissions_within_requested_scope( + requested: CoreRequestPermissionProfile, + granted: CoreRequestPermissionProfile, + cwd: &Path, +) -> CoreRequestPermissionProfile { + let requested: CorePermissionProfile = requested.into(); + let granted: CorePermissionProfile = granted.into(); + intersect_permission_profiles(requested, granted, cwd).into() +} + const REVIEW_FALLBACK_MESSAGE: &str = "Reviewer failed to output a response."; fn render_review_output_text(output: &ReviewOutputEvent) -> String { @@ -3673,6 +3688,7 @@ mod tests { let response = request_permissions_response_from_client_result( CoreRequestPermissionProfile::default(), + Path::new("/tmp"), Ok(Err(error)), ); @@ -3703,10 +3719,10 @@ mod tests { network: Some(CoreNetworkPermissions { enabled: Some(true), }), - file_system: Some(CoreFileSystemPermissions { - read: Some(vec![absolute_path(input_path)]), - write: Some(vec![absolute_path(output_path)]), - }), + file_system: Some(CoreFileSystemPermissions::from_read_write_roots( + Some(vec![absolute_path(input_path)]), + Some(vec![absolute_path(output_path)]), + )), }; let cases = vec![ ( @@ -3733,10 +3749,10 @@ mod tests { }, }), CoreRequestPermissionProfile { - file_system: Some(CoreFileSystemPermissions { - read: None, - write: Some(vec![absolute_path(output_path)]), - }), + file_system: Some(CoreFileSystemPermissions::from_read_write_roots( + /*read*/ None, + Some(vec![absolute_path(output_path)]), + )), ..CoreRequestPermissionProfile::default() }, ), @@ -3751,10 +3767,10 @@ mod tests { }, }), CoreRequestPermissionProfile { - file_system: Some(CoreFileSystemPermissions { - read: Some(vec![absolute_path(input_path)]), - write: Some(vec![absolute_path(output_path)]), - }), + file_system: Some(CoreFileSystemPermissions::from_read_write_roots( + Some(vec![absolute_path(input_path)]), + Some(vec![absolute_path(output_path)]), + )), ..CoreRequestPermissionProfile::default() }, ), @@ -3763,6 +3779,7 @@ mod tests { for (granted_permissions, expected_permissions) in cases { let response = request_permissions_response_from_client_result( requested_permissions.clone(), + Path::new("/tmp"), Ok(Ok(serde_json::json!({ "permissions": granted_permissions, }))), @@ -3783,6 +3800,7 @@ mod tests { fn request_permissions_response_preserves_session_scope() { let response = request_permissions_response_from_client_result( CoreRequestPermissionProfile::default(), + Path::new("/tmp"), Ok(Ok(serde_json::json!({ "scope": "session", "permissions": {}, @@ -3799,6 +3817,153 @@ mod tests { ); } + #[test] + fn request_permissions_response_accepts_explicit_child_grant_for_requested_cwd_scope() { + let cwd = TempDir::new().expect("create temp dir"); + let child = + AbsolutePathBuf::try_from(cwd.path().join("child.txt")).expect("absolute child path"); + let requested_permissions = CoreRequestPermissionProfile { + file_system: Some(CoreFileSystemPermissions { + entries: vec![codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::Special { + value: codex_protocol::permissions::FileSystemSpecialPath::CurrentWorkingDirectory, + }, + access: codex_protocol::permissions::FileSystemAccessMode::Write, + }], + }), + ..CoreRequestPermissionProfile::default() + }; + + let response = request_permissions_response_from_client_result( + requested_permissions, + cwd.path(), + Ok(Ok(serde_json::json!({ + "permissions": { + "fileSystem": { + "write": [child], + }, + }, + }))), + ) + .expect("response should be accepted"); + + assert_eq!( + response, + CoreRequestPermissionsResponse { + permissions: CoreRequestPermissionProfile { + file_system: Some(CoreFileSystemPermissions::from_read_write_roots( + /*read*/ None, + Some(vec![child]), + )), + ..CoreRequestPermissionProfile::default() + }, + scope: CorePermissionGrantScope::Turn, + } + ); + } + + #[test] + fn request_permissions_response_caps_broader_cwd_grant_to_requested_child_path() { + let cwd = TempDir::new().expect("create temp dir"); + let child = + AbsolutePathBuf::try_from(cwd.path().join("child.txt")).expect("absolute child path"); + let requested_permissions = CoreRequestPermissionProfile { + file_system: Some(CoreFileSystemPermissions::from_read_write_roots( + /*read*/ None, + Some(vec![child.clone()]), + )), + ..CoreRequestPermissionProfile::default() + }; + + let response = request_permissions_response_from_client_result( + requested_permissions, + cwd.path(), + Ok(Ok(serde_json::json!({ + "permissions": { + "fileSystem": { + "entries": [{ + "path": { + "type": "special", + "value": { + "kind": "current_working_directory", + }, + }, + "access": "write", + }], + }, + }, + }))), + ) + .expect("response should be accepted"); + + assert_eq!( + response, + CoreRequestPermissionsResponse { + permissions: CoreRequestPermissionProfile { + file_system: Some(CoreFileSystemPermissions::from_read_write_roots( + /*read*/ None, + Some(vec![child]), + )), + ..CoreRequestPermissionProfile::default() + }, + scope: CorePermissionGrantScope::Turn, + } + ); + } + + #[test] + fn request_permissions_response_preserves_requested_carveouts_when_client_grants_broad_root() { + let cwd = TempDir::new().expect("create temp dir"); + let blocked = AbsolutePathBuf::try_from(cwd.path().join("blocked.txt")) + .expect("absolute blocked path"); + let requested_permissions = CoreRequestPermissionProfile { + file_system: Some(CoreFileSystemPermissions { + entries: vec![ + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::Special { + value: codex_protocol::permissions::FileSystemSpecialPath::Root, + }, + access: codex_protocol::permissions::FileSystemAccessMode::Write, + }, + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::Path { path: blocked }, + access: codex_protocol::permissions::FileSystemAccessMode::None, + }, + ], + }), + ..CoreRequestPermissionProfile::default() + }; + + let response = request_permissions_response_from_client_result( + requested_permissions.clone(), + cwd.path(), + Ok(Ok(serde_json::json!({ + "permissions": { + "fileSystem": { + "entries": [{ + "path": { + "type": "special", + "value": { + "kind": "root", + }, + }, + "access": "write", + }], + }, + }, + }))), + ) + .expect("response should be accepted"); + + assert_eq!( + response, + CoreRequestPermissionsResponse { + permissions: requested_permissions, + scope: CorePermissionGrantScope::Turn, + } + ); + } + #[test] fn collab_resume_begin_maps_to_item_started_resume_agent() { let event = CollabResumeBeginEvent { diff --git a/codex-rs/app-server/src/transport/mod.rs b/codex-rs/app-server/src/transport/mod.rs index 75a4971905ec..a71fd0b6f158 100644 --- a/codex-rs/app-server/src/transport/mod.rs +++ b/codex-rs/app-server/src/transport/mod.rs @@ -780,6 +780,7 @@ mod tests { codex_app_server_protocol::AdditionalFileSystemPermissions { read: Some(vec![absolute_path("/tmp/allowed")]), write: None, + entries: None, }, ), }, @@ -842,6 +843,7 @@ mod tests { codex_app_server_protocol::AdditionalFileSystemPermissions { read: Some(vec![absolute_path("/tmp/allowed")]), write: None, + entries: None, }, ), }, @@ -868,7 +870,8 @@ mod tests { "network": null, "fileSystem": { "read": [allowed_path], - "write": null, + "write": null, + "entries": null, }, }) ); diff --git a/codex-rs/app-server/tests/suite/v2/request_permissions.rs b/codex-rs/app-server/tests/suite/v2/request_permissions.rs index 5a0679415d0f..05a45011dea8 100644 --- a/codex-rs/app-server/tests/suite/v2/request_permissions.rs +++ b/codex-rs/app-server/tests/suite/v2/request_permissions.rs @@ -93,6 +93,7 @@ async fn request_permissions_round_trip() -> Result<()> { file_system: Some(codex_app_server_protocol::AdditionalFileSystemPermissions { read: None, write: Some(vec![requested_writes[0].clone()]), + entries: None, }), }, scope: PermissionGrantScope::Turn, diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index cc314a66e9b8..4529c640d71c 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1456,6 +1456,12 @@ impl Session { per_turn_config.service_tier = session_configuration.service_tier; per_turn_config.personality = session_configuration.personality; per_turn_config.approvals_reviewer = session_configuration.approvals_reviewer; + per_turn_config.permissions.approval_policy = session_configuration.approval_policy.clone(); + per_turn_config.permissions.sandbox_policy = session_configuration.sandbox_policy.clone(); + per_turn_config.permissions.file_system_sandbox_policy = + session_configuration.file_system_sandbox_policy.clone(); + per_turn_config.permissions.network_sandbox_policy = + session_configuration.network_sandbox_policy; let resolved_web_search_mode = resolve_web_search_mode_for_turn( &per_turn_config.web_search_mode, session_configuration.sandbox_policy.get(), @@ -3557,6 +3563,11 @@ impl Session { ts.granted_permissions() } + pub(crate) async fn cwd(&self) -> AbsolutePathBuf { + let state = self.state.lock().await; + state.session_configuration.cwd.clone() + } + pub(crate) async fn granted_session_permissions(&self) -> Option { let state = self.state.lock().await; state.granted_permissions() diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index fa9f835181c3..398cf1aca593 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -79,6 +79,10 @@ impl CodexThread { self.codex.shutdown_and_wait().await } + pub async fn cwd(&self) -> AbsolutePathBuf { + self.codex.session.cwd().await + } + #[doc(hidden)] pub async fn ensure_rollout_materialized(&self) { self.codex.session.ensure_rollout_materialized().await; diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index b2931e105583..461c434b15db 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -48,6 +48,7 @@ use codex_model_provider_info::LMSTUDIO_OSS_PROVIDER_ID; use codex_model_provider_info::OLLAMA_OSS_PROVIDER_ID; use codex_model_provider_info::WireApi; use codex_models_manager::bundled_models_response; +use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry; @@ -627,6 +628,13 @@ fn default_permissions_profile_populates_runtime_sandbox_policy() -> std::io::Re }, ]), ); + assert_eq!( + config.permissions.runtime_permission_profile(), + PermissionProfile::from_runtime_permissions( + &config.permissions.file_system_sandbox_policy, + config.permissions.network_sandbox_policy, + ) + ); assert_eq!( config.permissions.sandbox_policy.get(), &SandboxPolicy::WorkspaceWrite { @@ -1245,6 +1253,14 @@ exclude_slash_tmp = true NetworkSandboxPolicy::from(sandbox_policy), "case `{name}` should preserve network semantics from legacy config" ); + assert_eq!( + config.permissions.runtime_permission_profile(), + PermissionProfile::from_runtime_permissions( + &config.permissions.file_system_sandbox_policy, + config.permissions.network_sandbox_policy, + ), + "case `{name}` should populate canonical permission profile from runtime policies" + ); assert_eq!( config .permissions diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index b58acbf65110..523880b537d9 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -73,6 +73,8 @@ use codex_protocol::config_types::Verbosity; use codex_protocol::config_types::WebSearchConfig; use codex_protocol::config_types::WebSearchMode; use codex_protocol::config_types::WindowsSandboxLevel; +#[cfg(test)] +use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ModelsResponse; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::permissions::FileSystemSandboxPolicy; @@ -212,6 +214,16 @@ pub struct Permissions { pub windows_sandbox_private_desktop: bool, } +impl Permissions { + #[cfg(test)] + pub(crate) fn runtime_permission_profile(&self) -> PermissionProfile { + PermissionProfile::from_runtime_permissions( + &self.file_system_sandbox_policy, + self.network_sandbox_policy, + ) + } +} + /// Application configuration loaded from disk and merged with overrides. #[derive(Debug, Clone, PartialEq)] pub struct Config { diff --git a/codex-rs/core/src/tools/handlers/apply_patch.rs b/codex-rs/core/src/tools/handlers/apply_patch.rs index 8e932c3ad7df..59b464ea2f6e 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch.rs @@ -82,10 +82,10 @@ fn write_permissions_for_paths( .ok()?; let permissions = (!write_paths.is_empty()).then_some(PermissionProfile { - file_system: Some(FileSystemPermissions { - read: Some(vec![]), - write: Some(write_paths), - }), + file_system: Some(FileSystemPermissions::from_read_write_roots( + Some(vec![]), + Some(write_paths), + )), ..Default::default() })?; diff --git a/codex-rs/core/src/tools/handlers/apply_patch_tests.rs b/codex-rs/core/src/tools/handlers/apply_patch_tests.rs index 86e05fb8a105..d99f158e66fb 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch_tests.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch_tests.rs @@ -85,7 +85,13 @@ fn write_permissions_for_paths_keep_dirs_outside_workspace_root() { dunce::simplified(&outside.canonicalize().expect("canonicalize outside dir")).abs(); assert_eq!( - permissions.and_then(|profile| profile.file_system.and_then(|fs| fs.write)), - Some(vec![expected_outside]) + permissions, + Some(PermissionProfile { + file_system: Some(FileSystemPermissions::from_read_write_roots( + Some(vec![]), + Some(vec![expected_outside]), + )), + ..Default::default() + }) ); } diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index 022e02c39e20..9733211d4972 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -18,9 +18,9 @@ mod tool_suggest; pub(crate) mod unified_exec; mod view_image; -use codex_sandboxing::policy_transforms::intersect_permission_profiles; use codex_sandboxing::policy_transforms::merge_permission_profiles; use codex_sandboxing::policy_transforms::normalize_additional_permissions; +use codex_sandboxing::policy_transforms::permission_profile_is_preapproved; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::AbsolutePathBufGuard; use serde::Deserialize; @@ -183,17 +183,12 @@ pub(super) async fn apply_granted_turn_permissions( granted_session_permissions.as_ref(), granted_turn_permissions.as_ref(), ); - let effective_permissions = merge_permission_profiles( + let session_cwd = session.cwd().await; + let (effective_permissions, permissions_preapproved) = effective_permissions_for_granted_turn( additional_permissions.as_ref(), granted_permissions.as_ref(), + session_cwd.as_path(), ); - let permissions_preapproved = match (effective_permissions.as_ref(), granted_permissions) { - (Some(effective_permissions), Some(granted_permissions)) => { - intersect_permission_profiles(effective_permissions.clone(), granted_permissions) - == *effective_permissions - } - _ => false, - }; let sandbox_permissions = if effective_permissions.is_some() && !sandbox_permissions.uses_additional_permissions() { @@ -209,9 +204,30 @@ pub(super) async fn apply_granted_turn_permissions( } } +fn effective_permissions_for_granted_turn( + additional_permissions: Option<&PermissionProfile>, + granted_permissions: Option<&PermissionProfile>, + cwd: &Path, +) -> (Option, bool) { + let effective_permissions = + merge_permission_profiles(additional_permissions, granted_permissions); + let permissions_preapproved = match (effective_permissions.as_ref(), granted_permissions) { + (Some(effective_permissions), Some(granted_permissions)) => { + permission_profile_is_preapproved(effective_permissions, granted_permissions, cwd) + } + _ => false, + }; + if permissions_preapproved { + (granted_permissions.cloned(), true) + } else { + (effective_permissions, false) + } +} + #[cfg(test)] mod tests { use super::EffectiveAdditionalPermissions; + use super::effective_permissions_for_granted_turn; use super::implicit_granted_permissions; use super::normalize_and_validate_additional_permissions; use crate::sandboxing::SandboxPermissions; @@ -235,12 +251,24 @@ mod tests { fn file_system_permissions(path: &std::path::Path) -> PermissionProfile { PermissionProfile { - file_system: Some(FileSystemPermissions { - read: None, - write: Some(vec![ + file_system: Some(FileSystemPermissions::from_read_write_roots( + /*read*/ None, + Some(vec![ AbsolutePathBuf::from_absolute_path(path).expect("absolute path"), ]), - }), + )), + ..Default::default() + } + } + + fn read_file_system_permissions(path: &std::path::Path) -> PermissionProfile { + PermissionProfile { + file_system: Some(FileSystemPermissions::from_read_write_roots( + Some(vec![ + AbsolutePathBuf::from_absolute_path(path).expect("absolute path"), + ]), + /*write*/ None, + )), ..Default::default() } } @@ -322,4 +350,24 @@ mod tests { assert_eq!(implicit_permissions, None); } + + #[test] + fn preapproved_sticky_grants_do_not_get_narrowed_by_child_request() { + let cwd = tempdir().expect("tempdir"); + let child_path = cwd.path().join("child"); + let requested_permissions = read_file_system_permissions(&child_path); + let granted_permissions = file_system_permissions(cwd.path()); + + let (effective_permissions, permissions_preapproved) = + effective_permissions_for_granted_turn( + Some(&requested_permissions), + Some(&granted_permissions), + cwd.path(), + ); + + assert_eq!( + (effective_permissions, permissions_preapproved), + (Some(granted_permissions), true) + ); + } } 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 b64181451665..1f52445a5b75 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec_tests.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec_tests.rs @@ -188,10 +188,10 @@ fn exec_command_args_resolve_relative_additional_permissions_against_workdir() - assert_eq!( args.additional_permissions, Some(PermissionProfile { - file_system: Some(FileSystemPermissions { - read: None, - write: Some(vec![expected_write.abs()]), - }), + file_system: Some(FileSystemPermissions::from_read_write_roots( + /*read*/ None, + Some(vec![expected_write.abs()]), + )), ..Default::default() }) ); diff --git a/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs b/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs index 0ba3e131af3d..f838a43a7a7b 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs @@ -82,10 +82,10 @@ fn file_system_sandbox_context_uses_active_attempt() { .abs(); let additional_permissions = PermissionProfile { network: None, - file_system: Some(FileSystemPermissions { - read: Some(vec![path.clone()]), - write: Some(Vec::new()), - }), + file_system: Some(FileSystemPermissions::from_read_write_roots( + Some(vec![path.clone()]), + Some(Vec::new()), + )), }; let req = ApplyPatchRequest { action: ApplyPatchAction::new_add_for_test(&path, "hello".to_string()), diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs index f11050f72b5c..bbbcd82aaaf5 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs @@ -250,12 +250,12 @@ fn map_exec_result_preserves_stdout_and_stderr() { #[test] fn shell_request_escalation_execution_is_explicit() { let requested_permissions = PermissionProfile { - file_system: Some(FileSystemPermissions { - read: None, - write: Some(vec![ + file_system: Some(FileSystemPermissions::from_read_write_roots( + /*read*/ None, + Some(vec![ AbsolutePathBuf::from_absolute_path("/tmp/output").unwrap(), ]), - }), + )), ..Default::default() }; let sandbox_policy = SandboxPolicy::WorkspaceWrite { diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index 2cfd1cf6f783..1ab44593d82c 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -7,6 +7,7 @@ use codex_features::Feature; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::models::FileSystemPermissions; use codex_protocol::models::PermissionProfile; +use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ExecApprovalRequestEvent; @@ -293,20 +294,20 @@ fn workspace_write_excluding_tmp() -> SandboxPolicy { fn requested_directory_write_permissions(path: &Path) -> RequestPermissionProfile { RequestPermissionProfile { - file_system: Some(FileSystemPermissions { - read: Some(vec![]), - write: Some(vec![absolute_path(path)]), - }), + file_system: Some(FileSystemPermissions::from_read_write_roots( + Some(vec![]), + Some(vec![absolute_path(path)]), + )), ..RequestPermissionProfile::default() } } fn normalized_directory_write_permissions(path: &Path) -> Result { Ok(RequestPermissionProfile { - file_system: Some(FileSystemPermissions { - read: Some(vec![]), - write: Some(vec![AbsolutePathBuf::try_from(path.canonicalize()?)?]), - }), + file_system: Some(FileSystemPermissions::from_read_write_roots( + Some(vec![]), + Some(vec![AbsolutePathBuf::try_from(path.canonicalize()?)?]), + )), ..RequestPermissionProfile::default() }) } @@ -343,10 +344,10 @@ async fn with_additional_permissions_requires_approval_under_on_request() -> Res let call_id = "request_permissions_skip_approval"; let command = "touch requested-dir/requested-but-unused.txt"; let requested_permissions = PermissionProfile { - file_system: Some(FileSystemPermissions { - read: Some(vec![]), - write: Some(vec![absolute_path(&requested_dir_canonical)]), - }), + file_system: Some(FileSystemPermissions::from_read_write_roots( + Some(vec![]), + Some(vec![absolute_path(&requested_dir_canonical)]), + )), ..Default::default() }; let event = shell_event_with_request_permissions(call_id, command, &requested_permissions)?; @@ -521,10 +522,10 @@ async fn relative_additional_permissions_resolve_against_tool_workdir() -> Resul let call_id = "request_permissions_relative_workdir"; let command = "touch relative-write.txt"; let expected_permissions = PermissionProfile { - file_system: Some(FileSystemPermissions { - read: None, - write: Some(vec![absolute_path(&nested_dir_canonical)]), - }), + file_system: Some(FileSystemPermissions::from_read_write_roots( + /*read*/ None, + Some(vec![absolute_path(&nested_dir_canonical)]), + )), ..Default::default() }; let event = shell_event_with_raw_request_permissions( @@ -624,10 +625,10 @@ async fn read_only_with_additional_permissions_does_not_widen_to_unrequested_cwd "cwd-widened", unrequested_write, unrequested_write ); let requested_permissions = PermissionProfile { - file_system: Some(FileSystemPermissions { - read: Some(vec![]), - write: Some(vec![absolute_path(&requested_write)]), - }), + file_system: Some(FileSystemPermissions::from_read_write_roots( + Some(vec![]), + Some(vec![absolute_path(&requested_write)]), + )), ..Default::default() }; let event = shell_event_with_request_permissions(call_id, &command, &requested_permissions)?; @@ -725,10 +726,10 @@ async fn read_only_with_additional_permissions_does_not_widen_to_unrequested_tmp "tmp-widened", tmp_write, tmp_write ); let requested_permissions = PermissionProfile { - file_system: Some(FileSystemPermissions { - read: Some(vec![]), - write: Some(vec![absolute_path(&requested_write)]), - }), + file_system: Some(FileSystemPermissions::from_read_write_roots( + Some(vec![]), + Some(vec![absolute_path(&requested_write)]), + )), ..Default::default() }; let event = shell_event_with_request_permissions(call_id, &command, &requested_permissions)?; @@ -824,19 +825,19 @@ async fn workspace_write_with_additional_permissions_can_write_outside_cwd() -> "outside-cwd-ok", outside_write, outside_write ); let requested_permissions = RequestPermissionProfile { - file_system: Some(FileSystemPermissions { - read: Some(vec![]), - write: Some(vec![absolute_path(outside_dir.path())]), - }), + file_system: Some(FileSystemPermissions::from_read_write_roots( + Some(vec![]), + Some(vec![absolute_path(outside_dir.path())]), + )), ..RequestPermissionProfile::default() }; let normalized_requested_permissions = RequestPermissionProfile { - file_system: Some(FileSystemPermissions { - read: Some(vec![]), - write: Some(vec![AbsolutePathBuf::try_from( + file_system: Some(FileSystemPermissions::from_read_write_roots( + Some(vec![]), + Some(vec![AbsolutePathBuf::try_from( outside_dir.path().canonicalize()?, )?]), - }), + )), ..RequestPermissionProfile::default() }; let event = shell_event_with_request_permissions(call_id, &command, &requested_permissions)?; @@ -926,19 +927,19 @@ async fn with_additional_permissions_denied_approval_blocks_execution() -> Resul "should-not-write", outside_write, outside_write ); let requested_permissions = PermissionProfile { - file_system: Some(FileSystemPermissions { - read: Some(vec![]), - write: Some(vec![absolute_path(outside_dir.path())]), - }), + file_system: Some(FileSystemPermissions::from_read_write_roots( + Some(vec![]), + Some(vec![absolute_path(outside_dir.path())]), + )), ..Default::default() }; let normalized_requested_permissions = PermissionProfile { - file_system: Some(FileSystemPermissions { - read: Some(vec![]), - write: Some(vec![AbsolutePathBuf::try_from( + file_system: Some(FileSystemPermissions::from_read_write_roots( + Some(vec![]), + Some(vec![AbsolutePathBuf::try_from( outside_dir.path().canonicalize()?, )?]), - }), + )), ..Default::default() }; let event = shell_event_with_request_permissions(call_id, &command, &requested_permissions)?; @@ -1028,19 +1029,19 @@ async fn request_permissions_grants_apply_to_later_exec_command_calls() -> Resul "sticky-grant-ok", outside_write, outside_write ); let requested_permissions = RequestPermissionProfile { - file_system: Some(FileSystemPermissions { - read: Some(vec![]), - write: Some(vec![absolute_path(outside_dir.path())]), - }), + file_system: Some(FileSystemPermissions::from_read_write_roots( + Some(vec![]), + Some(vec![absolute_path(outside_dir.path())]), + )), ..Default::default() }; let normalized_requested_permissions = RequestPermissionProfile { - file_system: Some(FileSystemPermissions { - read: Some(vec![]), - write: Some(vec![AbsolutePathBuf::try_from( + file_system: Some(FileSystemPermissions::from_read_write_roots( + Some(vec![]), + Some(vec![AbsolutePathBuf::try_from( outside_dir.path().canonicalize()?, )?]), - }), + )), ..Default::default() }; let responses = mount_sse_sequence( @@ -1492,35 +1493,35 @@ async fn partial_request_permissions_grants_do_not_preapprove_new_permissions() ); let requested_permissions = RequestPermissionProfile { - file_system: Some(FileSystemPermissions { - read: Some(vec![]), - write: Some(vec![ + file_system: Some(FileSystemPermissions::from_read_write_roots( + Some(vec![]), + Some(vec![ absolute_path(first_dir.path()), absolute_path(second_dir.path()), ]), - }), + )), ..RequestPermissionProfile::default() }; let normalized_requested_permissions = RequestPermissionProfile { - file_system: Some(FileSystemPermissions { - read: Some(vec![]), - write: Some(vec![ + file_system: Some(FileSystemPermissions::from_read_write_roots( + Some(vec![]), + Some(vec![ AbsolutePathBuf::try_from(first_dir.path().canonicalize()?)?, AbsolutePathBuf::try_from(second_dir.path().canonicalize()?)?, ]), - }), + )), ..RequestPermissionProfile::default() }; let granted_permissions = normalized_directory_write_permissions(first_dir.path())?; let second_dir_permissions = requested_directory_write_permissions(second_dir.path()); let merged_permissions = PermissionProfile { - file_system: Some(FileSystemPermissions { - read: Some(vec![]), - write: Some(vec![ + file_system: Some(FileSystemPermissions::from_read_write_roots( + Some(vec![]), + Some(vec![ AbsolutePathBuf::try_from(first_dir.path().canonicalize()?)?, AbsolutePathBuf::try_from(second_dir.path().canonicalize()?)?, ]), - }), + )), ..Default::default() }; @@ -1584,16 +1585,31 @@ async fn partial_request_permissions_grants_do_not_preapprove_new_permissions() let approval_file_system = approval_permissions .file_system .unwrap_or_else(|| panic!("expected filesystem permissions")); - assert!(approval_file_system.read.as_ref().is_none_or(Vec::is_empty)); - let mut approval_writes = approval_file_system.write.unwrap_or_default(); + assert_eq!( + approval_file_system + .explicit_path_entries() + .filter(|(_, access)| *access == FileSystemAccessMode::Read) + .count(), + 0 + ); + + let mut approval_writes = approval_file_system + .explicit_path_entries() + .filter_map(|(path, access)| { + (access == FileSystemAccessMode::Write).then_some(path.clone()) + }) + .collect::>(); approval_writes.sort_by_key(|path| path.display().to_string()); let mut expected_writes = merged_permissions .file_system .unwrap_or_else(|| panic!("expected merged filesystem permissions")) - .write - .unwrap_or_default(); + .explicit_path_entries() + .filter_map(|(path, access)| { + (access == FileSystemAccessMode::Write).then_some(path.clone()) + }) + .collect::>(); expected_writes.sort_by_key(|path| path.display().to_string()); assert_eq!(approval_writes, expected_writes); diff --git a/codex-rs/core/tests/suite/request_permissions_tool.rs b/codex-rs/core/tests/suite/request_permissions_tool.rs index 14506f4a4184..0578441e9918 100644 --- a/codex-rs/core/tests/suite/request_permissions_tool.rs +++ b/codex-rs/core/tests/suite/request_permissions_tool.rs @@ -81,20 +81,20 @@ fn workspace_write_excluding_tmp() -> SandboxPolicy { fn requested_directory_write_permissions(path: &Path) -> RequestPermissionProfile { RequestPermissionProfile { - file_system: Some(FileSystemPermissions { - read: Some(vec![]), - write: Some(vec![absolute_path(path)]), - }), + file_system: Some(FileSystemPermissions::from_read_write_roots( + Some(vec![]), + Some(vec![absolute_path(path)]), + )), ..RequestPermissionProfile::default() } } fn normalized_directory_write_permissions(path: &Path) -> Result { Ok(RequestPermissionProfile { - file_system: Some(FileSystemPermissions { - read: Some(vec![]), - write: Some(vec![AbsolutePathBuf::try_from(path.canonicalize()?)?]), - }), + file_system: Some(FileSystemPermissions::from_read_write_roots( + Some(vec![]), + Some(vec![AbsolutePathBuf::try_from(path.canonicalize()?)?]), + )), ..RequestPermissionProfile::default() }) } diff --git a/codex-rs/exec-server/src/fs_sandbox.rs b/codex-rs/exec-server/src/fs_sandbox.rs index 7b0ac256d4db..f2f0c52d8d08 100644 --- a/codex-rs/exec-server/src/fs_sandbox.rs +++ b/codex-rs/exec-server/src/fs_sandbox.rs @@ -3,6 +3,9 @@ use std::collections::HashMap; use codex_app_server_protocol::JSONRPCErrorError; use codex_protocol::models::FileSystemPermissions; use codex_protocol::models::PermissionProfile; +use codex_protocol::permissions::FileSystemAccessMode; +use codex_protocol::permissions::FileSystemPath; +use codex_protocol::permissions::FileSystemSandboxEntry; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::ReadOnlyAccess; @@ -121,16 +124,23 @@ impl FileSystemSandboxRunner { match additional_permissions.and_then(|permissions| permissions.file_system.clone()) { Some(mut file_system) => { if let Some(helper_read_root) = &helper_read_root { - let read_paths = file_system.read.get_or_insert_with(Vec::new); - if !read_paths.contains(helper_read_root) { - read_paths.push(helper_read_root.clone()); + let helper_read_entry = FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: helper_read_root.clone(), + }, + access: FileSystemAccessMode::Read, + }; + if !file_system.entries.contains(&helper_read_entry) { + file_system.entries.push(helper_read_entry); } } Some(file_system) } - None => helper_read_root.map(|helper_read_root| FileSystemPermissions { - read: Some(vec![helper_read_root]), - write: None, + None => helper_read_root.map(|helper_read_root| { + FileSystemPermissions::from_read_write_roots( + Some(vec![helper_read_root]), + /*write*/ None, + ) }), }; @@ -289,6 +299,7 @@ mod tests { use codex_protocol::models::FileSystemPermissions; use codex_protocol::models::NetworkPermissions; use codex_protocol::models::PermissionProfile; + use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::protocol::ReadOnlyAccess; use codex_protocol::protocol::SandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; @@ -373,27 +384,32 @@ mod tests { network: Some(NetworkPermissions { enabled: Some(true), }), - file_system: Some(FileSystemPermissions { - read: Some(vec![]), - write: Some(vec![writable.clone()]), - }), + file_system: Some(FileSystemPermissions::from_read_write_roots( + Some(vec![]), + Some(vec![writable.clone()]), + )), })); assert_eq!(permissions.network, None); - assert_eq!( - permissions - .file_system - .as_ref() - .and_then(|fs| fs.write.clone()), - Some(vec![writable]) - ); - assert_eq!( - permissions - .file_system - .as_ref() - .and_then(|fs| fs.read.clone()), - Some(vec![readable]) - ); + let file_system = permissions + .file_system + .expect("helper permissions should include filesystem access"); + let mut read_paths = file_system + .explicit_path_entries() + .filter_map(|(path, access)| { + (access == FileSystemAccessMode::Read).then_some(path.clone()) + }) + .collect::>(); + read_paths.sort_by(|left, right| left.as_path().cmp(right.as_path())); + let mut write_paths = file_system + .explicit_path_entries() + .filter_map(|(path, access)| { + (access == FileSystemAccessMode::Write).then_some(path.clone()) + }) + .collect::>(); + write_paths.sort_by(|left, right| left.as_path().cmp(right.as_path())); + assert_eq!(read_paths, vec![readable]); + assert_eq!(write_paths, vec![writable]); } #[test] @@ -415,10 +431,10 @@ mod tests { assert_eq!(permissions.network, None); assert_eq!( permissions.file_system, - Some(FileSystemPermissions { - read: Some(vec![readable]), - write: None, - }) + Some(FileSystemPermissions::from_read_write_roots( + Some(vec![readable]), + /*write*/ None, + )) ); } } diff --git a/codex-rs/exec-server/tests/file_system.rs b/codex-rs/exec-server/tests/file_system.rs index 41dd3f860048..9e044322e39f 100644 --- a/codex-rs/exec-server/tests/file_system.rs +++ b/codex-rs/exec-server/tests/file_system.rs @@ -472,10 +472,10 @@ async fn file_system_sandboxed_write_allows_additional_write_root(use_remote: bo let mut sandbox = read_only_sandbox(readable_dir); sandbox.additional_permissions = Some(PermissionProfile { network: None, - file_system: Some(FileSystemPermissions { - read: None, - write: Some(vec![absolute_path(writable_dir)]), - }), + file_system: Some(FileSystemPermissions::from_read_write_roots( + /*read*/ None, + Some(vec![absolute_path(writable_dir)]), + )), }); file_system diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 0f18b68393fa..ec5ed25ac9d6 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::io; use std::path::Path; use std::sync::LazyLock; @@ -14,6 +15,12 @@ use ts_rs::TS; use crate::config_types::ApprovalsReviewer; use crate::config_types::CollaborationMode; use crate::config_types::SandboxMode; +use crate::permissions::FileSystemAccessMode; +use crate::permissions::FileSystemPath; +use crate::permissions::FileSystemSandboxEntry; +use crate::permissions::FileSystemSandboxPolicy; +use crate::permissions::FileSystemSpecialPath; +use crate::permissions::NetworkSandboxPolicy; use crate::protocol::AskForApproval; use crate::protocol::COLLABORATION_MODE_CLOSE_TAG; use crate::protocol::COLLABORATION_MODE_OPEN_TAG; @@ -80,15 +87,119 @@ impl SandboxPermissions { } } -#[derive(Debug, Clone, Default, Eq, Hash, PartialEq, Serialize, Deserialize, JsonSchema, TS)] +#[derive(Debug, Clone, Default, Eq, Hash, PartialEq, JsonSchema, TS)] pub struct FileSystemPermissions { - pub read: Option>, - pub write: Option>, + pub entries: Vec, } impl FileSystemPermissions { pub fn is_empty(&self) -> bool { - self.read.is_none() && self.write.is_none() + self.entries.is_empty() + } + + pub fn from_read_write_roots( + read: Option>, + write: Option>, + ) -> Self { + let mut entries = Vec::new(); + if let Some(read) = read { + entries.extend(read.into_iter().map(|path| FileSystemSandboxEntry { + path: FileSystemPath::Path { path }, + access: FileSystemAccessMode::Read, + })); + } + if let Some(write) = write { + entries.extend(write.into_iter().map(|path| FileSystemSandboxEntry { + path: FileSystemPath::Path { path }, + access: FileSystemAccessMode::Write, + })); + } + Self { entries } + } + + pub fn explicit_path_entries( + &self, + ) -> impl Iterator { + self.entries.iter().filter_map(|entry| match &entry.path { + FileSystemPath::Path { path } => Some((path, entry.access)), + FileSystemPath::Special { .. } => None, + }) + } + + fn as_legacy_permissions(&self) -> Option { + let mut read = Vec::new(); + let mut write = Vec::new(); + + for entry in &self.entries { + let FileSystemPath::Path { path } = &entry.path else { + return None; + }; + match entry.access { + FileSystemAccessMode::Read => read.push(path.clone()), + FileSystemAccessMode::Write => write.push(path.clone()), + FileSystemAccessMode::None => return None, + } + } + + Some(LegacyFileSystemPermissions { + read: (!read.is_empty()).then_some(read), + write: (!write.is_empty()).then_some(write), + }) + } +} + +#[derive(Debug, Clone, Default, Eq, Hash, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct LegacyFileSystemPermissions { + #[serde(default, skip_serializing_if = "Option::is_none")] + read: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + write: Option>, +} + +#[derive(Debug, Clone, Default, Eq, Hash, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct CanonicalFileSystemPermissions { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + entries: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +enum FileSystemPermissionsDe { + Canonical(CanonicalFileSystemPermissions), + Legacy(LegacyFileSystemPermissions), +} + +impl Serialize for FileSystemPermissions { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + if let Some(legacy) = self.as_legacy_permissions() { + legacy.serialize(serializer) + } else { + CanonicalFileSystemPermissions { + entries: self.entries.clone(), + } + .serialize(serializer) + } + } +} + +impl<'de> Deserialize<'de> for FileSystemPermissions { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + match FileSystemPermissionsDe::deserialize(deserializer)? { + FileSystemPermissionsDe::Canonical(CanonicalFileSystemPermissions { entries }) => { + Ok(Self { entries }) + } + FileSystemPermissionsDe::Legacy(LegacyFileSystemPermissions { read, write }) => { + Ok(Self::from_read_write_roots(read, write)) + } + } } } @@ -113,6 +224,80 @@ impl PermissionProfile { pub fn is_empty(&self) -> bool { self.network.is_none() && self.file_system.is_none() } + + pub fn from_runtime_permissions( + file_system_sandbox_policy: &FileSystemSandboxPolicy, + network_sandbox_policy: NetworkSandboxPolicy, + ) -> Self { + Self { + network: Some(network_sandbox_policy.into()), + file_system: Some(file_system_sandbox_policy.into()), + } + } + + pub fn from_legacy_sandbox_policy(sandbox_policy: &SandboxPolicy, cwd: &Path) -> Self { + Self::from_runtime_permissions( + &FileSystemSandboxPolicy::from_legacy_sandbox_policy(sandbox_policy, cwd), + NetworkSandboxPolicy::from(sandbox_policy), + ) + } + + pub fn file_system_sandbox_policy(&self) -> FileSystemSandboxPolicy { + self.file_system.as_ref().map_or_else( + || FileSystemSandboxPolicy::restricted(Vec::new()), + FileSystemSandboxPolicy::from, + ) + } + + pub fn network_sandbox_policy(&self) -> NetworkSandboxPolicy { + if self + .network + .as_ref() + .and_then(|network| network.enabled) + .unwrap_or(false) + { + NetworkSandboxPolicy::Enabled + } else { + NetworkSandboxPolicy::Restricted + } + } + + pub fn to_legacy_sandbox_policy(&self, cwd: &Path) -> io::Result { + self.file_system_sandbox_policy() + .to_legacy_sandbox_policy(self.network_sandbox_policy(), cwd) + } +} + +impl From for NetworkPermissions { + fn from(value: NetworkSandboxPolicy) -> Self { + Self { + enabled: Some(value.is_enabled()), + } + } +} + +impl From<&FileSystemSandboxPolicy> for FileSystemPermissions { + fn from(value: &FileSystemSandboxPolicy) -> Self { + let entries = match value.kind { + crate::permissions::FileSystemSandboxKind::Restricted => value.entries.clone(), + crate::permissions::FileSystemSandboxKind::Unrestricted + | crate::permissions::FileSystemSandboxKind::ExternalSandbox => { + vec![FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Write, + }] + } + }; + Self { entries } + } +} + +impl From<&FileSystemPermissions> for FileSystemSandboxPolicy { + fn from(value: &FileSystemPermissions) -> Self { + FileSystemSandboxPolicy::restricted(value.entries.clone()) + } } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] @@ -1436,6 +1621,10 @@ mod tests { use std::path::PathBuf; use tempfile::tempdir; + fn absolute_path(path: &str) -> AbsolutePathBuf { + AbsolutePathBuf::from_absolute_path(path).expect("path must be absolute") + } + #[test] fn sandbox_permissions_helpers_match_documented_semantics() { let cases = [ @@ -1546,6 +1735,58 @@ mod tests { assert_eq!(permission_profile.is_empty(), false); } + #[test] + fn file_system_permissions_deserialize_legacy_read_write_shape() { + let file_system = serde_json::from_value::(serde_json::json!({ + "read": [absolute_path("/tmp/read-only")], + "write": [absolute_path("/tmp/read-write")], + })) + .expect("deserialize legacy filesystem permissions"); + + assert_eq!( + file_system, + FileSystemPermissions::from_read_write_roots( + Some(vec![absolute_path("/tmp/read-only")]), + Some(vec![absolute_path("/tmp/read-write")]), + ) + ); + } + + #[test] + fn file_system_permissions_serialize_explicit_paths_as_legacy_read_write_shape() { + let file_system = FileSystemPermissions::from_read_write_roots( + Some(vec![absolute_path("/tmp/read-only")]), + Some(vec![absolute_path("/tmp/read-write")]), + ); + + let value = serde_json::to_value(file_system).expect("serialize filesystem permissions"); + + assert_eq!( + value, + serde_json::json!({ + "read": [absolute_path("/tmp/read-only")], + "write": [absolute_path("/tmp/read-write")], + }) + ); + } + + #[test] + fn file_system_permissions_round_trip_special_entries_through_canonical_shape() { + let file_system = FileSystemPermissions { + entries: vec![FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::CurrentWorkingDirectory, + }, + access: FileSystemAccessMode::Write, + }], + }; + + let value = serde_json::to_value(&file_system).expect("serialize filesystem permissions"); + let reparsed = serde_json::from_value::(value) + .expect("deserialize filesystem permissions"); + + assert_eq!(reparsed, file_system); + } #[test] fn convert_mcp_content_to_items_builds_data_urls_when_missing_prefix() { let contents = vec![serde_json::json!({ diff --git a/codex-rs/protocol/src/permissions.rs b/codex-rs/protocol/src/permissions.rs index f101f8bef07a..cc5327136e99 100644 --- a/codex-rs/protocol/src/permissions.rs +++ b/codex-rs/protocol/src/permissions.rs @@ -46,6 +46,7 @@ impl NetworkSandboxPolicy { Copy, PartialEq, Eq, + Hash, PartialOrd, Ord, Serialize, @@ -72,7 +73,7 @@ impl FileSystemAccessMode { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema, TS)] #[serde(tag = "kind", rename_all = "snake_case")] #[ts(tag = "kind")] pub enum FileSystemSpecialPath { @@ -115,7 +116,7 @@ impl FileSystemSpecialPath { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema, TS)] pub struct FileSystemSandboxEntry { pub path: FileSystemPath, pub access: FileSystemAccessMode, @@ -156,7 +157,7 @@ struct FileSystemSemanticSignature { unreadable_roots: Vec, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema, TS)] #[serde(tag = "type", rename_all = "snake_case")] #[ts(tag = "type")] pub enum FileSystemPath { @@ -370,6 +371,50 @@ impl FileSystemSandboxPolicy { self.resolve_access_with_cwd(path, cwd).can_write() } + pub fn covers_entry_with_cwd(&self, entry: &FileSystemSandboxEntry, cwd: &Path) -> bool { + match &entry.path { + FileSystemPath::Path { path } => match entry.access { + FileSystemAccessMode::Read => self.can_read_path_with_cwd(path.as_path(), cwd), + FileSystemAccessMode::Write => self.can_write_path_with_cwd(path.as_path(), cwd), + FileSystemAccessMode::None => { + self.resolve_access_with_cwd(path.as_path(), cwd) == FileSystemAccessMode::None + } + }, + FileSystemPath::Special { value } => match value { + FileSystemSpecialPath::Root => match entry.access { + FileSystemAccessMode::Read => self.has_full_disk_read_access(), + FileSystemAccessMode::Write => self.has_full_disk_write_access(), + FileSystemAccessMode::None => { + !self.has_full_disk_read_access() && !self.has_full_disk_write_access() + } + }, + FileSystemSpecialPath::Minimal => match entry.access { + FileSystemAccessMode::Read => { + self.has_full_disk_read_access() || self.include_platform_defaults() + } + FileSystemAccessMode::Write => self.has_full_disk_write_access(), + FileSystemAccessMode::None => { + !self.has_full_disk_read_access() && !self.include_platform_defaults() + } + }, + _ => resolve_file_system_special_path( + value, + AbsolutePathBuf::from_absolute_path(cwd).ok().as_ref(), + ) + .is_some_and(|path| match entry.access { + FileSystemAccessMode::Read => self.can_read_path_with_cwd(path.as_path(), cwd), + FileSystemAccessMode::Write => { + self.can_write_path_with_cwd(path.as_path(), cwd) + } + FileSystemAccessMode::None => { + self.resolve_access_with_cwd(path.as_path(), cwd) + == FileSystemAccessMode::None + } + }), + }, + } + } + pub fn with_additional_readable_roots( mut self, cwd: &Path, diff --git a/codex-rs/sandboxing/src/manager_tests.rs b/codex-rs/sandboxing/src/manager_tests.rs index f4e019ee2889..e8697362947a 100644 --- a/codex-rs/sandboxing/src/manager_tests.rs +++ b/codex-rs/sandboxing/src/manager_tests.rs @@ -130,10 +130,10 @@ fn transform_additional_permissions_enable_network_for_external_sandbox() { network: Some(NetworkPermissions { enabled: Some(true), }), - file_system: Some(FileSystemPermissions { - read: Some(vec![path]), - write: Some(Vec::new()), - }), + file_system: Some(FileSystemPermissions::from_read_write_roots( + Some(vec![path]), + Some(Vec::new()), + )), }), }, policy: &SandboxPolicy::ExternalSandbox { @@ -183,10 +183,10 @@ fn transform_additional_permissions_preserves_denied_entries() { cwd: cwd.clone(), env: HashMap::new(), additional_permissions: Some(PermissionProfile { - file_system: Some(FileSystemPermissions { - read: None, - write: Some(vec![allowed_path.clone()]), - }), + file_system: Some(FileSystemPermissions::from_read_write_roots( + /*read*/ None, + Some(vec![allowed_path.clone()]), + )), ..Default::default() }), }, diff --git a/codex-rs/sandboxing/src/policy_transforms.rs b/codex-rs/sandboxing/src/policy_transforms.rs index 73ecf81c9bc6..b9128ef7d9f9 100644 --- a/codex-rs/sandboxing/src/policy_transforms.rs +++ b/codex-rs/sandboxing/src/policy_transforms.rs @@ -13,6 +13,7 @@ use codex_protocol::protocol::SandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::canonicalize_preserving_symlinks; use std::collections::HashSet; +use std::path::Path; #[derive(Debug, Clone, PartialEq, Eq)] pub struct EffectiveSandboxPermissions { @@ -45,13 +46,26 @@ pub fn normalize_additional_permissions( let file_system = additional_permissions .file_system .map(|file_system| { - let read = file_system - .read - .map(|paths| normalize_permission_paths(paths, "file_system.read")); - let write = file_system - .write - .map(|paths| normalize_permission_paths(paths, "file_system.write")); - FileSystemPermissions { read, write } + let mut entries = Vec::with_capacity(file_system.entries.len()); + for entry in file_system.entries { + let path = match entry.path { + FileSystemPath::Path { path } => FileSystemPath::Path { + path: canonicalize_preserving_symlinks(path.as_path()) + .ok() + .and_then(|path| AbsolutePathBuf::from_absolute_path(path).ok()) + .unwrap_or(path), + }, + FileSystemPath::Special { value } => FileSystemPath::Special { value }, + }; + let normalized_entry = FileSystemSandboxEntry { + path, + access: entry.access, + }; + if !entries.contains(&normalized_entry) { + entries.push(normalized_entry); + } + } + FileSystemPermissions { entries } }) .filter(|file_system| !file_system.is_empty()); Ok(PermissionProfile { @@ -89,8 +103,7 @@ pub fn merge_permission_profiles( }; let file_system = match (base.file_system.as_ref(), permissions.file_system.as_ref()) { (Some(base), Some(permissions)) => Some(FileSystemPermissions { - read: merge_permission_paths(base.read.as_ref(), permissions.read.as_ref()), - write: merge_permission_paths(base.write.as_ref(), permissions.write.as_ref()), + entries: merge_permission_entries(&base.entries, &permissions.entries), }) .filter(|file_system| !file_system.is_empty()), (Some(base), None) => Some(base.clone()), @@ -111,16 +124,38 @@ pub fn merge_permission_profiles( pub fn intersect_permission_profiles( requested: PermissionProfile, granted: PermissionProfile, + cwd: &Path, ) -> PermissionProfile { let file_system = requested .file_system .map(|requested_file_system| { - let granted_file_system = granted.file_system.unwrap_or_default(); - let read = - intersect_permission_paths(requested_file_system.read, granted_file_system.read); - let write = - intersect_permission_paths(requested_file_system.write, granted_file_system.write); - FileSystemPermissions { read, write } + let granted_policy = granted.file_system_sandbox_policy(); + let requested_policy = FileSystemSandboxPolicy::from(&requested_file_system); + let mut entries = Vec::new(); + if let Some(granted_file_system) = granted.file_system.as_ref() { + for entry in &granted_file_system.entries { + if requested_policy.covers_entry_with_cwd(entry, cwd) + && !entries.contains(entry) + { + entries.push(entry.clone()); + } + } + } + for entry in &requested_file_system.entries { + if granted_policy.covers_entry_with_cwd(entry, cwd) && !entries.contains(entry) { + entries.push(entry.clone()); + } + } + let mut capped_policy = FileSystemSandboxPolicy::restricted(entries.clone()); + for entry in requested_file_system.entries { + if !entries.contains(&entry) + && capped_policy_exceeds_entry(&capped_policy, &entry, cwd) + { + entries.push(entry); + capped_policy = FileSystemSandboxPolicy::restricted(entries.clone()); + } + } + FileSystemPermissions { entries } }) .filter(|file_system| !file_system.is_empty()); let network = match (requested.network, granted.network) { @@ -143,67 +178,64 @@ pub fn intersect_permission_profiles( } } -fn intersect_permission_paths( - requested: Option>, - granted: Option>, -) -> Option> { - requested.and_then(|requested_paths| { - if requested_paths.is_empty() { - return granted.map(|_| Vec::new()); - } - - let granted_paths = granted.unwrap_or_default(); - Some( - requested_paths - .into_iter() - .filter(|path| granted_paths.contains(path)) - .collect::>(), - ) - .filter(|paths| !paths.is_empty()) - }) +fn capped_policy_exceeds_entry( + capped_policy: &FileSystemSandboxPolicy, + entry: &FileSystemSandboxEntry, + cwd: &Path, +) -> bool { + match entry.access { + FileSystemAccessMode::Write => false, + FileSystemAccessMode::Read => capped_policy.covers_entry_with_cwd( + &FileSystemSandboxEntry { + path: entry.path.clone(), + access: FileSystemAccessMode::Write, + }, + cwd, + ), + FileSystemAccessMode::None => !capped_policy.covers_entry_with_cwd(entry, cwd), + } } -fn normalize_permission_paths( - paths: Vec, - _permission_kind: &str, -) -> Vec { - let mut out = Vec::with_capacity(paths.len()); - let mut seen = HashSet::new(); - - for path in paths { - let canonicalized = canonicalize_preserving_symlinks(path.as_path()) - .ok() - .and_then(|path| AbsolutePathBuf::from_absolute_path(path).ok()) - .unwrap_or(path); - if seen.insert(canonicalized.clone()) { - out.push(canonicalized); +pub fn permission_profile_is_preapproved( + requested: &PermissionProfile, + granted: &PermissionProfile, + cwd: &Path, +) -> bool { + let network_preapproved = !requested + .network + .as_ref() + .and_then(|network| network.enabled) + .unwrap_or(false) + || granted + .network + .as_ref() + .and_then(|network| network.enabled) + .unwrap_or(false); + let file_system_preapproved = match requested.file_system.as_ref() { + Some(requested_file_system) => { + let granted_policy = granted.file_system_sandbox_policy(); + requested_file_system + .entries + .iter() + .all(|entry| granted_policy.covers_entry_with_cwd(entry, cwd)) } - } + None => true, + }; - out + network_preapproved && file_system_preapproved } -fn merge_permission_paths( - base: Option<&Vec>, - permissions: Option<&Vec>, -) -> Option> { - match (base, permissions) { - (Some(base), Some(permissions)) => { - let mut merged = Vec::with_capacity(base.len() + permissions.len()); - let mut seen = HashSet::with_capacity(base.len() + permissions.len()); - - for path in base.iter().chain(permissions.iter()) { - if seen.insert(path.clone()) { - merged.push(path.clone()); - } - } - - Some(merged).filter(|paths| !paths.is_empty()) +fn merge_permission_entries( + base: &[FileSystemSandboxEntry], + permissions: &[FileSystemSandboxEntry], +) -> Vec { + let mut merged = Vec::with_capacity(base.len() + permissions.len()); + for entry in base.iter().chain(permissions.iter()) { + if !merged.contains(entry) { + merged.push(entry.clone()); } - (Some(base), None) => Some(base.clone()), - (None, Some(permissions)) => Some(permissions.clone()), - (None, None) => None, } + merged } fn dedup_absolute_paths(paths: Vec) -> Vec { @@ -217,7 +249,7 @@ fn dedup_absolute_paths(paths: Vec) -> Vec { out } -fn additional_permission_roots( +fn additional_permission_explicit_path_roots( additional_permissions: &PermissionProfile, ) -> (Vec, Vec) { ( @@ -225,14 +257,24 @@ fn additional_permission_roots( additional_permissions .file_system .as_ref() - .and_then(|file_system| file_system.read.clone()) + .map(|file_system| { + file_system + .explicit_path_entries() + .filter_map(|(path, access)| access.can_read().then_some(path.clone())) + .collect() + }) .unwrap_or_default(), ), dedup_absolute_paths( additional_permissions .file_system .as_ref() - .and_then(|file_system| file_system.write.clone()) + .map(|file_system| { + file_system + .explicit_path_entries() + .filter_map(|(path, access)| access.can_write().then_some(path.clone())) + .collect() + }) .unwrap_or_default(), ), ) @@ -240,28 +282,14 @@ fn additional_permission_roots( fn merge_file_system_policy_with_additional_permissions( file_system_policy: &FileSystemSandboxPolicy, - extra_reads: Vec, - extra_writes: Vec, + additional_permissions: &FileSystemPermissions, ) -> FileSystemSandboxPolicy { match file_system_policy.kind { FileSystemSandboxKind::Restricted => { let mut merged_policy = file_system_policy.clone(); - for path in extra_reads { - let entry = FileSystemSandboxEntry { - path: FileSystemPath::Path { path }, - access: FileSystemAccessMode::Read, - }; - if !merged_policy.entries.contains(&entry) { - merged_policy.entries.push(entry); - } - } - for path in extra_writes { - let entry = FileSystemSandboxEntry { - path: FileSystemPath::Path { path }, - access: FileSystemAccessMode::Write, - }; - if !merged_policy.entries.contains(&entry) { - merged_policy.entries.push(entry); + for entry in &additional_permissions.entries { + if !merged_policy.entries.contains(entry) { + merged_policy.entries.push(entry.clone()); } } merged_policy @@ -280,14 +308,15 @@ pub fn effective_file_system_sandbox_policy( return file_system_policy.clone(); }; - let (extra_reads, extra_writes) = additional_permission_roots(additional_permissions); - if extra_reads.is_empty() && extra_writes.is_empty() { + let Some(file_system_permissions) = additional_permissions.file_system.as_ref() else { + return file_system_policy.clone(); + }; + if file_system_permissions.is_empty() { file_system_policy.clone() } else { merge_file_system_policy_with_additional_permissions( file_system_policy, - extra_reads, - extra_writes, + file_system_permissions, ) } } @@ -347,7 +376,11 @@ fn sandbox_policy_with_additional_permissions( return sandbox_policy.clone(); } - let (extra_reads, extra_writes) = additional_permission_roots(additional_permissions); + // Legacy SandboxPolicy remains a best-effort projection during the + // migration to PermissionProfile-backed thread permissions. The direct + // filesystem sandbox policy carries the full permission shape. + let (extra_reads, extra_writes) = + additional_permission_explicit_path_roots(additional_permissions); match sandbox_policy { SandboxPolicy::DangerFullAccess => SandboxPolicy::DangerFullAccess, diff --git a/codex-rs/sandboxing/src/policy_transforms_tests.rs b/codex-rs/sandboxing/src/policy_transforms_tests.rs index f33d312f1547..c1bf85f7d1be 100644 --- a/codex-rs/sandboxing/src/policy_transforms_tests.rs +++ b/codex-rs/sandboxing/src/policy_transforms_tests.rs @@ -2,6 +2,7 @@ use super::effective_file_system_sandbox_policy; use super::intersect_permission_profiles; use super::merge_file_system_policy_with_additional_permissions; use super::normalize_additional_permissions; +use super::permission_profile_is_preapproved; use super::sandbox_policy_with_additional_permissions; use super::should_require_platform_sandbox; use codex_protocol::models::FileSystemPermissions; @@ -106,10 +107,10 @@ fn normalize_additional_permissions_preserves_network() { network: Some(NetworkPermissions { enabled: Some(true), }), - file_system: Some(FileSystemPermissions { - read: Some(vec![path.clone()]), - write: Some(vec![path.clone()]), - }), + file_system: Some(FileSystemPermissions::from_read_write_roots( + Some(vec![path.clone()]), + Some(vec![path.clone()]), + )), }) .expect("permissions"); @@ -121,10 +122,10 @@ fn normalize_additional_permissions_preserves_network() { ); assert_eq!( permissions.file_system, - Some(FileSystemPermissions { - read: Some(vec![path.clone()]), - write: Some(vec![path]), - }) + Some(FileSystemPermissions::from_read_write_roots( + Some(vec![path.clone()]), + Some(vec![path]), + )) ); } @@ -141,23 +142,23 @@ fn normalize_additional_permissions_preserves_symlinked_write_paths() { let link_write_dir = AbsolutePathBuf::from_absolute_path(link_root.join("write")).expect("link write dir"); let permissions = normalize_additional_permissions(PermissionProfile { - file_system: Some(FileSystemPermissions { - read: Some(vec![]), - write: Some(vec![link_write_dir]), - }), + file_system: Some(FileSystemPermissions::from_read_write_roots( + Some(vec![]), + Some(vec![link_write_dir]), + )), ..Default::default() }) .expect("permissions"); assert_eq!( permissions.file_system, - Some(FileSystemPermissions { - read: Some(vec![]), - write: Some(vec![ + Some(FileSystemPermissions::from_read_write_roots( + Some(vec![]), + Some(vec![ AbsolutePathBuf::from_absolute_path(link_root.join("write")) - .expect("link write dir") + .expect("link write dir"), ]), - }) + )) ); } @@ -165,10 +166,7 @@ fn normalize_additional_permissions_preserves_symlinked_write_paths() { fn normalize_additional_permissions_drops_empty_nested_profiles() { let permissions = normalize_additional_permissions(PermissionProfile { network: Some(NetworkPermissions { enabled: None }), - file_system: Some(FileSystemPermissions { - read: None, - write: None, - }), + file_system: Some(FileSystemPermissions::default()), }) .expect("permissions"); @@ -176,67 +174,199 @@ fn normalize_additional_permissions_drops_empty_nested_profiles() { } #[test] -fn intersect_permission_profiles_preserves_explicit_empty_requested_reads() { +fn intersect_permission_profiles_drops_ungranted_nonempty_path_requests() { + let requested = PermissionProfile { + file_system: Some(FileSystemPermissions::from_read_write_roots( + Some(Vec::from(["/tmp/requested" + .try_into() + .expect("absolute path")])), + /*write*/ None, + )), + ..Default::default() + }; + + assert_eq!( + intersect_permission_profiles( + requested, + PermissionProfile::default(), + std::env::current_dir().expect("current dir").as_path() + ), + PermissionProfile::default() + ); +} + +#[test] +fn intersect_permission_profiles_caps_broad_grants_to_requested_paths() { let temp_dir = TempDir::new().expect("create temp dir"); - let path = AbsolutePathBuf::from_absolute_path( + let parent = AbsolutePathBuf::from_absolute_path( canonicalize(temp_dir.path()).expect("canonicalize temp dir"), ) .expect("absolute temp dir"); + let child = AbsolutePathBuf::from_absolute_path(parent.as_path().join("child")) + .expect("absolute child path"); + let requested = PermissionProfile { + file_system: Some(FileSystemPermissions::from_read_write_roots( + Some(vec![child.clone()]), + /*write*/ None, + )), + ..Default::default() + }; + let granted = PermissionProfile { + file_system: Some(FileSystemPermissions::from_read_write_roots( + /*read*/ None, + Some(vec![parent]), + )), + ..Default::default() + }; + + assert_eq!( + intersect_permission_profiles(requested, granted, temp_dir.path()), + PermissionProfile { + file_system: Some(FileSystemPermissions::from_read_write_roots( + Some(vec![child]), + /*write*/ None, + )), + ..Default::default() + } + ); +} + +#[test] +fn intersect_permission_profiles_preserves_requested_write_carveouts() { + let temp_dir = TempDir::new().expect("create temp dir"); + let blocked = AbsolutePathBuf::from_absolute_path(temp_dir.path().join("blocked")) + .expect("absolute blocked path"); let requested = PermissionProfile { file_system: Some(FileSystemPermissions { - read: Some(vec![]), - write: Some(vec![path]), + entries: vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Write, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { path: blocked }, + access: FileSystemAccessMode::None, + }, + ], + }), + ..Default::default() + }; + let granted = PermissionProfile { + file_system: Some(FileSystemPermissions { + entries: vec![FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Write, + }], }), ..Default::default() }; - let granted = requested.clone(); assert_eq!( - intersect_permission_profiles(requested.clone(), granted), + intersect_permission_profiles(requested.clone(), granted, temp_dir.path()), requested ); } #[test] -fn intersect_permission_profiles_drops_ungranted_nonempty_path_requests() { +fn intersect_permission_profiles_keeps_granted_child_under_requested_special_root() { let temp_dir = TempDir::new().expect("create temp dir"); - let path = AbsolutePathBuf::from_absolute_path( - canonicalize(temp_dir.path()).expect("canonicalize temp dir"), - ) - .expect("absolute temp dir"); + let child = AbsolutePathBuf::from_absolute_path(temp_dir.path().join("child")) + .expect("absolute child path"); let requested = PermissionProfile { file_system: Some(FileSystemPermissions { - read: Some(vec![path]), - write: None, + entries: vec![FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::CurrentWorkingDirectory, + }, + access: FileSystemAccessMode::Write, + }], }), ..Default::default() }; + let granted = PermissionProfile { + file_system: Some(FileSystemPermissions::from_read_write_roots( + /*read*/ None, + Some(vec![child.clone()]), + )), + ..Default::default() + }; assert_eq!( - intersect_permission_profiles(requested, PermissionProfile::default()), - PermissionProfile::default() + intersect_permission_profiles(requested, granted, temp_dir.path()), + PermissionProfile { + file_system: Some(FileSystemPermissions::from_read_write_roots( + /*read*/ None, + Some(vec![child]), + )), + ..Default::default() + } ); } #[test] -fn intersect_permission_profiles_drops_explicit_empty_reads_without_grant() { +fn permission_profile_is_preapproved_when_granted_parent_directory_covers_requested_child() { let temp_dir = TempDir::new().expect("create temp dir"); - let path = AbsolutePathBuf::from_absolute_path( + let parent = AbsolutePathBuf::from_absolute_path( canonicalize(temp_dir.path()).expect("canonicalize temp dir"), ) .expect("absolute temp dir"); + let child = AbsolutePathBuf::from_absolute_path(parent.as_path().join("child")) + .expect("absolute child path"); + let granted = PermissionProfile { + file_system: Some(FileSystemPermissions::from_read_write_roots( + /*read*/ None, + Some(vec![parent]), + )), + ..Default::default() + }; let requested = PermissionProfile { + file_system: Some(FileSystemPermissions::from_read_write_roots( + /*read*/ None, + Some(vec![child]), + )), + ..Default::default() + }; + + assert!(permission_profile_is_preapproved( + &requested, + &granted, + temp_dir.path(), + )); +} + +#[test] +fn permission_profile_is_preapproved_when_granted_cwd_covers_requested_child() { + let temp_dir = TempDir::new().expect("create temp dir"); + let child = AbsolutePathBuf::from_absolute_path(temp_dir.path().join("child")) + .expect("absolute child path"); + let granted = PermissionProfile { file_system: Some(FileSystemPermissions { - read: Some(vec![]), - write: Some(vec![path]), + entries: vec![FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::CurrentWorkingDirectory, + }, + access: FileSystemAccessMode::Write, + }], }), ..Default::default() }; + let requested = PermissionProfile { + file_system: Some(FileSystemPermissions::from_read_write_roots( + /*read*/ None, + Some(vec![child]), + )), + ..Default::default() + }; - assert_eq!( - intersect_permission_profiles(requested, PermissionProfile::default()), - PermissionProfile::default() - ); + assert!(permission_profile_is_preapproved( + &requested, + &granted, + temp_dir.path(), + )); } #[test] @@ -258,10 +388,10 @@ fn read_only_additional_permissions_can_enable_network_without_writes() { network: Some(NetworkPermissions { enabled: Some(true), }), - file_system: Some(FileSystemPermissions { - read: Some(vec![path.clone()]), - write: Some(Vec::new()), - }), + file_system: Some(FileSystemPermissions::from_read_write_roots( + Some(vec![path.clone()]), + Some(Vec::new()), + )), }, ); @@ -292,10 +422,10 @@ fn external_sandbox_additional_permissions_can_enable_network() { network: Some(NetworkPermissions { enabled: Some(true), }), - file_system: Some(FileSystemPermissions { - read: Some(vec![path]), - write: Some(Vec::new()), - }), + file_system: Some(FileSystemPermissions::from_read_write_roots( + Some(vec![path]), + Some(Vec::new()), + )), }, ); @@ -331,8 +461,10 @@ fn merge_file_system_policy_with_additional_permissions_preserves_unreadable_roo access: FileSystemAccessMode::None, }, ]), - vec![allowed_path.clone()], - Vec::new(), + &FileSystemPermissions::from_read_write_roots( + Some(vec![allowed_path.clone()]), + Some(Vec::new()), + ), ); assert_eq!( @@ -402,10 +534,10 @@ fn effective_file_system_sandbox_policy_merges_additional_write_roots() { }, ]); let additional_permissions = PermissionProfile { - file_system: Some(FileSystemPermissions { - read: Some(vec![]), - write: Some(vec![allowed_path.clone()]), - }), + file_system: Some(FileSystemPermissions::from_read_write_roots( + Some(vec![]), + Some(vec![allowed_path.clone()]), + )), ..Default::default() }; diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 71304deb2cc8..bef1fe1b83ae 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -8879,10 +8879,12 @@ guardian_approval = true network: Some(AdditionalNetworkPermissions { enabled: Some(true), }), - file_system: Some(AdditionalFileSystemPermissions { - read: Some(vec![test_absolute_path("/tmp/read-only")]), - write: Some(vec![test_absolute_path("/tmp/write")]), - }), + file_system: Some(AdditionalFileSystemPermissions::from( + FileSystemPermissions::from_read_write_roots( + Some(vec![test_absolute_path("/tmp/read-only")]), + Some(vec![test_absolute_path("/tmp/write")]), + ), + )), }); params.proposed_network_policy_amendments = Some(vec![AppServerNetworkPolicyAmendment { host: "example.com".to_string(), @@ -8914,10 +8916,10 @@ guardian_approval = true network: Some(NetworkPermissions { enabled: Some(true), }), - file_system: Some(FileSystemPermissions { - read: Some(vec![test_absolute_path("/tmp/read-only")]), - write: Some(vec![test_absolute_path("/tmp/write")]), - }), + file_system: Some(FileSystemPermissions::from_read_write_roots( + Some(vec![test_absolute_path("/tmp/read-only")]), + Some(vec![test_absolute_path("/tmp/write")]), + )), }) ); assert_eq!( @@ -8986,10 +8988,12 @@ guardian_approval = true network: Some(AdditionalNetworkPermissions { enabled: Some(true), }), - file_system: Some(AdditionalFileSystemPermissions { - read: Some(vec![test_absolute_path("/tmp/read-only")]), - write: Some(vec![test_absolute_path("/tmp/write")]), - }), + file_system: Some(AdditionalFileSystemPermissions::from( + FileSystemPermissions::from_read_write_roots( + Some(vec![test_absolute_path("/tmp/read-only")]), + Some(vec![test_absolute_path("/tmp/write")]), + ), + )), }, }, }; @@ -9010,10 +9014,10 @@ guardian_approval = true network: Some(NetworkPermissions { enabled: Some(true), }), - file_system: Some(FileSystemPermissions { - read: Some(vec![test_absolute_path("/tmp/read-only")]), - write: Some(vec![test_absolute_path("/tmp/write")]), - }), + file_system: Some(FileSystemPermissions::from_read_write_roots( + Some(vec![test_absolute_path("/tmp/read-only")]), + Some(vec![test_absolute_path("/tmp/write")]), + )), } ); } diff --git a/codex-rs/tui/src/app/app_server_requests.rs b/codex-rs/tui/src/app/app_server_requests.rs index b1393e95e08d..a9210fc97b44 100644 --- a/codex-rs/tui/src/app/app_server_requests.rs +++ b/codex-rs/tui/src/app/app_server_requests.rs @@ -393,10 +393,10 @@ mod tests { network: Some(NetworkPermissions { enabled: Some(true), }), - file_system: Some(FileSystemPermissions { - read: Some(vec![absolute_path(read_path)]), - write: Some(vec![absolute_path(write_path)]), - }), + file_system: Some(FileSystemPermissions::from_read_write_roots( + Some(vec![absolute_path(read_path)]), + Some(vec![absolute_path(write_path)]), + )), }, scope: codex_protocol::request_permissions::PermissionGrantScope::Session, }, @@ -412,10 +412,12 @@ mod tests { network: Some(AdditionalNetworkPermissions { enabled: Some(true), }), - file_system: Some(AdditionalFileSystemPermissions { - read: Some(vec![absolute_path(read_path)]), - write: Some(vec![absolute_path(write_path)]), - }), + file_system: Some(AdditionalFileSystemPermissions::from( + FileSystemPermissions::from_read_write_roots( + Some(vec![absolute_path(read_path)]), + Some(vec![absolute_path(write_path)]), + ), + )), }, scope: PermissionGrantScope::Session, } diff --git a/codex-rs/tui/src/app_server_approval_conversions.rs b/codex-rs/tui/src/app_server_approval_conversions.rs index 8d2335eb7dfe..700cd1e94607 100644 --- a/codex-rs/tui/src/app_server_approval_conversions.rs +++ b/codex-rs/tui/src/app_server_approval_conversions.rs @@ -35,12 +35,7 @@ pub(crate) fn granted_permission_profile_from_request( network: value.network.map(|network| AdditionalNetworkPermissions { enabled: network.enabled, }), - file_system: value - .file_system - .map(|file_system| AdditionalFileSystemPermissions { - read: file_system.read, - write: file_system.write, - }), + file_system: value.file_system.map(AdditionalFileSystemPermissions::from), } } @@ -82,19 +77,23 @@ mod tests { network: Some(NetworkPermissions { enabled: Some(true), }), - file_system: Some(FileSystemPermissions { - read: Some(vec![absolute_path("/tmp/read-only")]), - write: Some(vec![absolute_path("/tmp/write")]), - }), + file_system: Some(FileSystemPermissions::from_read_write_roots( + Some(vec![absolute_path("/tmp/read-only")]), + Some(vec![absolute_path("/tmp/write")]), + )), }), codex_app_server_protocol::GrantedPermissionProfile { network: Some(codex_app_server_protocol::AdditionalNetworkPermissions { enabled: Some(true), }), - file_system: Some(codex_app_server_protocol::AdditionalFileSystemPermissions { - read: Some(vec![absolute_path("/tmp/read-only")]), - write: Some(vec![absolute_path("/tmp/write")]), - }), + file_system: Some( + codex_app_server_protocol::AdditionalFileSystemPermissions::from( + FileSystemPermissions::from_read_write_roots( + Some(vec![absolute_path("/tmp/read-only")]), + Some(vec![absolute_path("/tmp/write")]), + ), + ) + ), } ); } diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index 656e812d669a..786403608595 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -20,6 +20,9 @@ use codex_features::Features; use codex_protocol::ThreadId; use codex_protocol::mcp::RequestId; use codex_protocol::models::PermissionProfile; +use codex_protocol::permissions::FileSystemAccessMode; +use codex_protocol::permissions::FileSystemPath; +use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::protocol::ElicitationAction; use codex_protocol::protocol::FileChange; use codex_protocol::protocol::NetworkApprovalContext; @@ -747,22 +750,20 @@ pub(crate) fn format_additional_permissions_rule( parts.push("network".to_string()); } if let Some(file_system) = additional_permissions.file_system.as_ref() { - if let Some(read) = file_system.read.as_ref() { - let reads = read - .iter() - .map(|path| format!("`{}`", path.display())) - .collect::>() - .join(", "); + if let Some(reads) = format_file_system_permissions(file_system, FileSystemAccessMode::Read) + { parts.push(format!("read {reads}")); } - if let Some(write) = file_system.write.as_ref() { - let writes = write - .iter() - .map(|path| format!("`{}`", path.display())) - .collect::>() - .join(", "); + if let Some(writes) = + format_file_system_permissions(file_system, FileSystemAccessMode::Write) + { parts.push(format!("write {writes}")); } + if let Some(denies) = + format_file_system_permissions(file_system, FileSystemAccessMode::None) + { + parts.push(format!("deny {denies}")); + } } if parts.is_empty() { None @@ -771,6 +772,40 @@ pub(crate) fn format_additional_permissions_rule( } } +fn format_file_system_permissions( + file_system: &codex_protocol::models::FileSystemPermissions, + access: FileSystemAccessMode, +) -> Option { + let values = file_system + .entries + .iter() + .filter(|entry| entry.access == access) + .map(|entry| format!("`{}`", format_file_system_path(&entry.path))) + .collect::>(); + (!values.is_empty()).then(|| values.join(", ")) +} + +fn format_file_system_path(path: &FileSystemPath) -> String { + match path { + FileSystemPath::Path { path } => path.display().to_string(), + FileSystemPath::Special { value } => match value { + FileSystemSpecialPath::Root => ":root".to_string(), + FileSystemSpecialPath::Minimal => ":minimal".to_string(), + FileSystemSpecialPath::CurrentWorkingDirectory => ":cwd".to_string(), + FileSystemSpecialPath::ProjectRoots { subpath } => subpath.as_ref().map_or_else( + || ":project_roots".to_string(), + |subpath| format!(":project_roots/{}", subpath.display()), + ), + FileSystemSpecialPath::Tmpdir => ":tmpdir".to_string(), + FileSystemSpecialPath::SlashTmp => "/tmp".to_string(), + FileSystemSpecialPath::Unknown { path, subpath } => subpath.as_ref().map_or_else( + || path.clone(), + |subpath| format!("{path}/{}", subpath.display()), + ), + }, + } +} + pub(crate) fn format_requested_permissions_rule( permissions: &RequestPermissionProfile, ) -> Option { @@ -852,6 +887,10 @@ mod tests { use crate::app_event::AppEvent; use codex_protocol::models::FileSystemPermissions; use codex_protocol::models::NetworkPermissions; + use codex_protocol::permissions::FileSystemAccessMode; + use codex_protocol::permissions::FileSystemPath; + use codex_protocol::permissions::FileSystemSandboxEntry; + use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::protocol::ExecPolicyAmendment; use codex_protocol::protocol::NetworkApprovalProtocol; use codex_protocol::protocol::NetworkPolicyAmendment; @@ -884,6 +923,7 @@ mod tests { [ (absolute_path("/tmp/readme.txt"), "/tmp/readme.txt"), (absolute_path("/tmp/out.txt"), "/tmp/out.txt"), + (absolute_path("/tmp/secret.txt"), "/tmp/secret.txt"), ] .into_iter() .fold(rendered, |rendered, (path, normalized)| { @@ -914,10 +954,10 @@ mod tests { network: Some(NetworkPermissions { enabled: Some(true), }), - file_system: Some(FileSystemPermissions { - read: Some(vec![absolute_path("/tmp/readme.txt")]), - write: Some(vec![absolute_path("/tmp/out.txt")]), - }), + file_system: Some(FileSystemPermissions::from_read_write_roots( + Some(vec![absolute_path("/tmp/readme.txt")]), + Some(vec![absolute_path("/tmp/out.txt")]), + )), }, } } @@ -1194,10 +1234,10 @@ mod tests { #[test] fn additional_permissions_exec_options_hide_execpolicy_amendment() { let additional_permissions = PermissionProfile { - file_system: Some(FileSystemPermissions { - read: Some(vec![absolute_path("/tmp/readme.txt")]), - write: Some(vec![absolute_path("/tmp/out.txt")]), - }), + file_system: Some(FileSystemPermissions::from_read_write_roots( + Some(vec![absolute_path("/tmp/readme.txt")]), + Some(vec![absolute_path("/tmp/out.txt")]), + )), ..Default::default() }; let options = exec_options( @@ -1275,10 +1315,10 @@ mod tests { network: Some(NetworkPermissions { enabled: Some(true), }), - file_system: Some(FileSystemPermissions { - read: Some(vec![absolute_path("/tmp/readme.txt")]), - write: Some(vec![absolute_path("/tmp/out.txt")]), - }), + file_system: Some(FileSystemPermissions::from_read_write_roots( + Some(vec![absolute_path("/tmp/readme.txt")]), + Some(vec![absolute_path("/tmp/out.txt")]), + )), }), }; @@ -1325,16 +1365,56 @@ mod tests { network: Some(NetworkPermissions { enabled: Some(true), }), + file_system: Some(FileSystemPermissions::from_read_write_roots( + Some(vec![absolute_path("/tmp/readme.txt")]), + Some(vec![absolute_path("/tmp/out.txt")]), + )), + }), + }; + + let view = ApprovalOverlay::new(exec_request, tx, Features::with_defaults()); + assert_snapshot!( + "approval_overlay_additional_permissions_prompt", + normalize_snapshot_paths(render_overlay_lines(&view, /*width*/ 120)) + ); + } + + #[test] + fn additional_permissions_special_entries_prompt_snapshot() { + let (tx, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + let exec_request = ApprovalRequest::Exec { + thread_id: ThreadId::new(), + thread_label: None, + id: "test".into(), + command: vec!["cat".into(), "/tmp/readme.txt".into()], + reason: Some("need broader filesystem access".into()), + available_decisions: vec![ReviewDecision::Approved, ReviewDecision::Abort], + network_approval_context: None, + additional_permissions: Some(PermissionProfile { file_system: Some(FileSystemPermissions { - read: Some(vec![absolute_path("/tmp/readme.txt")]), - write: Some(vec![absolute_path("/tmp/out.txt")]), + entries: vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Write, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: absolute_path("/tmp/secret.txt"), + }, + access: FileSystemAccessMode::None, + }, + ], }), + ..Default::default() }), }; let view = ApprovalOverlay::new(exec_request, tx, Features::with_defaults()); assert_snapshot!( - "approval_overlay_additional_permissions_prompt", + "approval_overlay_additional_permissions_special_entries_prompt", normalize_snapshot_paths(render_overlay_lines(&view, /*width*/ 120)) ); } diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__approval_overlay__tests__approval_overlay_additional_permissions_special_entries_prompt.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__approval_overlay__tests__approval_overlay_additional_permissions_special_entries_prompt.snap new file mode 100644 index 000000000000..0e868386cc69 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__approval_overlay__tests__approval_overlay_additional_permissions_special_entries_prompt.snap @@ -0,0 +1,16 @@ +--- +source: tui/src/bottom_pane/approval_overlay.rs +expression: "normalize_snapshot_paths(render_overlay_lines(&view, 120))" +--- + Would you like to run the following command? + + Reason: need broader filesystem access + + Permission rule: write `:root`; deny `/tmp/secret.txt` + + $ cat /tmp/readme.txt + +› 1. Yes, proceed (y) + 2. No, and tell Codex what to do differently (esc) + + Press enter to confirm or esc to cancel diff --git a/codex-rs/tui/src/chatwidget/tests/approval_requests.rs b/codex-rs/tui/src/chatwidget/tests/approval_requests.rs index 512b4159266e..06cb4ef2a9b8 100644 --- a/codex-rs/tui/src/chatwidget/tests/approval_requests.rs +++ b/codex-rs/tui/src/chatwidget/tests/approval_requests.rs @@ -110,10 +110,12 @@ fn app_server_exec_approval_request_preserves_permissions_context() { network: Some(AppServerAdditionalNetworkPermissions { enabled: Some(true), }), - file_system: Some(AppServerAdditionalFileSystemPermissions { - read: Some(vec![read_path.clone()]), - write: Some(vec![write_path.clone()]), - }), + file_system: Some(AppServerAdditionalFileSystemPermissions::from( + FileSystemPermissions::from_read_write_roots( + Some(vec![read_path.clone()]), + Some(vec![write_path.clone()]), + ), + )), }), proposed_execpolicy_amendment: None, proposed_network_policy_amendments: None, @@ -135,10 +137,10 @@ fn app_server_exec_approval_request_preserves_permissions_context() { network: Some(NetworkPermissions { enabled: Some(true), }), - file_system: Some(FileSystemPermissions { - read: Some(vec![read_path]), - write: Some(vec![write_path]), - }), + file_system: Some(FileSystemPermissions::from_read_write_roots( + Some(vec![read_path]), + Some(vec![write_path]), + )), }) ); } @@ -159,10 +161,12 @@ fn app_server_request_permissions_preserves_file_system_permissions() { network: Some(AppServerAdditionalNetworkPermissions { enabled: Some(true), }), - file_system: Some(AppServerAdditionalFileSystemPermissions { - read: Some(vec![read_path.clone()]), - write: Some(vec![write_path.clone()]), - }), + file_system: Some(AppServerAdditionalFileSystemPermissions::from( + FileSystemPermissions::from_read_write_roots( + Some(vec![read_path.clone()]), + Some(vec![write_path.clone()]), + ), + )), }, }); @@ -172,10 +176,10 @@ fn app_server_request_permissions_preserves_file_system_permissions() { network: Some(NetworkPermissions { enabled: Some(true), }), - file_system: Some(FileSystemPermissions { - read: Some(vec![read_path]), - write: Some(vec![write_path]), - }), + file_system: Some(FileSystemPermissions::from_read_write_roots( + Some(vec![read_path]), + Some(vec![write_path]), + )), } ); }