From f585fead89c389fe123028cad4748554cef976f0 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Wed, 11 Feb 2026 16:50:31 -0800 Subject: [PATCH] Make sandbox read access configurable with ReadOnlyAccess ## What This change introduces a new `ReadOnlyAccess` model and threads it through sandbox policy consumers so read access is explicit instead of implicit. - Added `ReadOnlyAccess` to protocol: - `Restricted { include_platform_defaults, readable_roots }` - `FullAccess` - Changed `SandboxPolicy` shape: - `ReadOnly` is now `ReadOnly { access: ReadOnlyAccess }` - `WorkspaceWrite` now carries `read_only_access: ReadOnlyAccess` - Kept existing behavior for now by defaulting to `ReadOnlyAccess::FullAccess` in constructors and current config/app-server mappings. - Added helper methods to compute effective readable roots (including optional platform defaults + cwd) and to detect full read access. - Updated seatbelt policy generation to honor restricted read roots by emitting scoped `(allow file-read* ...)` entries when full read access is not granted. - Updated Linux backends (`bwrap`, legacy landlock path) to fail closed with an explicit `UnsupportedOperation` when restricted read access is requested but not yet implemented there. - Updated Windows sandbox backends (standard, elevated, and runner paths) to fail closed in the same way for restricted read access. - Updated all call sites/tests/pattern matches for the new structured variants and regenerated app-server protocol schema/types. ## Why The previous `SandboxPolicy::ReadOnly` implied full-disk read access and left no way to express a narrower read surface. This refactor establishes the policy model needed to support user-configurable read restrictions in a follow-up without changing current runtime behavior. It also ensures we do not silently ignore future restricted-read policies on platform backends that do not support them yet. Failing closed keeps sandbox semantics predictable and avoids accidental over-permission. ## Compatibility and rollout notes - Existing behavior is preserved by default (`FullAccess`). - Existing config/app-server flows continue to serialize/deserialize cleanly. - New schema artifacts are included to keep generated protocol outputs in sync. ## Validation - `just fmt` - `just fix -p codex-protocol -p codex-core -p codex-linux-sandbox -p codex-windows-sandbox -p codex-app-server-protocol` - `cargo check -p codex-windows-sandbox` - Targeted crate/test runs were executed during development for protocol/core/ sandbox-related crates. --- .../schema/json/ClientRequest.json | 136 +++++++++++- .../schema/json/EventMsg.json | 69 +++++- .../schema/json/ServerNotification.json | 69 +++++- .../codex_app_server_protocol.schemas.json | 136 +++++++++++- .../json/v1/ExecOneOffCommandParams.json | 69 +++++- .../json/v1/ForkConversationResponse.json | 69 +++++- .../json/v1/ResumeConversationResponse.json | 69 +++++- .../schema/json/v1/SendUserTurnParams.json | 69 +++++- .../v1/SessionConfiguredNotification.json | 69 +++++- .../schema/json/v2/CommandExecParams.json | 67 ++++++ .../schema/json/v2/ThreadForkResponse.json | 67 ++++++ .../schema/json/v2/ThreadResumeResponse.json | 67 ++++++ .../schema/json/v2/ThreadStartResponse.json | 67 ++++++ .../schema/json/v2/TurnStartParams.json | 67 ++++++ .../schema/typescript/ReadOnlyAccess.ts | 19 ++ .../schema/typescript/SandboxPolicy.ts | 11 +- .../schema/typescript/index.ts | 1 + .../schema/typescript/v2/ReadOnlyAccess.ts | 6 + .../schema/typescript/v2/SandboxPolicy.ts | 3 +- .../schema/typescript/v2/index.ts | 1 + .../app-server-protocol/src/protocol/v2.rs | 181 +++++++++++++++- codex-rs/app-server-test-client/src/lib.rs | 9 +- .../suite/codex_message_processor_flow.rs | 1 + .../app-server/tests/suite/v2/turn_start.rs | 1 + codex-rs/config/src/config_requirements.rs | 11 +- codex-rs/core/src/config/mod.rs | 35 ++- codex-rs/core/src/config_loader/tests.rs | 3 +- codex-rs/core/src/connectors.rs | 2 +- codex-rs/core/src/exec.rs | 8 +- codex-rs/core/src/exec_policy.rs | 34 +-- codex-rs/core/src/landlock.rs | 4 +- codex-rs/core/src/mcp/mod.rs | 2 +- .../core/src/memories/startup/dispatch.rs | 1 + codex-rs/core/src/rollout/metadata.rs | 2 +- codex-rs/core/src/safety.rs | 4 +- codex-rs/core/src/seatbelt.rs | 53 ++++- codex-rs/core/src/tools/registry.rs | 2 +- codex-rs/core/src/tools/sandboxing.rs | 5 +- codex-rs/core/tests/suite/apply_patch_cli.rs | 2 + codex-rs/core/tests/suite/approvals.rs | 34 +-- codex-rs/core/tests/suite/codex_delegate.rs | 4 +- codex-rs/core/tests/suite/model_switching.rs | 12 +- .../core/tests/suite/permissions_messages.rs | 1 + codex-rs/core/tests/suite/personality.rs | 28 +-- codex-rs/core/tests/suite/prompt_caching.rs | 2 + codex-rs/core/tests/suite/rmcp_client.rs | 12 +- codex-rs/core/tests/suite/seatbelt.rs | 8 +- codex-rs/core/tests/suite/tools.rs | 2 +- codex-rs/core/tests/suite/truncation.rs | 6 +- codex-rs/core/tests/suite/unified_exec.rs | 4 +- codex-rs/core/tests/suite/web_search.rs | 29 ++- codex-rs/exec-server/src/posix/mcp.rs | 2 +- codex-rs/exec-server/tests/common/lib.rs | 3 +- .../tests/event_processor_with_json_output.rs | 2 +- codex-rs/exec/tests/suite/sandbox.rs | 8 +- codex-rs/linux-sandbox/src/bwrap.rs | 7 + codex-rs/linux-sandbox/src/landlock.rs | 14 +- codex-rs/linux-sandbox/src/linux_run_main.rs | 6 +- .../linux-sandbox/tests/suite/landlock.rs | 1 + codex-rs/mcp-server/src/outgoing_message.rs | 6 +- codex-rs/protocol/src/models.rs | 3 +- codex-rs/protocol/src/protocol.rs | 202 +++++++++++++++++- codex-rs/state/src/model/thread_metadata.rs | 2 +- codex-rs/state/src/runtime.rs | 2 +- codex-rs/tui/src/additional_dirs.rs | 6 +- codex-rs/tui/src/app.rs | 16 +- codex-rs/tui/src/chatwidget.rs | 2 +- codex-rs/tui/src/chatwidget/tests.rs | 11 +- codex-rs/tui/src/debug_config.rs | 4 +- codex-rs/tui/src/status/card.rs | 2 +- codex-rs/tui/src/status/tests.rs | 2 + codex-rs/utils/approval-presets/src/lib.rs | 2 +- .../sandbox-summary/src/sandbox_summary.rs | 4 +- codex-rs/windows-sandbox-rs/src/allow.rs | 5 + codex-rs/windows-sandbox-rs/src/audit.rs | 2 +- .../src/command_runner_win.rs | 9 +- .../windows-sandbox-rs/src/elevated_impl.rs | 10 +- codex-rs/windows-sandbox-rs/src/lib.rs | 12 +- codex-rs/windows-sandbox-rs/src/policy.rs | 7 +- 79 files changed, 1797 insertions(+), 188 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/ReadOnlyAccess.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/ReadOnlyAccess.ts diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index de746e7a78d..a63f11bccda 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1243,6 +1243,104 @@ ], "type": "string" }, + "ReadOnlyAccess": { + "oneOf": [ + { + "properties": { + "includePlatformDefaults": { + "default": true, + "type": "boolean" + }, + "readableRoots": { + "default": [], + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + }, + "type": { + "enum": [ + "restricted" + ], + "title": "RestrictedReadOnlyAccessType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "RestrictedReadOnlyAccess", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "fullAccess" + ], + "title": "FullAccessReadOnlyAccessType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "FullAccessReadOnlyAccess", + "type": "object" + } + ] + }, + "ReadOnlyAccess2": { + "description": "Determines how read-only file access is granted inside a restricted sandbox.", + "oneOf": [ + { + "description": "Restrict reads to an explicit set of roots.\n\nWhen `include_platform_defaults` is `true`, platform defaults required for basic execution are included in addition to `readable_roots`.", + "properties": { + "include_platform_defaults": { + "default": true, + "description": "Include built-in platform read roots required for basic process execution.", + "type": "boolean" + }, + "readable_roots": { + "description": "Additional absolute roots that should be readable.", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + }, + "type": { + "enum": [ + "restricted" + ], + "title": "RestrictedReadOnlyAccess2Type", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "RestrictedReadOnlyAccess2", + "type": "object" + }, + { + "description": "Allow unrestricted file reads.", + "properties": { + "type": { + "enum": [ + "full-access" + ], + "title": "FullAccessReadOnlyAccess2Type", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "FullAccessReadOnlyAccess2", + "type": "object" + } + ] + }, "ReasoningEffort": { "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", "enum": [ @@ -1922,6 +2020,16 @@ }, { "properties": { + "access": { + "allOf": [ + { + "$ref": "#/definitions/ReadOnlyAccess" + } + ], + "default": { + "type": "fullAccess" + } + }, "type": { "enum": [ "readOnly" @@ -1974,6 +2082,16 @@ "default": false, "type": "boolean" }, + "readOnlyAccess": { + "allOf": [ + { + "$ref": "#/definitions/ReadOnlyAccess" + } + ], + "default": { + "type": "fullAccess" + } + }, "type": { "enum": [ "workspaceWrite" @@ -2018,8 +2136,16 @@ "type": "object" }, { - "description": "Read-only access to the entire file-system.", + "description": "Read-only access configuration.", "properties": { + "access": { + "allOf": [ + { + "$ref": "#/definitions/ReadOnlyAccess2" + } + ], + "description": "Read access granted while running under this policy." + }, "type": { "enum": [ "read-only" @@ -2078,6 +2204,14 @@ "description": "When set to `true`, outbound network access is allowed. `false` by default.", "type": "boolean" }, + "read_only_access": { + "allOf": [ + { + "$ref": "#/definitions/ReadOnlyAccess2" + } + ], + "description": "Read access granted while running under this policy." + }, "type": { "enum": [ "workspace-write" diff --git a/codex-rs/app-server-protocol/schema/json/EventMsg.json b/codex-rs/app-server-protocol/schema/json/EventMsg.json index 32ae8b715c9..e3e9a6e1eac 100644 --- a/codex-rs/app-server-protocol/schema/json/EventMsg.json +++ b/codex-rs/app-server-protocol/schema/json/EventMsg.json @@ -3544,6 +3544,57 @@ ], "type": "object" }, + "ReadOnlyAccess": { + "description": "Determines how read-only file access is granted inside a restricted sandbox.", + "oneOf": [ + { + "description": "Restrict reads to an explicit set of roots.\n\nWhen `include_platform_defaults` is `true`, platform defaults required for basic execution are included in addition to `readable_roots`.", + "properties": { + "include_platform_defaults": { + "default": true, + "description": "Include built-in platform read roots required for basic process execution.", + "type": "boolean" + }, + "readable_roots": { + "description": "Additional absolute roots that should be readable.", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + }, + "type": { + "enum": [ + "restricted" + ], + "title": "RestrictedReadOnlyAccessType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "RestrictedReadOnlyAccess", + "type": "object" + }, + { + "description": "Allow unrestricted file reads.", + "properties": { + "type": { + "enum": [ + "full-access" + ], + "title": "FullAccessReadOnlyAccessType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "FullAccessReadOnlyAccess", + "type": "object" + } + ] + }, "ReasoningEffort": { "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", "enum": [ @@ -4381,8 +4432,16 @@ "type": "object" }, { - "description": "Read-only access to the entire file-system.", + "description": "Read-only access configuration.", "properties": { + "access": { + "allOf": [ + { + "$ref": "#/definitions/ReadOnlyAccess" + } + ], + "description": "Read access granted while running under this policy." + }, "type": { "enum": [ "read-only" @@ -4441,6 +4500,14 @@ "description": "When set to `true`, outbound network access is allowed. `false` by default.", "type": "boolean" }, + "read_only_access": { + "allOf": [ + { + "$ref": "#/definitions/ReadOnlyAccess" + } + ], + "description": "Read access granted while running under this policy." + }, "type": { "enum": [ "workspace-write" diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 71bed38d7ec..e90b50b47cb 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -4582,6 +4582,57 @@ ], "type": "object" }, + "ReadOnlyAccess": { + "description": "Determines how read-only file access is granted inside a restricted sandbox.", + "oneOf": [ + { + "description": "Restrict reads to an explicit set of roots.\n\nWhen `include_platform_defaults` is `true`, platform defaults required for basic execution are included in addition to `readable_roots`.", + "properties": { + "include_platform_defaults": { + "default": true, + "description": "Include built-in platform read roots required for basic process execution.", + "type": "boolean" + }, + "readable_roots": { + "description": "Additional absolute roots that should be readable.", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + }, + "type": { + "enum": [ + "restricted" + ], + "title": "RestrictedReadOnlyAccessType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "RestrictedReadOnlyAccess", + "type": "object" + }, + { + "description": "Allow unrestricted file reads.", + "properties": { + "type": { + "enum": [ + "full-access" + ], + "title": "FullAccessReadOnlyAccessType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "FullAccessReadOnlyAccess", + "type": "object" + } + ] + }, "ReasoningEffort": { "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", "enum": [ @@ -5499,8 +5550,16 @@ "type": "object" }, { - "description": "Read-only access to the entire file-system.", + "description": "Read-only access configuration.", "properties": { + "access": { + "allOf": [ + { + "$ref": "#/definitions/ReadOnlyAccess" + } + ], + "description": "Read access granted while running under this policy." + }, "type": { "enum": [ "read-only" @@ -5559,6 +5618,14 @@ "description": "When set to `true`, outbound network access is allowed. `false` by default.", "type": "boolean" }, + "read_only_access": { + "allOf": [ + { + "$ref": "#/definitions/ReadOnlyAccess" + } + ], + "description": "Read access granted while running under this policy." + }, "type": { "enum": [ "workspace-write" 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 dd63418f44d..f3f1f57f398 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 @@ -6629,6 +6629,57 @@ ], "type": "object" }, + "ReadOnlyAccess": { + "description": "Determines how read-only file access is granted inside a restricted sandbox.", + "oneOf": [ + { + "description": "Restrict reads to an explicit set of roots.\n\nWhen `include_platform_defaults` is `true`, platform defaults required for basic execution are included in addition to `readable_roots`.", + "properties": { + "include_platform_defaults": { + "default": true, + "description": "Include built-in platform read roots required for basic process execution.", + "type": "boolean" + }, + "readable_roots": { + "description": "Additional absolute roots that should be readable.", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + }, + "type": { + "enum": [ + "restricted" + ], + "title": "RestrictedReadOnlyAccessType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "RestrictedReadOnlyAccess", + "type": "object" + }, + { + "description": "Allow unrestricted file reads.", + "properties": { + "type": { + "enum": [ + "full-access" + ], + "title": "FullAccessReadOnlyAccessType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "FullAccessReadOnlyAccess", + "type": "object" + } + ] + }, "ReasoningEffort": { "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", "enum": [ @@ -7642,8 +7693,16 @@ "type": "object" }, { - "description": "Read-only access to the entire file-system.", + "description": "Read-only access configuration.", "properties": { + "access": { + "allOf": [ + { + "$ref": "#/definitions/ReadOnlyAccess" + } + ], + "description": "Read access granted while running under this policy." + }, "type": { "enum": [ "read-only" @@ -7702,6 +7761,14 @@ "description": "When set to `true`, outbound network access is allowed. `false` by default.", "type": "boolean" }, + "read_only_access": { + "allOf": [ + { + "$ref": "#/definitions/ReadOnlyAccess" + } + ], + "description": "Read access granted while running under this policy." + }, "type": { "enum": [ "workspace-write" @@ -12971,6 +13038,53 @@ "title": "RawResponseItemCompletedNotification", "type": "object" }, + "ReadOnlyAccess": { + "oneOf": [ + { + "properties": { + "includePlatformDefaults": { + "default": true, + "type": "boolean" + }, + "readableRoots": { + "default": [], + "items": { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + "type": "array" + }, + "type": { + "enum": [ + "restricted" + ], + "title": "RestrictedReadOnlyAccessType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "RestrictedReadOnlyAccess", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "fullAccess" + ], + "title": "FullAccessReadOnlyAccessType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "FullAccessReadOnlyAccess", + "type": "object" + } + ] + }, "ReasoningEffort": { "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", "enum": [ @@ -13811,6 +13925,16 @@ }, { "properties": { + "access": { + "allOf": [ + { + "$ref": "#/definitions/v2/ReadOnlyAccess" + } + ], + "default": { + "type": "fullAccess" + } + }, "type": { "enum": [ "readOnly" @@ -13863,6 +13987,16 @@ "default": false, "type": "boolean" }, + "readOnlyAccess": { + "allOf": [ + { + "$ref": "#/definitions/v2/ReadOnlyAccess" + } + ], + "default": { + "type": "fullAccess" + } + }, "type": { "enum": [ "workspaceWrite" diff --git a/codex-rs/app-server-protocol/schema/json/v1/ExecOneOffCommandParams.json b/codex-rs/app-server-protocol/schema/json/v1/ExecOneOffCommandParams.json index a325704be49..ec5b7893529 100644 --- a/codex-rs/app-server-protocol/schema/json/v1/ExecOneOffCommandParams.json +++ b/codex-rs/app-server-protocol/schema/json/v1/ExecOneOffCommandParams.json @@ -13,6 +13,57 @@ ], "type": "string" }, + "ReadOnlyAccess": { + "description": "Determines how read-only file access is granted inside a restricted sandbox.", + "oneOf": [ + { + "description": "Restrict reads to an explicit set of roots.\n\nWhen `include_platform_defaults` is `true`, platform defaults required for basic execution are included in addition to `readable_roots`.", + "properties": { + "include_platform_defaults": { + "default": true, + "description": "Include built-in platform read roots required for basic process execution.", + "type": "boolean" + }, + "readable_roots": { + "description": "Additional absolute roots that should be readable.", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + }, + "type": { + "enum": [ + "restricted" + ], + "title": "RestrictedReadOnlyAccessType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "RestrictedReadOnlyAccess", + "type": "object" + }, + { + "description": "Allow unrestricted file reads.", + "properties": { + "type": { + "enum": [ + "full-access" + ], + "title": "FullAccessReadOnlyAccessType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "FullAccessReadOnlyAccess", + "type": "object" + } + ] + }, "SandboxPolicy": { "description": "Determines execution restrictions for model shell commands.", "oneOf": [ @@ -34,8 +85,16 @@ "type": "object" }, { - "description": "Read-only access to the entire file-system.", + "description": "Read-only access configuration.", "properties": { + "access": { + "allOf": [ + { + "$ref": "#/definitions/ReadOnlyAccess" + } + ], + "description": "Read access granted while running under this policy." + }, "type": { "enum": [ "read-only" @@ -94,6 +153,14 @@ "description": "When set to `true`, outbound network access is allowed. `false` by default.", "type": "boolean" }, + "read_only_access": { + "allOf": [ + { + "$ref": "#/definitions/ReadOnlyAccess" + } + ], + "description": "Read access granted while running under this policy." + }, "type": { "enum": [ "workspace-write" diff --git a/codex-rs/app-server-protocol/schema/json/v1/ForkConversationResponse.json b/codex-rs/app-server-protocol/schema/json/v1/ForkConversationResponse.json index 54b02199313..2122dcf1899 100644 --- a/codex-rs/app-server-protocol/schema/json/v1/ForkConversationResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v1/ForkConversationResponse.json @@ -3544,6 +3544,57 @@ ], "type": "object" }, + "ReadOnlyAccess": { + "description": "Determines how read-only file access is granted inside a restricted sandbox.", + "oneOf": [ + { + "description": "Restrict reads to an explicit set of roots.\n\nWhen `include_platform_defaults` is `true`, platform defaults required for basic execution are included in addition to `readable_roots`.", + "properties": { + "include_platform_defaults": { + "default": true, + "description": "Include built-in platform read roots required for basic process execution.", + "type": "boolean" + }, + "readable_roots": { + "description": "Additional absolute roots that should be readable.", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + }, + "type": { + "enum": [ + "restricted" + ], + "title": "RestrictedReadOnlyAccessType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "RestrictedReadOnlyAccess", + "type": "object" + }, + { + "description": "Allow unrestricted file reads.", + "properties": { + "type": { + "enum": [ + "full-access" + ], + "title": "FullAccessReadOnlyAccessType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "FullAccessReadOnlyAccess", + "type": "object" + } + ] + }, "ReasoningEffort": { "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", "enum": [ @@ -4381,8 +4432,16 @@ "type": "object" }, { - "description": "Read-only access to the entire file-system.", + "description": "Read-only access configuration.", "properties": { + "access": { + "allOf": [ + { + "$ref": "#/definitions/ReadOnlyAccess" + } + ], + "description": "Read access granted while running under this policy." + }, "type": { "enum": [ "read-only" @@ -4441,6 +4500,14 @@ "description": "When set to `true`, outbound network access is allowed. `false` by default.", "type": "boolean" }, + "read_only_access": { + "allOf": [ + { + "$ref": "#/definitions/ReadOnlyAccess" + } + ], + "description": "Read access granted while running under this policy." + }, "type": { "enum": [ "workspace-write" diff --git a/codex-rs/app-server-protocol/schema/json/v1/ResumeConversationResponse.json b/codex-rs/app-server-protocol/schema/json/v1/ResumeConversationResponse.json index aaf063dacc5..1610c21b5c8 100644 --- a/codex-rs/app-server-protocol/schema/json/v1/ResumeConversationResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v1/ResumeConversationResponse.json @@ -3544,6 +3544,57 @@ ], "type": "object" }, + "ReadOnlyAccess": { + "description": "Determines how read-only file access is granted inside a restricted sandbox.", + "oneOf": [ + { + "description": "Restrict reads to an explicit set of roots.\n\nWhen `include_platform_defaults` is `true`, platform defaults required for basic execution are included in addition to `readable_roots`.", + "properties": { + "include_platform_defaults": { + "default": true, + "description": "Include built-in platform read roots required for basic process execution.", + "type": "boolean" + }, + "readable_roots": { + "description": "Additional absolute roots that should be readable.", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + }, + "type": { + "enum": [ + "restricted" + ], + "title": "RestrictedReadOnlyAccessType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "RestrictedReadOnlyAccess", + "type": "object" + }, + { + "description": "Allow unrestricted file reads.", + "properties": { + "type": { + "enum": [ + "full-access" + ], + "title": "FullAccessReadOnlyAccessType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "FullAccessReadOnlyAccess", + "type": "object" + } + ] + }, "ReasoningEffort": { "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", "enum": [ @@ -4381,8 +4432,16 @@ "type": "object" }, { - "description": "Read-only access to the entire file-system.", + "description": "Read-only access configuration.", "properties": { + "access": { + "allOf": [ + { + "$ref": "#/definitions/ReadOnlyAccess" + } + ], + "description": "Read access granted while running under this policy." + }, "type": { "enum": [ "read-only" @@ -4441,6 +4500,14 @@ "description": "When set to `true`, outbound network access is allowed. `false` by default.", "type": "boolean" }, + "read_only_access": { + "allOf": [ + { + "$ref": "#/definitions/ReadOnlyAccess" + } + ], + "description": "Read access granted while running under this policy." + }, "type": { "enum": [ "workspace-write" diff --git a/codex-rs/app-server-protocol/schema/json/v1/SendUserTurnParams.json b/codex-rs/app-server-protocol/schema/json/v1/SendUserTurnParams.json index d56ae933bd8..b2fc2486632 100644 --- a/codex-rs/app-server-protocol/schema/json/v1/SendUserTurnParams.json +++ b/codex-rs/app-server-protocol/schema/json/v1/SendUserTurnParams.json @@ -142,6 +142,57 @@ ], "type": "string" }, + "ReadOnlyAccess": { + "description": "Determines how read-only file access is granted inside a restricted sandbox.", + "oneOf": [ + { + "description": "Restrict reads to an explicit set of roots.\n\nWhen `include_platform_defaults` is `true`, platform defaults required for basic execution are included in addition to `readable_roots`.", + "properties": { + "include_platform_defaults": { + "default": true, + "description": "Include built-in platform read roots required for basic process execution.", + "type": "boolean" + }, + "readable_roots": { + "description": "Additional absolute roots that should be readable.", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + }, + "type": { + "enum": [ + "restricted" + ], + "title": "RestrictedReadOnlyAccessType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "RestrictedReadOnlyAccess", + "type": "object" + }, + { + "description": "Allow unrestricted file reads.", + "properties": { + "type": { + "enum": [ + "full-access" + ], + "title": "FullAccessReadOnlyAccessType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "FullAccessReadOnlyAccess", + "type": "object" + } + ] + }, "ReasoningEffort": { "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", "enum": [ @@ -195,8 +246,16 @@ "type": "object" }, { - "description": "Read-only access to the entire file-system.", + "description": "Read-only access configuration.", "properties": { + "access": { + "allOf": [ + { + "$ref": "#/definitions/ReadOnlyAccess" + } + ], + "description": "Read access granted while running under this policy." + }, "type": { "enum": [ "read-only" @@ -255,6 +314,14 @@ "description": "When set to `true`, outbound network access is allowed. `false` by default.", "type": "boolean" }, + "read_only_access": { + "allOf": [ + { + "$ref": "#/definitions/ReadOnlyAccess" + } + ], + "description": "Read access granted while running under this policy." + }, "type": { "enum": [ "workspace-write" diff --git a/codex-rs/app-server-protocol/schema/json/v1/SessionConfiguredNotification.json b/codex-rs/app-server-protocol/schema/json/v1/SessionConfiguredNotification.json index 07ab5710b9c..aef20e2eadb 100644 --- a/codex-rs/app-server-protocol/schema/json/v1/SessionConfiguredNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v1/SessionConfiguredNotification.json @@ -3544,6 +3544,57 @@ ], "type": "object" }, + "ReadOnlyAccess": { + "description": "Determines how read-only file access is granted inside a restricted sandbox.", + "oneOf": [ + { + "description": "Restrict reads to an explicit set of roots.\n\nWhen `include_platform_defaults` is `true`, platform defaults required for basic execution are included in addition to `readable_roots`.", + "properties": { + "include_platform_defaults": { + "default": true, + "description": "Include built-in platform read roots required for basic process execution.", + "type": "boolean" + }, + "readable_roots": { + "description": "Additional absolute roots that should be readable.", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + }, + "type": { + "enum": [ + "restricted" + ], + "title": "RestrictedReadOnlyAccessType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "RestrictedReadOnlyAccess", + "type": "object" + }, + { + "description": "Allow unrestricted file reads.", + "properties": { + "type": { + "enum": [ + "full-access" + ], + "title": "FullAccessReadOnlyAccessType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "FullAccessReadOnlyAccess", + "type": "object" + } + ] + }, "ReasoningEffort": { "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", "enum": [ @@ -4381,8 +4432,16 @@ "type": "object" }, { - "description": "Read-only access to the entire file-system.", + "description": "Read-only access configuration.", "properties": { + "access": { + "allOf": [ + { + "$ref": "#/definitions/ReadOnlyAccess" + } + ], + "description": "Read access granted while running under this policy." + }, "type": { "enum": [ "read-only" @@ -4441,6 +4500,14 @@ "description": "When set to `true`, outbound network access is allowed. `false` by default.", "type": "boolean" }, + "read_only_access": { + "allOf": [ + { + "$ref": "#/definitions/ReadOnlyAccess" + } + ], + "description": "Read access granted while running under this policy." + }, "type": { "enum": [ "workspace-write" diff --git a/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json b/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json index 6dd8fb7bc84..4528b341597 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json @@ -12,6 +12,53 @@ ], "type": "string" }, + "ReadOnlyAccess": { + "oneOf": [ + { + "properties": { + "includePlatformDefaults": { + "default": true, + "type": "boolean" + }, + "readableRoots": { + "default": [], + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + }, + "type": { + "enum": [ + "restricted" + ], + "title": "RestrictedReadOnlyAccessType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "RestrictedReadOnlyAccess", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "fullAccess" + ], + "title": "FullAccessReadOnlyAccessType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "FullAccessReadOnlyAccess", + "type": "object" + } + ] + }, "SandboxPolicy": { "oneOf": [ { @@ -32,6 +79,16 @@ }, { "properties": { + "access": { + "allOf": [ + { + "$ref": "#/definitions/ReadOnlyAccess" + } + ], + "default": { + "type": "fullAccess" + } + }, "type": { "enum": [ "readOnly" @@ -84,6 +141,16 @@ "default": false, "type": "boolean" }, + "readOnlyAccess": { + "allOf": [ + { + "$ref": "#/definitions/ReadOnlyAccess" + } + ], + "default": { + "type": "fullAccess" + } + }, "type": { "enum": [ "workspaceWrite" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index 094db44d730..06d4c9cffa0 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -488,6 +488,53 @@ } ] }, + "ReadOnlyAccess": { + "oneOf": [ + { + "properties": { + "includePlatformDefaults": { + "default": true, + "type": "boolean" + }, + "readableRoots": { + "default": [], + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + }, + "type": { + "enum": [ + "restricted" + ], + "title": "RestrictedReadOnlyAccessType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "RestrictedReadOnlyAccess", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "fullAccess" + ], + "title": "FullAccessReadOnlyAccessType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "FullAccessReadOnlyAccess", + "type": "object" + } + ] + }, "ReasoningEffort": { "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", "enum": [ @@ -520,6 +567,16 @@ }, { "properties": { + "access": { + "allOf": [ + { + "$ref": "#/definitions/ReadOnlyAccess" + } + ], + "default": { + "type": "fullAccess" + } + }, "type": { "enum": [ "readOnly" @@ -572,6 +629,16 @@ "default": false, "type": "boolean" }, + "readOnlyAccess": { + "allOf": [ + { + "$ref": "#/definitions/ReadOnlyAccess" + } + ], + "default": { + "type": "fullAccess" + } + }, "type": { "enum": [ "workspaceWrite" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index 901fc829c32..8977c048a33 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -488,6 +488,53 @@ } ] }, + "ReadOnlyAccess": { + "oneOf": [ + { + "properties": { + "includePlatformDefaults": { + "default": true, + "type": "boolean" + }, + "readableRoots": { + "default": [], + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + }, + "type": { + "enum": [ + "restricted" + ], + "title": "RestrictedReadOnlyAccessType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "RestrictedReadOnlyAccess", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "fullAccess" + ], + "title": "FullAccessReadOnlyAccessType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "FullAccessReadOnlyAccess", + "type": "object" + } + ] + }, "ReasoningEffort": { "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", "enum": [ @@ -520,6 +567,16 @@ }, { "properties": { + "access": { + "allOf": [ + { + "$ref": "#/definitions/ReadOnlyAccess" + } + ], + "default": { + "type": "fullAccess" + } + }, "type": { "enum": [ "readOnly" @@ -572,6 +629,16 @@ "default": false, "type": "boolean" }, + "readOnlyAccess": { + "allOf": [ + { + "$ref": "#/definitions/ReadOnlyAccess" + } + ], + "default": { + "type": "fullAccess" + } + }, "type": { "enum": [ "workspaceWrite" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index 97599464870..f2dbee3ca3a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -488,6 +488,53 @@ } ] }, + "ReadOnlyAccess": { + "oneOf": [ + { + "properties": { + "includePlatformDefaults": { + "default": true, + "type": "boolean" + }, + "readableRoots": { + "default": [], + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + }, + "type": { + "enum": [ + "restricted" + ], + "title": "RestrictedReadOnlyAccessType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "RestrictedReadOnlyAccess", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "fullAccess" + ], + "title": "FullAccessReadOnlyAccessType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "FullAccessReadOnlyAccess", + "type": "object" + } + ] + }, "ReasoningEffort": { "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", "enum": [ @@ -520,6 +567,16 @@ }, { "properties": { + "access": { + "allOf": [ + { + "$ref": "#/definitions/ReadOnlyAccess" + } + ], + "default": { + "type": "fullAccess" + } + }, "type": { "enum": [ "readOnly" @@ -572,6 +629,16 @@ "default": false, "type": "boolean" }, + "readOnlyAccess": { + "allOf": [ + { + "$ref": "#/definitions/ReadOnlyAccess" + } + ], + "default": { + "type": "fullAccess" + } + }, "type": { "enum": [ "workspaceWrite" diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index d1b24561466..a1363f97d7f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -72,6 +72,53 @@ ], "type": "string" }, + "ReadOnlyAccess": { + "oneOf": [ + { + "properties": { + "includePlatformDefaults": { + "default": true, + "type": "boolean" + }, + "readableRoots": { + "default": [], + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + }, + "type": { + "enum": [ + "restricted" + ], + "title": "RestrictedReadOnlyAccessType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "RestrictedReadOnlyAccess", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "fullAccess" + ], + "title": "FullAccessReadOnlyAccessType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "FullAccessReadOnlyAccess", + "type": "object" + } + ] + }, "ReasoningEffort": { "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", "enum": [ @@ -124,6 +171,16 @@ }, { "properties": { + "access": { + "allOf": [ + { + "$ref": "#/definitions/ReadOnlyAccess" + } + ], + "default": { + "type": "fullAccess" + } + }, "type": { "enum": [ "readOnly" @@ -176,6 +233,16 @@ "default": false, "type": "boolean" }, + "readOnlyAccess": { + "allOf": [ + { + "$ref": "#/definitions/ReadOnlyAccess" + } + ], + "default": { + "type": "fullAccess" + } + }, "type": { "enum": [ "workspaceWrite" diff --git a/codex-rs/app-server-protocol/schema/typescript/ReadOnlyAccess.ts b/codex-rs/app-server-protocol/schema/typescript/ReadOnlyAccess.ts new file mode 100644 index 00000000000..c01bdd37c68 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/ReadOnlyAccess.ts @@ -0,0 +1,19 @@ +// 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"; + +/** + * Determines how read-only file access is granted inside a restricted + * sandbox. + */ +export type ReadOnlyAccess = { "type": "restricted", +/** + * Include built-in platform read roots required for basic process + * execution. + */ +include_platform_defaults: boolean, +/** + * Additional absolute roots that should be readable. + */ +readable_roots?: Array, } | { "type": "full-access" }; diff --git a/codex-rs/app-server-protocol/schema/typescript/SandboxPolicy.ts b/codex-rs/app-server-protocol/schema/typescript/SandboxPolicy.ts index 103a6863f4c..743ad222294 100644 --- a/codex-rs/app-server-protocol/schema/typescript/SandboxPolicy.ts +++ b/codex-rs/app-server-protocol/schema/typescript/SandboxPolicy.ts @@ -3,11 +3,16 @@ // 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 { NetworkAccess } from "./NetworkAccess"; +import type { ReadOnlyAccess } from "./ReadOnlyAccess"; /** * Determines execution restrictions for model shell commands. */ -export type SandboxPolicy = { "type": "danger-full-access" } | { "type": "read-only" } | { "type": "external-sandbox", +export type SandboxPolicy = { "type": "danger-full-access" } | { "type": "read-only", +/** + * Read access granted while running under this policy. + */ +access?: ReadOnlyAccess, } | { "type": "external-sandbox", /** * Whether the external sandbox permits outbound network traffic. */ @@ -17,6 +22,10 @@ network_access: NetworkAccess, } | { "type": "workspace-write", * writable from within the sandbox. */ writable_roots?: Array, +/** + * Read access granted while running under this policy. + */ +read_only_access?: ReadOnlyAccess, /** * When set to `true`, outbound network access is allowed. `false` by * default. diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 895936de2b4..03675b3e100 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -137,6 +137,7 @@ export type { Profile } from "./Profile"; export type { RateLimitSnapshot } from "./RateLimitSnapshot"; export type { RateLimitWindow } from "./RateLimitWindow"; export type { RawResponseItemEvent } from "./RawResponseItemEvent"; +export type { ReadOnlyAccess } from "./ReadOnlyAccess"; export type { ReasoningContentDeltaEvent } from "./ReasoningContentDeltaEvent"; export type { ReasoningEffort } from "./ReasoningEffort"; export type { ReasoningItem } from "./ReasoningItem"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ReadOnlyAccess.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ReadOnlyAccess.ts new file mode 100644 index 00000000000..78fa04ff379 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ReadOnlyAccess.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AbsolutePathBuf } from "../AbsolutePathBuf"; + +export type ReadOnlyAccess = { "type": "restricted", includePlatformDefaults: boolean, readableRoots: Array, } | { "type": "fullAccess" }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/SandboxPolicy.ts b/codex-rs/app-server-protocol/schema/typescript/v2/SandboxPolicy.ts index 199d7f2a522..c81c2642d22 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/SandboxPolicy.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/SandboxPolicy.ts @@ -3,5 +3,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 { NetworkAccess } from "./NetworkAccess"; +import type { ReadOnlyAccess } from "./ReadOnlyAccess"; -export type SandboxPolicy = { "type": "dangerFullAccess" } | { "type": "readOnly" } | { "type": "externalSandbox", networkAccess: NetworkAccess, } | { "type": "workspaceWrite", writableRoots: Array, networkAccess: boolean, excludeTmpdirEnvVar: boolean, excludeSlashTmp: boolean, }; +export type SandboxPolicy = { "type": "dangerFullAccess" } | { "type": "readOnly", access: ReadOnlyAccess, } | { "type": "externalSandbox", networkAccess: NetworkAccess, } | { "type": "workspaceWrite", writableRoots: Array, readOnlyAccess: ReadOnlyAccess, networkAccess: boolean, excludeTmpdirEnvVar: boolean, excludeSlashTmp: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index f54d94e7f53..500b1d930ff 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -101,6 +101,7 @@ export type { ProfileV2 } from "./ProfileV2"; export type { RateLimitSnapshot } from "./RateLimitSnapshot"; export type { RateLimitWindow } from "./RateLimitWindow"; export type { RawResponseItemCompletedNotification } from "./RawResponseItemCompletedNotification"; +export type { ReadOnlyAccess } from "./ReadOnlyAccess"; export type { ReasoningEffortOption } from "./ReasoningEffortOption"; export type { ReasoningSummaryPartAddedNotification } from "./ReasoningSummaryPartAddedNotification"; export type { ReasoningSummaryTextDeltaNotification } from "./ReasoningSummaryTextDeltaNotification"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index bfc4b7a80fa..c1060bde483 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -32,6 +32,7 @@ use codex_protocol::protocol::CreditsSnapshot as CoreCreditsSnapshot; use codex_protocol::protocol::NetworkAccess as CoreNetworkAccess; use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot; use codex_protocol::protocol::RateLimitWindow as CoreRateLimitWindow; +use codex_protocol::protocol::ReadOnlyAccess as CoreReadOnlyAccess; use codex_protocol::protocol::SessionSource as CoreSessionSource; use codex_protocol::protocol::SkillDependencies as CoreSkillDependencies; use codex_protocol::protocol::SkillErrorInfo as CoreSkillErrorInfo; @@ -404,6 +405,10 @@ const fn default_enabled() -> bool { true } +const fn default_include_platform_defaults() -> bool { + true +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] #[serde(rename_all = "snake_case")] #[ts(export_to = "v2/")] @@ -647,13 +652,65 @@ pub enum NetworkAccess { Enabled, } +#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum ReadOnlyAccess { + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Restricted { + #[serde(default = "default_include_platform_defaults")] + include_platform_defaults: bool, + #[serde(default)] + readable_roots: Vec, + }, + #[default] + FullAccess, +} + +impl ReadOnlyAccess { + pub fn to_core(&self) -> CoreReadOnlyAccess { + match self { + ReadOnlyAccess::Restricted { + include_platform_defaults, + readable_roots, + } => CoreReadOnlyAccess::Restricted { + include_platform_defaults: *include_platform_defaults, + readable_roots: readable_roots.clone(), + }, + ReadOnlyAccess::FullAccess => CoreReadOnlyAccess::FullAccess, + } + } +} + +impl From for ReadOnlyAccess { + fn from(value: CoreReadOnlyAccess) -> Self { + match value { + CoreReadOnlyAccess::Restricted { + include_platform_defaults, + readable_roots, + } => ReadOnlyAccess::Restricted { + include_platform_defaults, + readable_roots, + }, + CoreReadOnlyAccess::FullAccess => ReadOnlyAccess::FullAccess, + } + } +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(tag = "type", rename_all = "camelCase")] #[ts(tag = "type")] #[ts(export_to = "v2/")] pub enum SandboxPolicy { DangerFullAccess, - ReadOnly, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + ReadOnly { + #[serde(default)] + access: ReadOnlyAccess, + }, #[serde(rename_all = "camelCase")] #[ts(rename_all = "camelCase")] ExternalSandbox { @@ -666,6 +723,8 @@ pub enum SandboxPolicy { #[serde(default)] writable_roots: Vec, #[serde(default)] + read_only_access: ReadOnlyAccess, + #[serde(default)] network_access: bool, #[serde(default)] exclude_tmpdir_env_var: bool, @@ -680,7 +739,11 @@ impl SandboxPolicy { SandboxPolicy::DangerFullAccess => { codex_protocol::protocol::SandboxPolicy::DangerFullAccess } - SandboxPolicy::ReadOnly => codex_protocol::protocol::SandboxPolicy::ReadOnly, + SandboxPolicy::ReadOnly { access } => { + codex_protocol::protocol::SandboxPolicy::ReadOnly { + access: access.to_core(), + } + } SandboxPolicy::ExternalSandbox { network_access } => { codex_protocol::protocol::SandboxPolicy::ExternalSandbox { network_access: match network_access { @@ -691,11 +754,13 @@ impl SandboxPolicy { } SandboxPolicy::WorkspaceWrite { writable_roots, + read_only_access, network_access, exclude_tmpdir_env_var, exclude_slash_tmp, } => codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { writable_roots: writable_roots.clone(), + read_only_access: read_only_access.to_core(), network_access: *network_access, exclude_tmpdir_env_var: *exclude_tmpdir_env_var, exclude_slash_tmp: *exclude_slash_tmp, @@ -710,7 +775,11 @@ impl From for SandboxPolicy { codex_protocol::protocol::SandboxPolicy::DangerFullAccess => { SandboxPolicy::DangerFullAccess } - codex_protocol::protocol::SandboxPolicy::ReadOnly => SandboxPolicy::ReadOnly, + codex_protocol::protocol::SandboxPolicy::ReadOnly { access } => { + SandboxPolicy::ReadOnly { + access: ReadOnlyAccess::from(access), + } + } codex_protocol::protocol::SandboxPolicy::ExternalSandbox { network_access } => { SandboxPolicy::ExternalSandbox { network_access: match network_access { @@ -721,11 +790,13 @@ impl From for SandboxPolicy { } codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { writable_roots, + read_only_access, network_access, exclude_tmpdir_env_var, exclude_slash_tmp, } => SandboxPolicy::WorkspaceWrite { writable_roots, + read_only_access: ReadOnlyAccess::from(read_only_access), network_access, exclude_tmpdir_env_var, exclude_slash_tmp, @@ -3235,11 +3306,21 @@ mod tests { use codex_protocol::items::WebSearchItem; use codex_protocol::models::WebSearchAction as CoreWebSearchAction; use codex_protocol::protocol::NetworkAccess as CoreNetworkAccess; + use codex_protocol::protocol::ReadOnlyAccess as CoreReadOnlyAccess; use codex_protocol::user_input::UserInput as CoreUserInput; use pretty_assertions::assert_eq; use serde_json::json; use std::path::PathBuf; + fn test_absolute_path() -> AbsolutePathBuf { + let path = if cfg!(windows) { + r"C:\readable" + } else { + "/readable" + }; + AbsolutePathBuf::from_absolute_path(path).expect("path must be absolute") + } + #[test] fn sandbox_policy_round_trips_external_sandbox_network_access() { let v2_policy = SandboxPolicy::ExternalSandbox { @@ -3258,6 +3339,100 @@ mod tests { assert_eq!(back_to_v2, v2_policy); } + #[test] + fn sandbox_policy_round_trips_read_only_access() { + let readable_root = test_absolute_path(); + let v2_policy = SandboxPolicy::ReadOnly { + access: ReadOnlyAccess::Restricted { + include_platform_defaults: false, + readable_roots: vec![readable_root.clone()], + }, + }; + + let core_policy = v2_policy.to_core(); + assert_eq!( + core_policy, + codex_protocol::protocol::SandboxPolicy::ReadOnly { + access: CoreReadOnlyAccess::Restricted { + include_platform_defaults: false, + readable_roots: vec![readable_root], + }, + } + ); + + let back_to_v2 = SandboxPolicy::from(core_policy); + assert_eq!(back_to_v2, v2_policy); + } + + #[test] + fn sandbox_policy_round_trips_workspace_write_read_only_access() { + let readable_root = test_absolute_path(); + let v2_policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + read_only_access: ReadOnlyAccess::Restricted { + include_platform_defaults: false, + readable_roots: vec![readable_root.clone()], + }, + network_access: true, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }; + + let core_policy = v2_policy.to_core(); + assert_eq!( + core_policy, + codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + read_only_access: CoreReadOnlyAccess::Restricted { + include_platform_defaults: false, + readable_roots: vec![readable_root], + }, + network_access: true, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + } + ); + + let back_to_v2 = SandboxPolicy::from(core_policy); + assert_eq!(back_to_v2, v2_policy); + } + + #[test] + fn sandbox_policy_deserializes_legacy_read_only_without_access_field() { + let policy: SandboxPolicy = serde_json::from_value(json!({ + "type": "readOnly" + })) + .expect("read-only policy should deserialize"); + assert_eq!( + policy, + SandboxPolicy::ReadOnly { + access: ReadOnlyAccess::FullAccess, + } + ); + } + + #[test] + fn sandbox_policy_deserializes_legacy_workspace_write_without_read_only_access_field() { + let policy: SandboxPolicy = serde_json::from_value(json!({ + "type": "workspaceWrite", + "writableRoots": [], + "networkAccess": false, + "excludeTmpdirEnvVar": false, + "excludeSlashTmp": false + })) + .expect("workspace-write policy should deserialize"); + assert_eq!( + policy, + SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + read_only_access: ReadOnlyAccess::FullAccess, + network_access: false, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + } + ); + } + #[test] fn core_turn_item_into_thread_item_converts_supported_variants() { let user_item = TurnItem::UserMessage(UserMessageItem { diff --git a/codex-rs/app-server-test-client/src/lib.rs b/codex-rs/app-server-test-client/src/lib.rs index 4c415254346..91012572acc 100644 --- a/codex-rs/app-server-test-client/src/lib.rs +++ b/codex-rs/app-server-test-client/src/lib.rs @@ -46,6 +46,7 @@ use codex_app_server_protocol::ModelListParams; use codex_app_server_protocol::ModelListResponse; use codex_app_server_protocol::NewConversationParams; use codex_app_server_protocol::NewConversationResponse; +use codex_app_server_protocol::ReadOnlyAccess; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy; use codex_app_server_protocol::SendUserMessageParams; @@ -301,7 +302,9 @@ fn trigger_cmd_approval( config_overrides, message, Some(AskForApproval::OnRequest), - Some(SandboxPolicy::ReadOnly), + Some(SandboxPolicy::ReadOnly { + access: ReadOnlyAccess::FullAccess, + }), dynamic_tools, ) } @@ -320,7 +323,9 @@ fn trigger_patch_approval( config_overrides, message, Some(AskForApproval::OnRequest), - Some(SandboxPolicy::ReadOnly), + Some(SandboxPolicy::ReadOnly { + access: ReadOnlyAccess::FullAccess, + }), dynamic_tools, ) } diff --git a/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs b/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs index 2debbda6535..863a7bfc4f7 100644 --- a/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs +++ b/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs @@ -444,6 +444,7 @@ async fn test_send_user_turn_updates_sandbox_and_cwd_between_turns() -> Result<( approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::WorkspaceWrite { writable_roots: vec![first_cwd.try_into()?], + read_only_access: Default::default(), network_access: false, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 1c146ea7fea..97cb14f445d 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1104,6 +1104,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { approval_policy: Some(codex_app_server_protocol::AskForApproval::Never), sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::WorkspaceWrite { writable_roots: vec![first_cwd.try_into()?], + read_only_access: codex_app_server_protocol::ReadOnlyAccess::FullAccess, network_access: false, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index 8632023d486..c6b63258f25 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -94,7 +94,7 @@ impl Default for ConfigRequirements { None, ), sandbox_policy: ConstrainedWithSource::new( - Constrained::allow_any(SandboxPolicy::ReadOnly), + Constrained::allow_any(SandboxPolicy::new_read_only_policy()), None, ), web_search_mode: ConstrainedWithSource::new( @@ -421,7 +421,7 @@ impl TryFrom for ConfigRequirements { // the other variants (WorkspaceWrite, ExternalSandbox) require // additional parameters. Ultimately, we should expand the config // format to allow specifying those parameters. - let default_sandbox_policy = SandboxPolicy::ReadOnly; + let default_sandbox_policy = SandboxPolicy::new_read_only_policy(); let sandbox_policy = match allowed_sandbox_modes { Some(Sourced { value: modes, @@ -439,7 +439,7 @@ impl TryFrom for ConfigRequirements { let requirement_source_for_error = requirement_source.clone(); let constrained = Constrained::new(default_sandbox_policy, move |candidate| { let mode = match candidate { - SandboxPolicy::ReadOnly => SandboxModeRequirement::ReadOnly, + SandboxPolicy::ReadOnly { .. } => SandboxModeRequirement::ReadOnly, SandboxPolicy::WorkspaceWrite { .. } => { SandboxModeRequirement::WorkspaceWrite } @@ -878,7 +878,7 @@ mod tests { assert!( requirements .sandbox_policy - .can_set(&SandboxPolicy::ReadOnly) + .can_set(&SandboxPolicy::new_read_only_policy()) .is_ok() ); @@ -897,7 +897,7 @@ mod tests { assert!( requirements .sandbox_policy - .can_set(&SandboxPolicy::ReadOnly) + .can_set(&SandboxPolicy::new_read_only_policy()) .is_ok() ); assert!( @@ -905,6 +905,7 @@ mod tests { .sandbox_policy .can_set(&SandboxPolicy::WorkspaceWrite { writable_roots: vec![AbsolutePathBuf::from_absolute_path(root)?], + read_only_access: Default::default(), network_access: false, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 075d1ae70ea..28b619eed76 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -45,6 +45,7 @@ use crate::model_provider_info::built_in_model_providers; use crate::project_doc::DEFAULT_PROJECT_DOC_FILENAME; use crate::project_doc::LOCAL_PROJECT_DOC_FILENAME; use crate::protocol::AskForApproval; +use crate::protocol::ReadOnlyAccess; use crate::protocol::SandboxPolicy; use crate::windows_sandbox::WindowsSandboxLevelExt; use crate::windows_sandbox::resolve_windows_sandbox_mode; @@ -1198,6 +1199,7 @@ impl ConfigToml { exclude_slash_tmp, }) => SandboxPolicy::WorkspaceWrite { writable_roots: writable_roots.clone(), + read_only_access: ReadOnlyAccess::FullAccess, network_access: *network_access, exclude_tmpdir_env_var: *exclude_tmpdir_env_var, exclude_slash_tmp: *exclude_slash_tmp, @@ -2122,7 +2124,7 @@ network_access = true # This should be ignored. &PathBuf::from("/tmp/test"), None, ); - assert_eq!(resolution, SandboxPolicy::ReadOnly); + assert_eq!(resolution, SandboxPolicy::new_read_only_policy()); let writable_root = test_absolute_path("/my/workspace"); let sandbox_workspace_write = format!( @@ -2150,12 +2152,13 @@ exclude_slash_tmp = true None, ); if cfg!(target_os = "windows") { - assert_eq!(resolution, SandboxPolicy::ReadOnly); + assert_eq!(resolution, SandboxPolicy::new_read_only_policy()); } else { assert_eq!( resolution, SandboxPolicy::WorkspaceWrite { writable_roots: vec![writable_root.clone()], + read_only_access: ReadOnlyAccess::FullAccess, network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -2191,12 +2194,13 @@ trust_level = "trusted" None, ); if cfg!(target_os = "windows") { - assert_eq!(resolution, SandboxPolicy::ReadOnly); + assert_eq!(resolution, SandboxPolicy::new_read_only_policy()); } else { assert_eq!( resolution, SandboxPolicy::WorkspaceWrite { writable_roots: vec![writable_root], + read_only_access: ReadOnlyAccess::FullAccess, network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -2363,7 +2367,7 @@ trust_level = "trusted" let expected_backend = AbsolutePathBuf::try_from(backend).unwrap(); if cfg!(target_os = "windows") { match config.sandbox_policy.get() { - &SandboxPolicy::ReadOnly => {} + SandboxPolicy::ReadOnly { .. } => {} other => panic!("expected read-only policy on Windows, got {other:?}"), } } else { @@ -2509,7 +2513,10 @@ trust_level = "trusted" #[test] fn web_search_mode_for_turn_uses_preference_for_read_only() { let web_search_mode = Constrained::allow_any(WebSearchMode::Cached); - let mode = resolve_web_search_mode_for_turn(&web_search_mode, &SandboxPolicy::ReadOnly); + let mode = resolve_web_search_mode_for_turn( + &web_search_mode, + &SandboxPolicy::new_read_only_policy(), + ); assert_eq!(mode, WebSearchMode::Cached); } @@ -2692,7 +2699,7 @@ profile = "project" if cfg!(target_os = "windows") { assert!(matches!( config.sandbox_policy.get(), - SandboxPolicy::ReadOnly + SandboxPolicy::ReadOnly { .. } )); } else { assert!(matches!( @@ -4666,7 +4673,7 @@ trust_level = "untrusted" // Verify that untrusted projects get WorkspaceWrite (or ReadOnly on Windows due to downgrade) if cfg!(target_os = "windows") { assert!( - matches!(resolution, SandboxPolicy::ReadOnly), + matches!(resolution, SandboxPolicy::ReadOnly { .. }), "Expected ReadOnly on Windows, got {resolution:?}" ); } else { @@ -4757,7 +4764,7 @@ trust_level = "untrusted" ); if cfg!(target_os = "windows") { - assert_eq!(resolution, SandboxPolicy::ReadOnly); + assert_eq!(resolution, SandboxPolicy::new_read_only_policy()); } else { assert_eq!(resolution, SandboxPolicy::new_workspace_write_policy()); } @@ -4904,7 +4911,7 @@ mcp_oauth_callback_port = 5678 // Verify that untrusted projects still get WorkspaceWrite sandbox (or ReadOnly on Windows) if cfg!(target_os = "windows") { assert!( - matches!(config.sandbox_policy.get(), SandboxPolicy::ReadOnly), + matches!(config.sandbox_policy.get(), SandboxPolicy::ReadOnly { .. }), "Expected ReadOnly on Windows" ); } else { @@ -4938,7 +4945,10 @@ mcp_oauth_callback_port = 5678 .build() .await?; - assert_eq!(*config.sandbox_policy.get(), SandboxPolicy::ReadOnly); + assert_eq!( + *config.sandbox_policy.get(), + SandboxPolicy::new_read_only_policy() + ); Ok(()) } @@ -4972,7 +4982,10 @@ mcp_oauth_callback_port = 5678 )) .build() .await?; - assert_eq!(*config.sandbox_policy.get(), SandboxPolicy::ReadOnly); + assert_eq!( + *config.sandbox_policy.get(), + SandboxPolicy::new_read_only_policy() + ); Ok(()) } diff --git a/codex-rs/core/src/config_loader/tests.rs b/codex-rs/core/src/config_loader/tests.rs index 336036a4203..37226505de5 100644 --- a/codex-rs/core/src/config_loader/tests.rs +++ b/codex-rs/core/src/config_loader/tests.rs @@ -410,7 +410,7 @@ allowed_sandbox_modes = ["read-only"] ); assert_eq!( *state.requirements().sandbox_policy.get(), - SandboxPolicy::ReadOnly + SandboxPolicy::new_read_only_policy() ); assert!( state @@ -425,6 +425,7 @@ allowed_sandbox_modes = ["read-only"] .sandbox_policy .can_set(&SandboxPolicy::WorkspaceWrite { writable_roots: Vec::new(), + read_only_access: Default::default(), network_access: false, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index b451d21b57b..1769446b66e 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -80,7 +80,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options( let cancel_token = CancellationToken::new(); let sandbox_state = SandboxState { - sandbox_policy: SandboxPolicy::ReadOnly, + sandbox_policy: SandboxPolicy::new_read_only_policy(), codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(), sandbox_cwd: env::current_dir().unwrap_or_else(|_| PathBuf::from("/")), use_linux_sandbox_bwrap: config.features.enabled(Feature::UseLinuxSandboxBwrap), diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index e627ebec065..398a767ce08 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -1094,7 +1094,13 @@ mod tests { arg0: None, }; - let output = exec(params, SandboxType::None, &SandboxPolicy::ReadOnly, None).await?; + let output = exec( + params, + SandboxType::None, + &SandboxPolicy::new_read_only_policy(), + None, + ) + .await?; assert!(output.timed_out); let stdout = output.stdout.from_utf8_lossy().text; diff --git a/codex-rs/core/src/exec_policy.rs b/codex-rs/core/src/exec_policy.rs index 710794a78db..60740d88841 100644 --- a/codex-rs/core/src/exec_policy.rs +++ b/codex-rs/core/src/exec_policy.rs @@ -290,7 +290,7 @@ pub fn render_decision_for_unmatched_command( // On Windows, ReadOnly sandbox is not a real sandbox, so special-case it // here. let runtime_sandbox_provides_safety = - cfg!(windows) && matches!(sandbox_policy, SandboxPolicy::ReadOnly); + cfg!(windows) && matches!(sandbox_policy, SandboxPolicy::ReadOnly { .. }); // If the command is flagged as dangerous or we have no sandbox protection, // we should never allow it to run without user approval. @@ -325,7 +325,7 @@ pub fn render_decision_for_unmatched_command( // command has not been flagged as dangerous. Decision::Allow } - SandboxPolicy::ReadOnly | SandboxPolicy::WorkspaceWrite { .. } => { + SandboxPolicy::ReadOnly { .. } | SandboxPolicy::WorkspaceWrite { .. } => { // In restricted sandboxes (ReadOnly/WorkspaceWrite), do not prompt for // non‑escalated, non‑dangerous commands — let the sandbox enforce // restrictions (e.g., block network/write) without a user prompt. @@ -852,7 +852,7 @@ prefix_rule(pattern=["rm"], decision="forbidden") .create_exec_approval_requirement_for_command(ExecApprovalRequest { command: &command, approval_policy: AskForApproval::OnRequest, - sandbox_policy: &SandboxPolicy::ReadOnly, + sandbox_policy: &SandboxPolicy::new_read_only_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }) @@ -879,7 +879,7 @@ prefix_rule(pattern=["rm"], decision="forbidden") .create_exec_approval_requirement_for_command(ExecApprovalRequest { command: &command, approval_policy: AskForApproval::UnlessTrusted, - sandbox_policy: &SandboxPolicy::ReadOnly, + sandbox_policy: &SandboxPolicy::new_read_only_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }) @@ -907,7 +907,7 @@ prefix_rule(pattern=["rm"], decision="forbidden") .create_exec_approval_requirement_for_command(ExecApprovalRequest { command: &command, approval_policy: AskForApproval::UnlessTrusted, - sandbox_policy: &SandboxPolicy::ReadOnly, + sandbox_policy: &SandboxPolicy::new_read_only_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: Some(requested_prefix.clone()), }) @@ -1028,7 +1028,7 @@ prefix_rule( .create_exec_approval_requirement_for_command(ExecApprovalRequest { command: &command, approval_policy: AskForApproval::UnlessTrusted, - sandbox_policy: &SandboxPolicy::ReadOnly, + sandbox_policy: &SandboxPolicy::new_read_only_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }) @@ -1052,7 +1052,7 @@ prefix_rule( .create_exec_approval_requirement_for_command(ExecApprovalRequest { command: &command, approval_policy: AskForApproval::UnlessTrusted, - sandbox_policy: &SandboxPolicy::ReadOnly, + sandbox_policy: &SandboxPolicy::new_read_only_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }) @@ -1080,7 +1080,7 @@ prefix_rule( .create_exec_approval_requirement_for_command(ExecApprovalRequest { command: &command, approval_policy: AskForApproval::UnlessTrusted, - sandbox_policy: &SandboxPolicy::ReadOnly, + sandbox_policy: &SandboxPolicy::new_read_only_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }) @@ -1110,7 +1110,7 @@ prefix_rule( .create_exec_approval_requirement_for_command(ExecApprovalRequest { command: &command, approval_policy: AskForApproval::OnRequest, - sandbox_policy: &SandboxPolicy::ReadOnly, + sandbox_policy: &SandboxPolicy::new_read_only_policy(), sandbox_permissions: SandboxPermissions::RequireEscalated, prefix_rule: Some(vec!["cargo".to_string(), "install".to_string()]), }) @@ -1221,7 +1221,7 @@ prefix_rule( .create_exec_approval_requirement_for_command(ExecApprovalRequest { command: &command, approval_policy: AskForApproval::UnlessTrusted, - sandbox_policy: &SandboxPolicy::ReadOnly, + sandbox_policy: &SandboxPolicy::new_read_only_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }) @@ -1278,7 +1278,7 @@ prefix_rule( .create_exec_approval_requirement_for_command(ExecApprovalRequest { command: &command, approval_policy: AskForApproval::UnlessTrusted, - sandbox_policy: &SandboxPolicy::ReadOnly, + sandbox_policy: &SandboxPolicy::new_read_only_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }) @@ -1316,7 +1316,7 @@ prefix_rule( .create_exec_approval_requirement_for_command(ExecApprovalRequest { command: &command, approval_policy: AskForApproval::UnlessTrusted, - sandbox_policy: &SandboxPolicy::ReadOnly, + sandbox_policy: &SandboxPolicy::new_read_only_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }) @@ -1339,7 +1339,7 @@ prefix_rule( .create_exec_approval_requirement_for_command(ExecApprovalRequest { command: &command, approval_policy: AskForApproval::OnRequest, - sandbox_policy: &SandboxPolicy::ReadOnly, + sandbox_policy: &SandboxPolicy::new_read_only_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }) @@ -1369,7 +1369,7 @@ prefix_rule( .create_exec_approval_requirement_for_command(ExecApprovalRequest { command: &command, approval_policy: AskForApproval::OnRequest, - sandbox_policy: &SandboxPolicy::ReadOnly, + sandbox_policy: &SandboxPolicy::new_read_only_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }) @@ -1458,7 +1458,7 @@ prefix_rule( .create_exec_approval_requirement_for_command(ExecApprovalRequest { command: &sneaky_command, approval_policy: AskForApproval::OnRequest, - sandbox_policy: &SandboxPolicy::ReadOnly, + sandbox_policy: &SandboxPolicy::new_read_only_policy(), sandbox_permissions: permissions, prefix_rule: None, }) @@ -1481,7 +1481,7 @@ prefix_rule( .create_exec_approval_requirement_for_command(ExecApprovalRequest { command: &dangerous_command, approval_policy: AskForApproval::OnRequest, - sandbox_policy: &SandboxPolicy::ReadOnly, + sandbox_policy: &SandboxPolicy::new_read_only_policy(), sandbox_permissions: permissions, prefix_rule: None, }) @@ -1500,7 +1500,7 @@ prefix_rule( .create_exec_approval_requirement_for_command(ExecApprovalRequest { command: &dangerous_command, approval_policy: AskForApproval::Never, - sandbox_policy: &SandboxPolicy::ReadOnly, + sandbox_policy: &SandboxPolicy::new_read_only_policy(), sandbox_permissions: permissions, prefix_rule: None, }) diff --git a/codex-rs/core/src/landlock.rs b/codex-rs/core/src/landlock.rs index 51a46619389..65b2a68073d 100644 --- a/codex-rs/core/src/landlock.rs +++ b/codex-rs/core/src/landlock.rs @@ -112,7 +112,7 @@ mod tests { fn bwrap_flags_are_feature_gated() { let command = vec!["/bin/true".to_string()]; let cwd = Path::new("/tmp"); - let policy = SandboxPolicy::ReadOnly; + let policy = SandboxPolicy::new_read_only_policy(); let with_bwrap = create_linux_sandbox_command_args(command.clone(), &policy, cwd, true, false); @@ -132,7 +132,7 @@ mod tests { fn proxy_flag_is_included_when_requested() { let command = vec!["/bin/true".to_string()]; let cwd = Path::new("/tmp"); - let policy = SandboxPolicy::ReadOnly; + let policy = SandboxPolicy::new_read_only_policy(); let args = create_linux_sandbox_command_args(command, &policy, cwd, true, true); assert_eq!( diff --git a/codex-rs/core/src/mcp/mod.rs b/codex-rs/core/src/mcp/mod.rs index 8034ed13a26..0ed0d221d2e 100644 --- a/codex-rs/core/src/mcp/mod.rs +++ b/codex-rs/core/src/mcp/mod.rs @@ -165,7 +165,7 @@ pub async fn collect_mcp_snapshot(config: &Config) -> McpListToolsResponseEvent // Use ReadOnly sandbox policy for MCP snapshot collection (safest default) let sandbox_state = SandboxState { - sandbox_policy: SandboxPolicy::ReadOnly, + sandbox_policy: SandboxPolicy::new_read_only_policy(), codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(), sandbox_cwd: env::current_dir().unwrap_or_else(|_| PathBuf::from("/")), use_linux_sandbox_bwrap: config.features.enabled(Feature::UseLinuxSandboxBwrap), diff --git a/codex-rs/core/src/memories/startup/dispatch.rs b/codex-rs/core/src/memories/startup/dispatch.rs index c0360bd25c9..aaf21fed8a8 100644 --- a/codex-rs/core/src/memories/startup/dispatch.rs +++ b/codex-rs/core/src/memories/startup/dispatch.rs @@ -83,6 +83,7 @@ pub(super) async fn run_global_memory_consolidation( } let consolidation_sandbox_policy = SandboxPolicy::WorkspaceWrite { writable_roots, + read_only_access: Default::default(), network_access: false, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, diff --git a/codex-rs/core/src/rollout/metadata.rs b/codex-rs/core/src/rollout/metadata.rs index 5bfdc363ae3..72988777518 100644 --- a/codex-rs/core/src/rollout/metadata.rs +++ b/codex-rs/core/src/rollout/metadata.rs @@ -49,7 +49,7 @@ pub(crate) fn builder_from_session_meta( builder.model_provider = session_meta.meta.model_provider.clone(); builder.cwd = session_meta.meta.cwd.clone(); builder.cli_version = Some(session_meta.meta.cli_version.clone()); - builder.sandbox_policy = SandboxPolicy::ReadOnly; + builder.sandbox_policy = SandboxPolicy::new_read_only_policy(); builder.approval_mode = AskForApproval::OnRequest; if let Some(git) = session_meta.git.as_ref() { builder.git_sha = git.commit_hash.clone(); diff --git a/codex-rs/core/src/safety.rs b/codex-rs/core/src/safety.rs index 47a12e029ec..b0d359b7e19 100644 --- a/codex-rs/core/src/safety.rs +++ b/codex-rs/core/src/safety.rs @@ -108,7 +108,7 @@ fn is_write_patch_constrained_to_writable_paths( ) -> bool { // Early‑exit if there are no declared writable roots. let writable_roots = match sandbox_policy { - SandboxPolicy::ReadOnly => { + SandboxPolicy::ReadOnly { .. } => { return false; } SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => { @@ -195,6 +195,7 @@ mod tests { // only `cwd` is writable by default. let policy_workspace_only = SandboxPolicy::WorkspaceWrite { writable_roots: vec![], + read_only_access: Default::default(), network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -216,6 +217,7 @@ mod tests { // outside write should be permitted. let policy_with_parent = SandboxPolicy::WorkspaceWrite { writable_roots: vec![AbsolutePathBuf::try_from(parent).unwrap()], + read_only_access: Default::default(), network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, diff --git a/codex-rs/core/src/seatbelt.rs b/codex-rs/core/src/seatbelt.rs index f71d061609f..fc425f2184c 100644 --- a/codex-rs/core/src/seatbelt.rs +++ b/codex-rs/core/src/seatbelt.rs @@ -237,10 +237,40 @@ pub(crate) fn create_seatbelt_command_args( } }; - let file_read_policy = if sandbox_policy.has_full_disk_read_access() { - "; allow read-only file operations\n(allow file-read*)" + let (file_read_policy, file_read_dir_params) = if sandbox_policy.has_full_disk_read_access() { + ( + "; allow read-only file operations\n(allow file-read*)".to_string(), + Vec::new(), + ) } else { - "" + let mut readable_roots_policies: Vec = Vec::new(); + let mut file_read_params = Vec::new(); + for (index, root) in sandbox_policy + .get_readable_roots_with_cwd(sandbox_policy_cwd) + .into_iter() + .enumerate() + { + // Canonicalize to avoid mismatches like /var vs /private/var on macOS. + let canonical_root = root + .as_path() + .canonicalize() + .unwrap_or_else(|_| root.to_path_buf()); + let root_param = format!("READABLE_ROOT_{index}"); + file_read_params.push((root_param.clone(), canonical_root)); + readable_roots_policies.push(format!("(subpath (param \"{root_param}\"))")); + } + + if readable_roots_policies.is_empty() { + ("".to_string(), Vec::new()) + } else { + ( + format!( + "; allow read-only file operations\n(allow file-read*\n{}\n)", + readable_roots_policies.join(" ") + ), + file_read_params, + ) + } }; let proxy = proxy_policy_inputs(network); @@ -250,7 +280,12 @@ pub(crate) fn create_seatbelt_command_args( "{MACOS_SEATBELT_BASE_POLICY}\n{file_read_policy}\n{file_write_policy}\n{network_policy}" ); - let dir_params = [file_write_dir_params, macos_dir_params()].concat(); + let dir_params = [ + file_read_dir_params, + file_write_dir_params, + macos_dir_params(), + ] + .concat(); let mut seatbelt_args: Vec = vec!["-p".to_string(), full_policy]; let definition_args = dir_params @@ -329,7 +364,7 @@ mod tests { #[test] fn create_seatbelt_args_routes_network_through_proxy_ports() { let policy = dynamic_network_policy( - &SandboxPolicy::ReadOnly, + &SandboxPolicy::new_read_only_policy(), false, &ProxyPolicyInputs { ports: vec![43128, 48081], @@ -363,7 +398,7 @@ mod tests { #[test] fn create_seatbelt_args_allows_local_binding_when_explicitly_enabled() { let policy = dynamic_network_policy( - &SandboxPolicy::ReadOnly, + &SandboxPolicy::new_read_only_policy(), false, &ProxyPolicyInputs { ports: vec![43128], @@ -395,6 +430,7 @@ mod tests { let policy = dynamic_network_policy( &SandboxPolicy::WorkspaceWrite { writable_roots: vec![], + read_only_access: Default::default(), network_access: true, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, @@ -422,6 +458,7 @@ mod tests { let policy = dynamic_network_policy( &SandboxPolicy::WorkspaceWrite { writable_roots: vec![], + read_only_access: Default::default(), network_access: true, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, @@ -442,6 +479,7 @@ mod tests { let policy = dynamic_network_policy( &SandboxPolicy::WorkspaceWrite { writable_roots: vec![], + read_only_access: Default::default(), network_access: true, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, @@ -491,6 +529,7 @@ mod tests { .into_iter() .map(|p| p.try_into().unwrap()) .collect(), + read_only_access: Default::default(), network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -676,6 +715,7 @@ mod tests { let policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![worktree_root.try_into().expect("worktree_root is absolute")], + read_only_access: Default::default(), network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -760,6 +800,7 @@ mod tests { // `.codex` checks are done properly for cwd. let policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![], + read_only_access: Default::default(), network_access: false, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index d54b0f46389..8ab702c0c24 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -275,7 +275,7 @@ fn unsupported_tool_call_message(payload: &ToolPayload, tool_name: &str) -> Stri fn sandbox_policy_tag(policy: &SandboxPolicy) -> &'static str { match policy { - SandboxPolicy::ReadOnly => "read-only", + SandboxPolicy::ReadOnly { .. } => "read-only", SandboxPolicy::WorkspaceWrite { .. } => "workspace-write", SandboxPolicy::DangerFullAccess => "danger-full-access", SandboxPolicy::ExternalSandbox { .. } => "external-sandbox", diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index 56e54e62f1f..36717608443 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -326,7 +326,10 @@ mod tests { #[test] fn restricted_sandbox_requires_exec_approval_on_request() { assert_eq!( - default_exec_approval_requirement(AskForApproval::OnRequest, &SandboxPolicy::ReadOnly), + default_exec_approval_requirement( + AskForApproval::OnRequest, + &SandboxPolicy::new_read_only_policy() + ), ExecApprovalRequirement::NeedsApproval { reason: None, proposed_execpolicy_amendment: None, diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index ccfa9fe6544..112f242bc09 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -578,6 +578,7 @@ async fn apply_patch_cli_rejects_path_traversal_outside_workspace( let sandbox_policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![], + read_only_access: Default::default(), network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -634,6 +635,7 @@ async fn apply_patch_cli_rejects_move_path_traversal_outside_workspace( let sandbox_policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![], + read_only_access: Default::default(), network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index ccf8760c8dd..3b5e115ef86 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -628,6 +628,7 @@ fn scenarios() -> Vec { let workspace_write = |network_access| SandboxPolicy::WorkspaceWrite { writable_roots: vec![], + read_only_access: Default::default(), network_access, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, @@ -841,7 +842,7 @@ fn scenarios() -> Vec { ScenarioSpec { name: "read_only_on_request_requires_approval", approval_policy: OnRequest, - sandbox_policy: SandboxPolicy::ReadOnly, + sandbox_policy: SandboxPolicy::new_read_only_policy(), action: ActionKind::WriteFile { target: TargetPath::Workspace("ro_on_request.txt"), content: "read-only-approval", @@ -861,7 +862,7 @@ fn scenarios() -> Vec { ScenarioSpec { name: "read_only_on_request_requires_approval_gpt_5_1_no_exit", approval_policy: OnRequest, - sandbox_policy: SandboxPolicy::ReadOnly, + sandbox_policy: SandboxPolicy::new_read_only_policy(), action: ActionKind::WriteFile { target: TargetPath::Workspace("ro_on_request_5_1.txt"), content: "read-only-approval", @@ -881,7 +882,7 @@ fn scenarios() -> Vec { ScenarioSpec { name: "trusted_command_on_request_read_only_runs_without_prompt", approval_policy: OnRequest, - sandbox_policy: SandboxPolicy::ReadOnly, + sandbox_policy: SandboxPolicy::new_read_only_policy(), action: ActionKind::RunCommand { command: "echo trusted-read-only", }, @@ -896,7 +897,7 @@ fn scenarios() -> Vec { ScenarioSpec { name: "trusted_command_on_request_read_only_runs_without_prompt_gpt_5_1_no_exit", approval_policy: OnRequest, - sandbox_policy: SandboxPolicy::ReadOnly, + sandbox_policy: SandboxPolicy::new_read_only_policy(), action: ActionKind::RunCommand { command: "echo trusted-read-only", }, @@ -911,7 +912,7 @@ fn scenarios() -> Vec { ScenarioSpec { name: "read_only_on_request_blocks_network", approval_policy: OnRequest, - sandbox_policy: SandboxPolicy::ReadOnly, + sandbox_policy: SandboxPolicy::new_read_only_policy(), action: ActionKind::FetchUrl { endpoint: "/ro/network-blocked", response_body: "should-not-see", @@ -925,7 +926,7 @@ fn scenarios() -> Vec { ScenarioSpec { name: "read_only_on_request_denied_blocks_execution", approval_policy: OnRequest, - sandbox_policy: SandboxPolicy::ReadOnly, + sandbox_policy: SandboxPolicy::new_read_only_policy(), action: ActionKind::WriteFile { target: TargetPath::Workspace("ro_on_request_denied.txt"), content: "should-not-write", @@ -946,7 +947,7 @@ fn scenarios() -> Vec { ScenarioSpec { name: "read_only_on_failure_escalates_after_sandbox_error", approval_policy: OnFailure, - sandbox_policy: SandboxPolicy::ReadOnly, + sandbox_policy: SandboxPolicy::new_read_only_policy(), action: ActionKind::WriteFile { target: TargetPath::Workspace("ro_on_failure.txt"), content: "read-only-on-failure", @@ -967,7 +968,7 @@ fn scenarios() -> Vec { ScenarioSpec { name: "read_only_on_failure_escalates_after_sandbox_error_gpt_5_1_no_exit", approval_policy: OnFailure, - sandbox_policy: SandboxPolicy::ReadOnly, + sandbox_policy: SandboxPolicy::new_read_only_policy(), action: ActionKind::WriteFile { target: TargetPath::Workspace("ro_on_failure_5_1.txt"), content: "read-only-on-failure", @@ -987,7 +988,7 @@ fn scenarios() -> Vec { ScenarioSpec { name: "read_only_on_request_network_escalates_when_approved", approval_policy: OnRequest, - sandbox_policy: SandboxPolicy::ReadOnly, + sandbox_policy: SandboxPolicy::new_read_only_policy(), action: ActionKind::FetchUrl { endpoint: "/ro/network-approved", response_body: "read-only-network-ok", @@ -1006,7 +1007,7 @@ fn scenarios() -> Vec { ScenarioSpec { name: "read_only_on_request_network_escalates_when_approved_gpt_5_1_no_exit", approval_policy: OnRequest, - sandbox_policy: SandboxPolicy::ReadOnly, + sandbox_policy: SandboxPolicy::new_read_only_policy(), action: ActionKind::FetchUrl { endpoint: "/ro/network-approved", response_body: "read-only-network-ok", @@ -1178,7 +1179,7 @@ fn scenarios() -> Vec { ScenarioSpec { name: "read_only_unless_trusted_requires_approval", approval_policy: UnlessTrusted, - sandbox_policy: SandboxPolicy::ReadOnly, + sandbox_policy: SandboxPolicy::new_read_only_policy(), action: ActionKind::WriteFile { target: TargetPath::Workspace("ro_unless_trusted.txt"), content: "read-only-unless-trusted", @@ -1198,7 +1199,7 @@ fn scenarios() -> Vec { ScenarioSpec { name: "read_only_unless_trusted_requires_approval_gpt_5_1_no_exit", approval_policy: UnlessTrusted, - sandbox_policy: SandboxPolicy::ReadOnly, + sandbox_policy: SandboxPolicy::new_read_only_policy(), action: ActionKind::WriteFile { target: TargetPath::Workspace("ro_unless_trusted_5_1.txt"), content: "read-only-unless-trusted", @@ -1218,7 +1219,7 @@ fn scenarios() -> Vec { ScenarioSpec { name: "read_only_never_reports_sandbox_failure", approval_policy: Never, - sandbox_policy: SandboxPolicy::ReadOnly, + sandbox_policy: SandboxPolicy::new_read_only_policy(), action: ActionKind::WriteFile { target: TargetPath::Workspace("ro_never.txt"), content: "read-only-never", @@ -1242,7 +1243,7 @@ fn scenarios() -> Vec { ScenarioSpec { name: "trusted_command_never_runs_without_prompt", approval_policy: Never, - sandbox_policy: SandboxPolicy::ReadOnly, + sandbox_policy: SandboxPolicy::new_read_only_policy(), action: ActionKind::RunCommand { command: "echo trusted-never", }, @@ -1407,7 +1408,7 @@ fn scenarios() -> Vec { ScenarioSpec { name: "unified exec on request escalated requires approval", approval_policy: OnRequest, - sandbox_policy: SandboxPolicy::ReadOnly, + sandbox_policy: SandboxPolicy::new_read_only_policy(), action: ActionKind::RunUnifiedExecCommand { command: "python3 -c 'print('\"'\"'escalated unified exec'\"'\"')'", justification: Some(DEFAULT_UNIFIED_EXEC_JUSTIFICATION), @@ -1574,6 +1575,7 @@ async fn approving_apply_patch_for_session_skips_future_prompts_for_same_file() let approval_policy = AskForApproval::OnRequest; let sandbox_policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![], + read_only_access: Default::default(), network_access: false, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, @@ -1687,7 +1689,7 @@ async fn approving_apply_patch_for_session_skips_future_prompts_for_same_file() async fn approving_execpolicy_amendment_persists_policy_and_skips_future_prompts() -> Result<()> { let server = start_mock_server().await; let approval_policy = AskForApproval::UnlessTrusted; - let sandbox_policy = SandboxPolicy::ReadOnly; + let sandbox_policy = SandboxPolicy::new_read_only_policy(); let sandbox_policy_for_config = sandbox_policy.clone(); let mut builder = test_codex().with_config(move |config| { config.approval_policy = Constrained::allow_any(approval_policy); diff --git a/codex-rs/core/tests/suite/codex_delegate.rs b/codex-rs/core/tests/suite/codex_delegate.rs index 99455681455..a7a8bc7eec3 100644 --- a/codex-rs/core/tests/suite/codex_delegate.rs +++ b/codex-rs/core/tests/suite/codex_delegate.rs @@ -64,7 +64,7 @@ async fn codex_delegate_forwards_exec_approval_and_proceeds_on_approval() { // routes ExecApprovalRequest via the parent. let mut builder = test_codex().with_model("gpt-5.1").with_config(|config| { config.approval_policy = Constrained::allow_any(AskForApproval::OnRequest); - config.sandbox_policy = Constrained::allow_any(SandboxPolicy::ReadOnly); + config.sandbox_policy = Constrained::allow_any(SandboxPolicy::new_read_only_policy()); }); let test = builder.build(&server).await.expect("build test codex"); @@ -146,7 +146,7 @@ async fn codex_delegate_forwards_patch_approval_and_proceeds_on_decision() { let mut builder = test_codex().with_model("gpt-5.1").with_config(|config| { config.approval_policy = Constrained::allow_any(AskForApproval::OnRequest); // Use a restricted sandbox so patch approval is required - config.sandbox_policy = Constrained::allow_any(SandboxPolicy::ReadOnly); + config.sandbox_policy = Constrained::allow_any(SandboxPolicy::new_read_only_policy()); config.include_apply_patch_tool = true; }); let test = builder.build(&server).await.expect("build test codex"); diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index fddd3d282d7..1c06d40b986 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -53,7 +53,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::ReadOnly, + sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: ReasoningSummary::Auto, @@ -86,7 +86,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::ReadOnly, + sandbox_policy: SandboxPolicy::new_read_only_policy(), model: next_model.to_string(), effort: test.config.model_reasoning_effort, summary: ReasoningSummary::Auto, @@ -141,7 +141,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::ReadOnly, + sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: ReasoningSummary::Auto, @@ -174,7 +174,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::ReadOnly, + sandbox_policy: SandboxPolicy::new_read_only_policy(), model: next_model.to_string(), effort: test.config.model_reasoning_effort, summary: ReasoningSummary::Auto, @@ -289,7 +289,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::ReadOnly, + sandbox_policy: SandboxPolicy::new_read_only_policy(), model: image_model_slug.to_string(), effort: test.config.model_reasoning_effort, summary: ReasoningSummary::Auto, @@ -308,7 +308,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::ReadOnly, + sandbox_policy: SandboxPolicy::new_read_only_policy(), model: text_model_slug.to_string(), effort: test.config.model_reasoning_effort, summary: ReasoningSummary::Auto, diff --git a/codex-rs/core/tests/suite/permissions_messages.rs b/codex-rs/core/tests/suite/permissions_messages.rs index fc1f0fa0b62..5bb92114f65 100644 --- a/codex-rs/core/tests/suite/permissions_messages.rs +++ b/codex-rs/core/tests/suite/permissions_messages.rs @@ -457,6 +457,7 @@ async fn permissions_message_includes_writable_roots() -> Result<()> { let writable_root = AbsolutePathBuf::try_from(writable.path())?; let sandbox_policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![writable_root], + read_only_access: Default::default(), network_access: false, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index d0f4ecc561b..157afbd2ceb 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -95,7 +95,7 @@ async fn user_turn_personality_none_does_not_add_update_message() -> anyhow::Res final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: test.config.approval_policy.value(), - sandbox_policy: SandboxPolicy::ReadOnly, + sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: ReasoningSummary::Auto, @@ -142,7 +142,7 @@ async fn config_personality_some_sets_instructions_template() -> anyhow::Result< final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: test.config.approval_policy.value(), - sandbox_policy: SandboxPolicy::ReadOnly, + sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: ReasoningSummary::Auto, @@ -196,7 +196,7 @@ async fn config_personality_none_sends_no_personality() -> anyhow::Result<()> { final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: test.config.approval_policy.value(), - sandbox_policy: SandboxPolicy::ReadOnly, + sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: ReasoningSummary::Auto, @@ -256,7 +256,7 @@ async fn default_personality_is_pragmatic_without_config_toml() -> anyhow::Resul final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: test.config.approval_policy.value(), - sandbox_policy: SandboxPolicy::ReadOnly, + sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: ReasoningSummary::Auto, @@ -304,7 +304,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: test.config.approval_policy.value(), - sandbox_policy: SandboxPolicy::ReadOnly, + sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: ReasoningSummary::Auto, @@ -338,7 +338,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: test.config.approval_policy.value(), - sandbox_policy: SandboxPolicy::ReadOnly, + sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: ReasoningSummary::Auto, @@ -401,7 +401,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: test.config.approval_policy.value(), - sandbox_policy: SandboxPolicy::ReadOnly, + sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: ReasoningSummary::Auto, @@ -435,7 +435,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: test.config.approval_policy.value(), - sandbox_policy: SandboxPolicy::ReadOnly, + sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: ReasoningSummary::Auto, @@ -508,7 +508,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: test.config.approval_policy.value(), - sandbox_policy: SandboxPolicy::ReadOnly, + sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: ReasoningSummary::Auto, @@ -542,7 +542,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: test.config.approval_policy.value(), - sandbox_policy: SandboxPolicy::ReadOnly, + sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: ReasoningSummary::Auto, @@ -654,7 +654,7 @@ async fn ignores_remote_personality_if_remote_models_disabled() -> anyhow::Resul final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::ReadOnly, + sandbox_policy: SandboxPolicy::new_read_only_policy(), model: remote_slug.to_string(), effort: test.config.model_reasoning_effort, summary: ReasoningSummary::Auto, @@ -771,7 +771,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::ReadOnly, + sandbox_policy: SandboxPolicy::new_read_only_policy(), model: remote_slug.to_string(), effort: test.config.model_reasoning_effort, summary: ReasoningSummary::Auto, @@ -886,7 +886,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::ReadOnly, + sandbox_policy: SandboxPolicy::new_read_only_policy(), model: remote_slug.to_string(), effort: test.config.model_reasoning_effort, summary: ReasoningSummary::Auto, @@ -920,7 +920,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::ReadOnly, + sandbox_policy: SandboxPolicy::new_read_only_policy(), model: remote_slug.to_string(), effort: test.config.model_reasoning_effort, summary: ReasoningSummary::Auto, diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index e1ef097e200..64a9b55f8b8 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -375,6 +375,7 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an let writable = TempDir::new().unwrap(); let new_policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![writable.path().try_into().unwrap()], + read_only_access: Default::default(), network_access: true, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -618,6 +619,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res let writable = TempDir::new().unwrap(); let new_policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![AbsolutePathBuf::try_from(writable.path()).unwrap()], + read_only_access: Default::default(), network_access: true, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index a1bf72b1030..46df9ca17e1 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -126,7 +126,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { final_output_json_schema: None, cwd: fixture.cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::ReadOnly, + sandbox_policy: SandboxPolicy::new_read_only_policy(), model: session_model, effort: None, summary: ReasoningSummary::Auto, @@ -293,7 +293,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { final_output_json_schema: None, cwd: fixture.cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::ReadOnly, + sandbox_policy: SandboxPolicy::new_read_only_policy(), model: session_model, effort: None, summary: ReasoningSummary::Auto, @@ -491,7 +491,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re final_output_json_schema: None, cwd: fixture.cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::ReadOnly, + sandbox_policy: SandboxPolicy::new_read_only_policy(), model: text_only_model_slug.to_string(), effort: None, summary: ReasoningSummary::Auto, @@ -603,7 +603,7 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> { final_output_json_schema: None, cwd: fixture.cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::ReadOnly, + sandbox_policy: SandboxPolicy::new_read_only_policy(), model: session_model, effort: None, summary: ReasoningSummary::Auto, @@ -762,7 +762,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { final_output_json_schema: None, cwd: fixture.cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::ReadOnly, + sandbox_policy: SandboxPolicy::new_read_only_policy(), model: session_model, effort: None, summary: ReasoningSummary::Auto, @@ -953,7 +953,7 @@ async fn streamable_http_with_oauth_round_trip() -> anyhow::Result<()> { final_output_json_schema: None, cwd: fixture.cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::ReadOnly, + sandbox_policy: SandboxPolicy::new_read_only_policy(), model: session_model, effort: None, summary: ReasoningSummary::Auto, diff --git a/codex-rs/core/tests/suite/seatbelt.rs b/codex-rs/core/tests/suite/seatbelt.rs index 614e87367a5..89674494a9b 100644 --- a/codex-rs/core/tests/suite/seatbelt.rs +++ b/codex-rs/core/tests/suite/seatbelt.rs @@ -77,6 +77,7 @@ async fn if_parent_of_repo_is_writable_then_dot_git_folder_is_writable() { let test_scenario = create_test_scenario(&tmp); let policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![test_scenario.repo_parent.as_path().try_into().unwrap()], + read_only_access: Default::default(), network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -103,6 +104,7 @@ async fn if_git_repo_is_writable_root_then_dot_git_folder_is_read_only() { let test_scenario = create_test_scenario(&tmp); let policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![test_scenario.repo_root.as_path().try_into().unwrap()], + read_only_access: Default::default(), network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -145,7 +147,7 @@ async fn danger_full_access_allows_all_writes() { async fn read_only_forbids_all_writes() { let tmp = TempDir::new().expect("should be able to create temp dir"); let test_scenario = create_test_scenario(&tmp); - let policy = SandboxPolicy::ReadOnly; + let policy = SandboxPolicy::new_read_only_policy(); test_scenario .run_test( @@ -171,7 +173,7 @@ async fn openpty_works_under_seatbelt() { return; } - let policy = SandboxPolicy::ReadOnly; + let policy = SandboxPolicy::new_read_only_policy(); let command_cwd = std::env::current_dir().expect("getcwd"); let sandbox_cwd = command_cwd.clone(); @@ -229,7 +231,7 @@ async fn java_home_finds_runtime_under_seatbelt() { return; } - let policy = SandboxPolicy::ReadOnly; + let policy = SandboxPolicy::new_read_only_policy(); let command_cwd = std::env::current_dir().expect("getcwd"); let sandbox_cwd = command_cwd.clone(); diff --git a/codex-rs/core/tests/suite/tools.rs b/codex-rs/core/tests/suite/tools.rs index 7efa8bb28e0..a828071ea52 100644 --- a/codex-rs/core/tests/suite/tools.rs +++ b/codex-rs/core/tests/suite/tools.rs @@ -230,7 +230,7 @@ async fn sandbox_denied_shell_returns_original_output() -> Result<()> { fixture .submit_turn_with_policy( "run a command that should be denied by the read-only sandbox", - SandboxPolicy::ReadOnly, + SandboxPolicy::new_read_only_policy(), ) .await?; diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index b36fef9eacd..9475a9c5105 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -384,7 +384,7 @@ async fn mcp_tool_call_output_exceeds_limit_truncated_for_model() -> Result<()> fixture .submit_turn_with_policy( "call the rmcp echo tool with a very large message", - SandboxPolicy::ReadOnly, + SandboxPolicy::new_read_only_policy(), ) .await?; @@ -485,7 +485,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { final_output_json_schema: None, cwd: fixture.cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::ReadOnly, + sandbox_policy: SandboxPolicy::new_read_only_policy(), model: session_model, effort: None, summary: ReasoningSummary::Auto, @@ -742,7 +742,7 @@ async fn mcp_tool_call_output_not_truncated_with_custom_limit() -> Result<()> { fixture .submit_turn_with_policy( "call the rmcp echo tool with a very large message", - SandboxPolicy::ReadOnly, + SandboxPolicy::new_read_only_policy(), ) .await?; diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index c7b21413c5b..34c5bd9617d 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -2540,7 +2540,7 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, // Important! - sandbox_policy: SandboxPolicy::ReadOnly, + sandbox_policy: SandboxPolicy::new_read_only_policy(), model: session_model, effort: None, summary: ReasoningSummary::Auto, @@ -2644,7 +2644,7 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::ReadOnly, + sandbox_policy: SandboxPolicy::new_read_only_policy(), model: session_model, effort: None, summary: ReasoningSummary::Auto, diff --git a/codex-rs/core/tests/suite/web_search.rs b/codex-rs/core/tests/suite/web_search.rs index 65e8aedbed5..8909e01cf48 100644 --- a/codex-rs/core/tests/suite/web_search.rs +++ b/codex-rs/core/tests/suite/web_search.rs @@ -44,9 +44,12 @@ async fn web_search_mode_cached_sets_external_web_access_false() { .await .expect("create test Codex conversation"); - test.submit_turn_with_policy("hello cached web search", SandboxPolicy::ReadOnly) - .await - .expect("submit turn"); + test.submit_turn_with_policy( + "hello cached web search", + SandboxPolicy::new_read_only_policy(), + ) + .await + .expect("submit turn"); let body = resp_mock.single_request().body_json(); let tool = find_web_search_tool(&body); @@ -82,9 +85,12 @@ async fn web_search_mode_takes_precedence_over_legacy_flags() { .await .expect("create test Codex conversation"); - test.submit_turn_with_policy("hello cached+live flags", SandboxPolicy::ReadOnly) - .await - .expect("submit turn"); + test.submit_turn_with_policy( + "hello cached+live flags", + SandboxPolicy::new_read_only_policy(), + ) + .await + .expect("submit turn"); let body = resp_mock.single_request().body_json(); let tool = find_web_search_tool(&body); @@ -121,9 +127,12 @@ async fn web_search_mode_defaults_to_cached_when_features_disabled() { .await .expect("create test Codex conversation"); - test.submit_turn_with_policy("hello default cached web search", SandboxPolicy::ReadOnly) - .await - .expect("submit turn"); + test.submit_turn_with_policy( + "hello default cached web search", + SandboxPolicy::new_read_only_policy(), + ) + .await + .expect("submit turn"); let body = resp_mock.single_request().body_json(); let tool = find_web_search_tool(&body); @@ -169,7 +178,7 @@ async fn web_search_mode_updates_between_turns_with_sandbox_policy() { .await .expect("create test Codex conversation"); - test.submit_turn_with_policy("hello cached", SandboxPolicy::ReadOnly) + test.submit_turn_with_policy("hello cached", SandboxPolicy::new_read_only_policy()) .await .expect("submit first turn"); test.submit_turn_with_policy("hello live", SandboxPolicy::DangerFullAccess) diff --git a/codex-rs/exec-server/src/posix/mcp.rs b/codex-rs/exec-server/src/posix/mcp.rs index 4cfce9ff89a..7e8d0ccdff2 100644 --- a/codex-rs/exec-server/src/posix/mcp.rs +++ b/codex-rs/exec-server/src/posix/mcp.rs @@ -123,7 +123,7 @@ impl ExecTool { .await .clone() .unwrap_or_else(|| SandboxState { - sandbox_policy: SandboxPolicy::ReadOnly, + sandbox_policy: SandboxPolicy::new_read_only_policy(), codex_linux_sandbox_exe: None, sandbox_cwd: PathBuf::from(¶ms.workdir), use_linux_sandbox_bwrap: false, diff --git a/codex-rs/exec-server/tests/common/lib.rs b/codex-rs/exec-server/tests/common/lib.rs index a51a589adf9..430eb899539 100644 --- a/codex-rs/exec-server/tests/common/lib.rs +++ b/codex-rs/exec-server/tests/common/lib.rs @@ -88,7 +88,7 @@ where S: Service + ClientHandler, { let sandbox_state = SandboxState { - sandbox_policy: SandboxPolicy::ReadOnly, + sandbox_policy: SandboxPolicy::new_read_only_policy(), codex_linux_sandbox_exe, sandbox_cwd: sandbox_cwd.as_ref().to_path_buf(), use_linux_sandbox_bwrap: false, @@ -110,6 +110,7 @@ where // Note that sandbox_cwd will already be included as a writable root // when the sandbox policy is expanded. writable_roots: vec![], + read_only_access: Default::default(), network_access: false, // Disable writes to temp dir because this is a test, so // writable_folder is likely also under /tmp and we want to be diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index 4e669d232e0..e0588304139 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -92,7 +92,7 @@ fn session_configured_produces_thread_started_event() { model: "codex-mini-latest".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::ReadOnly, + sandbox_policy: SandboxPolicy::new_read_only_policy(), cwd: PathBuf::from("/home/user/project"), reasoning_effort: None, history_log_id: 0, diff --git a/codex-rs/exec/tests/suite/sandbox.rs b/codex-rs/exec/tests/suite/sandbox.rs index 45de9c22366..ce2e79eac22 100644 --- a/codex-rs/exec/tests/suite/sandbox.rs +++ b/codex-rs/exec/tests/suite/sandbox.rs @@ -69,7 +69,7 @@ async fn spawn_command_under_sandbox( async fn linux_sandbox_test_env() -> Option> { let command_cwd = std::env::current_dir().ok()?; let sandbox_cwd = command_cwd.clone(); - let policy = SandboxPolicy::ReadOnly; + let policy = SandboxPolicy::new_read_only_policy(); if can_apply_linux_sandbox_policy(&policy, &command_cwd, sandbox_cwd.as_path(), HashMap::new()) .await @@ -134,6 +134,7 @@ async fn python_multiprocessing_lock_works_under_sandbox() { let policy = SandboxPolicy::WorkspaceWrite { writable_roots, + read_only_access: Default::default(), network_access: false, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, @@ -194,7 +195,7 @@ async fn python_getpwuid_works_under_sandbox() { return; } - let policy = SandboxPolicy::ReadOnly; + let policy = SandboxPolicy::new_read_only_policy(); let command_cwd = std::env::current_dir().expect("should be able to get current dir"); let sandbox_cwd = command_cwd.clone(); @@ -247,6 +248,7 @@ async fn sandbox_distinguishes_command_and_policy_cwds() { // is under a writable root. let policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![], + read_only_access: Default::default(), network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -387,7 +389,7 @@ fn unix_sock_body() { async fn allow_unix_socketpair_recvfrom() { run_code_under_sandbox( "allow_unix_socketpair_recvfrom", - &SandboxPolicy::ReadOnly, + &SandboxPolicy::new_read_only_policy(), || async { unix_sock_body() }, ) .await diff --git a/codex-rs/linux-sandbox/src/bwrap.rs b/codex-rs/linux-sandbox/src/bwrap.rs index 1a835fd5e03..7ff7acf5ef6 100644 --- a/codex-rs/linux-sandbox/src/bwrap.rs +++ b/codex-rs/linux-sandbox/src/bwrap.rs @@ -141,6 +141,13 @@ fn create_bwrap_flags( /// 4. `--dev-bind /dev/null /dev/null` preserves the common sink even under a /// read-only root. fn create_filesystem_args(sandbox_policy: &SandboxPolicy, cwd: &Path) -> Result> { + if !sandbox_policy.has_full_disk_read_access() { + return Err(CodexErr::UnsupportedOperation( + "Restricted read-only access is not yet supported by the Linux bubblewrap backend." + .to_string(), + )); + } + let writable_roots = sandbox_policy.get_writable_roots_with_cwd(cwd); ensure_mount_targets_exist(&writable_roots)?; diff --git a/codex-rs/linux-sandbox/src/landlock.rs b/codex-rs/linux-sandbox/src/landlock.rs index d86fafd297d..8d568dba0a2 100644 --- a/codex-rs/linux-sandbox/src/landlock.rs +++ b/codex-rs/linux-sandbox/src/landlock.rs @@ -62,6 +62,13 @@ pub(crate) fn apply_sandbox_policy_to_current_thread( } if apply_landlock_fs && !sandbox_policy.has_full_disk_write_access() { + if !sandbox_policy.has_full_disk_read_access() { + return Err(CodexErr::UnsupportedOperation( + "Restricted read-only access is not supported by the legacy Linux Landlock filesystem backend." + .to_string(), + )); + } + let writable_roots = sandbox_policy .get_writable_roots_with_cwd(cwd) .into_iter() @@ -70,9 +77,6 @@ pub(crate) fn apply_sandbox_policy_to_current_thread( install_filesystem_landlock_rules_on_current_thread(writable_roots)?; } - // TODO(ragona): Add appropriate restrictions if - // `sandbox_policy.has_full_disk_read_access()` is `false`. - Ok(()) } @@ -222,11 +226,11 @@ mod tests { #[test] fn restricted_network_policy_always_installs_seccomp() { assert!(should_install_network_seccomp( - &SandboxPolicy::ReadOnly, + &SandboxPolicy::new_read_only_policy(), false )); assert!(should_install_network_seccomp( - &SandboxPolicy::ReadOnly, + &SandboxPolicy::new_read_only_policy(), true )); } diff --git a/codex-rs/linux-sandbox/src/linux_run_main.rs b/codex-rs/linux-sandbox/src/linux_run_main.rs index 2978efae2a2..1df9a54438e 100644 --- a/codex-rs/linux-sandbox/src/linux_run_main.rs +++ b/codex-rs/linux-sandbox/src/linux_run_main.rs @@ -391,7 +391,7 @@ mod tests { fn inserts_bwrap_argv0_before_command_separator() { let argv = build_bwrap_argv( vec!["/bin/true".to_string()], - &SandboxPolicy::ReadOnly, + &SandboxPolicy::new_read_only_policy(), Path::new("/"), BwrapOptions { mount_proc: true, @@ -425,7 +425,7 @@ mod tests { fn inserts_unshare_net_when_network_isolation_requested() { let argv = build_bwrap_argv( vec!["/bin/true".to_string()], - &SandboxPolicy::ReadOnly, + &SandboxPolicy::new_read_only_policy(), Path::new("/"), BwrapOptions { mount_proc: true, @@ -439,7 +439,7 @@ mod tests { fn inserts_unshare_net_when_proxy_only_network_mode_requested() { let argv = build_bwrap_argv( vec!["/bin/true".to_string()], - &SandboxPolicy::ReadOnly, + &SandboxPolicy::new_read_only_policy(), Path::new("/"), BwrapOptions { mount_proc: true, diff --git a/codex-rs/linux-sandbox/tests/suite/landlock.rs b/codex-rs/linux-sandbox/tests/suite/landlock.rs index 6623a3f09f6..84fa1d2c3ee 100644 --- a/codex-rs/linux-sandbox/tests/suite/landlock.rs +++ b/codex-rs/linux-sandbox/tests/suite/landlock.rs @@ -87,6 +87,7 @@ async fn run_cmd_result_with_writable_roots( .iter() .map(|p| AbsolutePathBuf::try_from(p.as_path()).unwrap()) .collect(), + read_only_access: Default::default(), network_access: false, // Exclude tmp-related folders from writable roots because we need a // folder that is writable by tests but that we intentionally disallow diff --git a/codex-rs/mcp-server/src/outgoing_message.rs b/codex-rs/mcp-server/src/outgoing_message.rs index 1bf98534b06..3f83e1990e3 100644 --- a/codex-rs/mcp-server/src/outgoing_message.rs +++ b/codex-rs/mcp-server/src/outgoing_message.rs @@ -301,7 +301,7 @@ mod tests { model: "gpt-4o".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::ReadOnly, + sandbox_policy: SandboxPolicy::new_read_only_policy(), cwd: PathBuf::from("/home/user/project"), reasoning_effort: Some(ReasoningEffort::default()), history_log_id: 1, @@ -343,7 +343,7 @@ mod tests { model: "gpt-4o".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::ReadOnly, + sandbox_policy: SandboxPolicy::new_read_only_policy(), cwd: PathBuf::from("/home/user/project"), reasoning_effort: Some(ReasoningEffort::default()), history_log_id: 1, @@ -409,7 +409,7 @@ mod tests { model: "gpt-4o".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::ReadOnly, + sandbox_policy: SandboxPolicy::new_read_only_policy(), cwd: PathBuf::from("/home/user/project"), reasoning_effort: Some(ReasoningEffort::default()), history_log_id: 1, diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 7f331997020..b5ce0f2b2ca 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -312,7 +312,7 @@ impl DeveloperInstructions { let (sandbox_mode, writable_roots) = match sandbox_policy { SandboxPolicy::DangerFullAccess => (SandboxMode::DangerFullAccess, None), - SandboxPolicy::ReadOnly => (SandboxMode::ReadOnly, None), + SandboxPolicy::ReadOnly { .. } => (SandboxMode::ReadOnly, None), SandboxPolicy::ExternalSandbox { .. } => (SandboxMode::DangerFullAccess, None), SandboxPolicy::WorkspaceWrite { .. } => { let roots = sandbox_policy.get_writable_roots_with_cwd(cwd); @@ -1223,6 +1223,7 @@ mod tests { fn builds_permissions_from_policy() { let policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![], + read_only_access: Default::default(), network_access: true, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index cbf51fc6516..dbab8edc29c 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -4,6 +4,7 @@ //! between user and agent. use std::collections::HashMap; +use std::collections::HashSet; use std::ffi::OsStr; use std::fmt; use std::path::Path; @@ -382,6 +383,107 @@ impl NetworkAccess { } } +fn default_include_platform_defaults() -> bool { + true +} + +/// Determines how read-only file access is granted inside a restricted +/// sandbox. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Display, Default, JsonSchema, TS)] +#[strum(serialize_all = "kebab-case")] +#[serde(tag = "type", rename_all = "kebab-case")] +#[ts(tag = "type")] +pub enum ReadOnlyAccess { + /// Restrict reads to an explicit set of roots. + /// + /// When `include_platform_defaults` is `true`, platform defaults required + /// for basic execution are included in addition to `readable_roots`. + Restricted { + /// Include built-in platform read roots required for basic process + /// execution. + #[serde(default = "default_include_platform_defaults")] + include_platform_defaults: bool, + /// Additional absolute roots that should be readable. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + readable_roots: Vec, + }, + + /// Allow unrestricted file reads. + #[default] + FullAccess, +} + +impl ReadOnlyAccess { + pub fn has_full_disk_read_access(&self) -> bool { + matches!(self, ReadOnlyAccess::FullAccess) + } + + /// Returns the readable roots for restricted read access. + /// + /// For [`ReadOnlyAccess::FullAccess`], returns an empty list because + /// callers should grant blanket read access instead. + pub fn get_readable_roots_with_cwd(&self, cwd: &Path) -> Vec { + let mut roots: Vec = match self { + ReadOnlyAccess::FullAccess => return Vec::new(), + ReadOnlyAccess::Restricted { + include_platform_defaults, + readable_roots, + } => { + let mut roots = readable_roots.clone(); + if *include_platform_defaults { + #[cfg(target_os = "macos")] + for platform_path in [ + "/bin", "/dev", "/etc", "/Library", "/private", "/sbin", "/System", "/tmp", + "/usr", + ] { + #[allow(clippy::expect_used)] + roots.push( + AbsolutePathBuf::from_absolute_path(platform_path) + .expect("platform defaults should be absolute"), + ); + } + + #[cfg(target_os = "linux")] + for platform_path in ["/bin", "/dev", "/etc", "/lib", "/lib64", "/tmp", "/usr"] + { + #[allow(clippy::expect_used)] + roots.push( + AbsolutePathBuf::from_absolute_path(platform_path) + .expect("platform defaults should be absolute"), + ); + } + + #[cfg(target_os = "windows")] + for platform_path in [ + r"C:\Windows", + r"C:\Program Files", + r"C:\Program Files (x86)", + r"C:\ProgramData", + ] { + #[allow(clippy::expect_used)] + roots.push( + AbsolutePathBuf::from_absolute_path(platform_path) + .expect("platform defaults should be absolute"), + ); + } + + match AbsolutePathBuf::from_absolute_path(cwd) { + Ok(cwd_root) => roots.push(cwd_root), + Err(err) => { + error!("Ignoring invalid cwd {cwd:?} for sandbox readable root: {err}"); + } + } + } + roots + } + }; + + let mut seen = HashSet::new(); + roots.retain(|root| seen.insert(root.to_path_buf())); + roots + } +} + /// Determines execution restrictions for model shell commands. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Display, JsonSchema, TS)] #[strum(serialize_all = "kebab-case")] @@ -391,9 +493,16 @@ pub enum SandboxPolicy { #[serde(rename = "danger-full-access")] DangerFullAccess, - /// Read-only access to the entire file-system. + /// Read-only access configuration. #[serde(rename = "read-only")] - ReadOnly, + ReadOnly { + /// Read access granted while running under this policy. + #[serde( + default, + skip_serializing_if = "ReadOnlyAccess::has_full_disk_read_access" + )] + access: ReadOnlyAccess, + }, /// Indicates the process is already in an external sandbox. Allows full /// disk access while honoring the provided network setting. @@ -413,6 +522,13 @@ pub enum SandboxPolicy { #[serde(default, skip_serializing_if = "Vec::is_empty")] writable_roots: Vec, + /// Read access granted while running under this policy. + #[serde( + default, + skip_serializing_if = "ReadOnlyAccess::has_full_disk_read_access" + )] + read_only_access: ReadOnlyAccess, + /// When set to `true`, outbound network access is allowed. `false` by /// default. #[serde(default)] @@ -473,7 +589,9 @@ impl FromStr for SandboxPolicy { impl SandboxPolicy { /// Returns a policy with read-only disk access and no network. pub fn new_read_only_policy() -> Self { - SandboxPolicy::ReadOnly + SandboxPolicy::ReadOnly { + access: ReadOnlyAccess::FullAccess, + } } /// Returns a policy that can read the entire disk, but can only write to @@ -482,22 +600,29 @@ impl SandboxPolicy { pub fn new_workspace_write_policy() -> Self { SandboxPolicy::WorkspaceWrite { writable_roots: vec![], + read_only_access: ReadOnlyAccess::FullAccess, network_access: false, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, } } - /// Always returns `true`; restricting read access is not supported. pub fn has_full_disk_read_access(&self) -> bool { - true + match self { + SandboxPolicy::DangerFullAccess => true, + SandboxPolicy::ExternalSandbox { .. } => true, + SandboxPolicy::ReadOnly { access } => access.has_full_disk_read_access(), + SandboxPolicy::WorkspaceWrite { + read_only_access, .. + } => read_only_access.has_full_disk_read_access(), + } } pub fn has_full_disk_write_access(&self) -> bool { match self { SandboxPolicy::DangerFullAccess => true, SandboxPolicy::ExternalSandbox { .. } => true, - SandboxPolicy::ReadOnly => false, + SandboxPolicy::ReadOnly { .. } => false, SandboxPolicy::WorkspaceWrite { .. } => false, } } @@ -506,11 +631,37 @@ impl SandboxPolicy { match self { SandboxPolicy::DangerFullAccess => true, SandboxPolicy::ExternalSandbox { network_access } => network_access.is_enabled(), - SandboxPolicy::ReadOnly => false, + SandboxPolicy::ReadOnly { .. } => false, SandboxPolicy::WorkspaceWrite { network_access, .. } => *network_access, } } + /// Returns the list of readable roots (tailored to the current working + /// directory) when read access is restricted. + /// + /// For policies with full read access, this returns an empty list because + /// callers should grant blanket reads. + pub fn get_readable_roots_with_cwd(&self, cwd: &Path) -> Vec { + let mut roots = match self { + SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => Vec::new(), + SandboxPolicy::ReadOnly { access } => access.get_readable_roots_with_cwd(cwd), + SandboxPolicy::WorkspaceWrite { + read_only_access, .. + } => { + let mut roots = read_only_access.get_readable_roots_with_cwd(cwd); + roots.extend( + self.get_writable_roots_with_cwd(cwd) + .into_iter() + .map(|root| root.root), + ); + roots + } + }; + let mut seen = HashSet::new(); + roots.retain(|root| seen.insert(root.to_path_buf())); + roots + } + /// Returns the list of writable roots (tailored to the current working /// directory) together with subpaths that should remain read‑only under /// each writable root. @@ -518,9 +669,10 @@ impl SandboxPolicy { match self { SandboxPolicy::DangerFullAccess => Vec::new(), SandboxPolicy::ExternalSandbox { .. } => Vec::new(), - SandboxPolicy::ReadOnly => Vec::new(), + SandboxPolicy::ReadOnly { .. } => Vec::new(), SandboxPolicy::WorkspaceWrite { writable_roots, + read_only_access: _, exclude_tmpdir_env_var, exclude_slash_tmp, network_access: _, @@ -2565,6 +2717,38 @@ mod tests { assert!(enabled.has_full_network_access()); } + #[test] + fn workspace_write_restricted_read_access_includes_effective_writable_roots() { + let cwd = if cfg!(windows) { + Path::new(r"C:\workspace") + } else { + Path::new("/tmp/workspace") + }; + let policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + read_only_access: ReadOnlyAccess::Restricted { + include_platform_defaults: false, + readable_roots: vec![], + }, + network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: false, + }; + + let readable_roots = policy.get_readable_roots_with_cwd(cwd); + let writable_roots = policy.get_writable_roots_with_cwd(cwd); + + for writable_root in writable_roots { + assert!( + readable_roots + .iter() + .any(|root| root.as_path() == writable_root.root.as_path()), + "expected writable root {} to also be readable", + writable_root.root.as_path().display() + ); + } + } + #[test] fn item_started_event_from_web_search_emits_begin_event() { let event = ItemStartedEvent { @@ -2730,7 +2914,7 @@ mod tests { model: "codex-mini-latest".to_string(), model_provider_id: "openai".to_string(), approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::ReadOnly, + sandbox_policy: SandboxPolicy::new_read_only_policy(), cwd: PathBuf::from("/home/user/project"), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, diff --git a/codex-rs/state/src/model/thread_metadata.rs b/codex-rs/state/src/model/thread_metadata.rs index 2577ead502c..d29bf076067 100644 --- a/codex-rs/state/src/model/thread_metadata.rs +++ b/codex-rs/state/src/model/thread_metadata.rs @@ -138,7 +138,7 @@ impl ThreadMetadataBuilder { model_provider: None, cwd: PathBuf::new(), cli_version: None, - sandbox_policy: SandboxPolicy::ReadOnly, + sandbox_policy: SandboxPolicy::new_read_only_policy(), approval_mode: AskForApproval::OnRequest, archived_at: None, git_sha: None, diff --git a/codex-rs/state/src/runtime.rs b/codex-rs/state/src/runtime.rs index 3254ed63344..838af81fda0 100644 --- a/codex-rs/state/src/runtime.rs +++ b/codex-rs/state/src/runtime.rs @@ -2141,7 +2141,7 @@ VALUES (?, ?, ?, ?, ?) cwd, cli_version: "0.0.0".to_string(), title: String::new(), - sandbox_policy: crate::extract::enum_to_string(&SandboxPolicy::ReadOnly), + sandbox_policy: crate::extract::enum_to_string(&SandboxPolicy::new_read_only_policy()), approval_mode: crate::extract::enum_to_string(&AskForApproval::OnRequest), tokens_used: 0, first_user_message: Some("hello".to_string()), diff --git a/codex-rs/tui/src/additional_dirs.rs b/codex-rs/tui/src/additional_dirs.rs index 54746c17052..98724aa1617 100644 --- a/codex-rs/tui/src/additional_dirs.rs +++ b/codex-rs/tui/src/additional_dirs.rs @@ -16,7 +16,7 @@ pub fn add_dir_warning_message( SandboxPolicy::WorkspaceWrite { .. } | SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => None, - SandboxPolicy::ReadOnly => Some(format_warning(additional_dirs)), + SandboxPolicy::ReadOnly { .. } => Some(format_warning(additional_dirs)), } } @@ -64,7 +64,7 @@ mod tests { #[test] fn warns_for_read_only() { - let sandbox = SandboxPolicy::ReadOnly; + let sandbox = SandboxPolicy::new_read_only_policy(); let dirs = vec![PathBuf::from("relative"), PathBuf::from("/abs")]; let message = add_dir_warning_message(&dirs, &sandbox) .expect("expected warning for read-only sandbox"); @@ -76,7 +76,7 @@ mod tests { #[test] fn returns_none_when_no_additional_dirs() { - let sandbox = SandboxPolicy::ReadOnly; + let sandbox = SandboxPolicy::new_read_only_policy(); let dirs: Vec = Vec::new(); assert_eq!(add_dir_warning_message(&dirs, &sandbox), None); } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index d5f183e8edc..2f426570fd6 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1158,7 +1158,7 @@ impl App { && matches!( app.config.sandbox_policy.get(), codex_core::protocol::SandboxPolicy::WorkspaceWrite { .. } - | codex_core::protocol::SandboxPolicy::ReadOnly + | codex_core::protocol::SandboxPolicy::ReadOnly { .. } ) && !app .config @@ -2016,9 +2016,8 @@ impl App { let policy_is_workspace_write_or_ro = matches!( &policy, codex_core::protocol::SandboxPolicy::WorkspaceWrite { .. } - | codex_core::protocol::SandboxPolicy::ReadOnly + | codex_core::protocol::SandboxPolicy::ReadOnly { .. } ); - #[cfg(target_os = "windows")] let policy_for_chat = policy.clone(); if let Err(err) = self.config.sandbox_policy.set(policy) { @@ -2027,7 +2026,6 @@ impl App { .add_error_message(format!("Failed to set sandbox policy: {err}")); return Ok(AppRunControl::Continue); } - #[cfg(target_os = "windows")] if let Err(err) = self.chat_widget.set_sandbox_policy(policy_for_chat) { tracing::warn!(%err, "failed to set sandbox policy on chat config"); self.chat_widget @@ -3081,7 +3079,7 @@ mod tests { model: "gpt-test".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::ReadOnly, + sandbox_policy: SandboxPolicy::new_read_only_policy(), cwd: PathBuf::from("/home/user/project"), reasoning_effort: None, history_log_id: 0, @@ -3136,7 +3134,7 @@ mod tests { model: "gpt-test".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::ReadOnly, + sandbox_policy: SandboxPolicy::new_read_only_policy(), cwd: PathBuf::from("/home/user/project"), reasoning_effort: None, history_log_id: 0, @@ -3185,7 +3183,7 @@ mod tests { model: "gpt-test".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::ReadOnly, + sandbox_policy: SandboxPolicy::new_read_only_policy(), cwd: PathBuf::from("/home/user/project"), reasoning_effort: None, history_log_id: 0, @@ -3264,7 +3262,7 @@ mod tests { model: "gpt-test".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::ReadOnly, + sandbox_policy: SandboxPolicy::new_read_only_policy(), cwd: PathBuf::from("/home/user/project"), reasoning_effort: None, history_log_id: 0, @@ -3388,7 +3386,7 @@ mod tests { model: "gpt-test".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::ReadOnly, + sandbox_policy: SandboxPolicy::new_read_only_policy(), cwd: PathBuf::from("/home/user/project"), reasoning_effort: None, history_log_id: 0, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index f189f539014..eeac3a35049 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -5554,7 +5554,7 @@ impl ChatWidget { let mut header_children: Vec> = Vec::new(); let describe_policy = |policy: &SandboxPolicy| match policy { SandboxPolicy::WorkspaceWrite { .. } => "Agent mode", - SandboxPolicy::ReadOnly => "Read-Only mode", + SandboxPolicy::ReadOnly { .. } => "Read-Only mode", _ => "Agent mode", }; let mode_label = preset diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index f4a3f14e222..08471bdb650 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -151,7 +151,7 @@ async fn resumed_initial_messages_render_history() { model: "test-model".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::ReadOnly, + sandbox_policy: SandboxPolicy::new_read_only_policy(), cwd: PathBuf::from("/home/user/project"), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -219,7 +219,7 @@ async fn replayed_user_message_preserves_text_elements_and_local_images() { model: "test-model".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::ReadOnly, + sandbox_policy: SandboxPolicy::new_read_only_policy(), cwd: PathBuf::from("/home/user/project"), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -337,7 +337,7 @@ async fn submission_preserves_text_elements_and_local_images() { model: "test-model".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::ReadOnly, + sandbox_policy: SandboxPolicy::new_read_only_policy(), cwd: PathBuf::from("/home/user/project"), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -417,7 +417,7 @@ async fn submission_prefers_selected_duplicate_skill_path() { model: "test-model".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::ReadOnly, + sandbox_policy: SandboxPolicy::new_read_only_policy(), cwd: PathBuf::from("/home/user/project"), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -3105,7 +3105,7 @@ async fn plan_slash_command_with_args_submits_prompt_in_plan_mode() { model: "test-model".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::ReadOnly, + sandbox_policy: SandboxPolicy::new_read_only_policy(), cwd: PathBuf::from("/home/user/project"), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -4183,6 +4183,7 @@ async fn preset_matching_requires_exact_workspace_write_settings() { .expect("auto preset exists"); let current_sandbox = SandboxPolicy::WorkspaceWrite { writable_roots: vec![AbsolutePathBuf::try_from("C:\\extra").unwrap()], + read_only_access: Default::default(), network_access: false, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, diff --git a/codex-rs/tui/src/debug_config.rs b/codex-rs/tui/src/debug_config.rs index ddc155da0c8..135a0b88272 100644 --- a/codex-rs/tui/src/debug_config.rs +++ b/codex-rs/tui/src/debug_config.rs @@ -474,13 +474,14 @@ mod tests { } else { absolute_path("/etc/codex/requirements.toml") }; + let requirements = ConfigRequirements { approval_policy: ConstrainedWithSource::new( Constrained::allow_any(AskForApproval::OnRequest), Some(RequirementSource::CloudRequirements), ), sandbox_policy: ConstrainedWithSource::new( - Constrained::allow_any(SandboxPolicy::ReadOnly), + Constrained::allow_any(SandboxPolicy::new_read_only_policy()), Some(RequirementSource::SystemRequirementsToml { file: requirements_file.clone(), }), @@ -572,7 +573,6 @@ mod tests { )); assert!(!rendered.contains(" - rules:")); } - #[test] fn debug_config_output_lists_session_flag_key_value_pairs() { let session_flags = toml::from_str::( diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index 334a6551989..224c96fa996 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -193,7 +193,7 @@ impl StatusHistoryCell { .unwrap_or_else(|| "".to_string()); let sandbox = match config.sandbox_policy.get() { SandboxPolicy::DangerFullAccess => "danger-full-access".to_string(), - SandboxPolicy::ReadOnly => "read-only".to_string(), + SandboxPolicy::ReadOnly { .. } => "read-only".to_string(), SandboxPolicy::WorkspaceWrite { network_access: true, .. diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index e46baf5b393..c0e03847283 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -101,6 +101,7 @@ async fn status_snapshot_includes_reasoning_details() { .sandbox_policy .set(SandboxPolicy::WorkspaceWrite { writable_roots: Vec::new(), + read_only_access: Default::default(), network_access: false, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, @@ -183,6 +184,7 @@ async fn status_permissions_non_default_workspace_write_is_custom() { .sandbox_policy .set(SandboxPolicy::WorkspaceWrite { writable_roots: Vec::new(), + read_only_access: Default::default(), network_access: true, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, diff --git a/codex-rs/utils/approval-presets/src/lib.rs b/codex-rs/utils/approval-presets/src/lib.rs index cec67d258df..9e66e39e086 100644 --- a/codex-rs/utils/approval-presets/src/lib.rs +++ b/codex-rs/utils/approval-presets/src/lib.rs @@ -26,7 +26,7 @@ pub fn builtin_approval_presets() -> Vec { label: "Read Only", description: "Codex can read files in the current workspace. Approval is required to edit files or access the internet.", approval: AskForApproval::OnRequest, - sandbox: SandboxPolicy::ReadOnly, + sandbox: SandboxPolicy::new_read_only_policy(), }, ApprovalPreset { id: "auto", diff --git a/codex-rs/utils/sandbox-summary/src/sandbox_summary.rs b/codex-rs/utils/sandbox-summary/src/sandbox_summary.rs index 45520b11a00..53851d86b74 100644 --- a/codex-rs/utils/sandbox-summary/src/sandbox_summary.rs +++ b/codex-rs/utils/sandbox-summary/src/sandbox_summary.rs @@ -4,7 +4,7 @@ use codex_core::protocol::SandboxPolicy; pub fn summarize_sandbox_policy(sandbox_policy: &SandboxPolicy) -> String { match sandbox_policy { SandboxPolicy::DangerFullAccess => "danger-full-access".to_string(), - SandboxPolicy::ReadOnly => "read-only".to_string(), + SandboxPolicy::ReadOnly { .. } => "read-only".to_string(), SandboxPolicy::ExternalSandbox { network_access } => { let mut summary = "external-sandbox".to_string(); if matches!(network_access, NetworkAccess::Enabled) { @@ -17,6 +17,7 @@ pub fn summarize_sandbox_policy(sandbox_policy: &SandboxPolicy) -> String { network_access, exclude_tmpdir_env_var, exclude_slash_tmp, + read_only_access: _, } => { let mut summary = "workspace-write".to_string(); @@ -71,6 +72,7 @@ mod tests { let writable_root = AbsolutePathBuf::try_from(root).unwrap(); let summary = summarize_sandbox_policy(&SandboxPolicy::WorkspaceWrite { writable_roots: vec![writable_root.clone()], + read_only_access: Default::default(), network_access: true, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, diff --git a/codex-rs/windows-sandbox-rs/src/allow.rs b/codex-rs/windows-sandbox-rs/src/allow.rs index 83d72f7e557..d2a2f949152 100644 --- a/codex-rs/windows-sandbox-rs/src/allow.rs +++ b/codex-rs/windows-sandbox-rs/src/allow.rs @@ -108,6 +108,7 @@ mod tests { let policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![AbsolutePathBuf::try_from(extra_root.as_path()).unwrap()], + read_only_access: Default::default(), network_access: false, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, @@ -134,6 +135,7 @@ mod tests { let policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![], + read_only_access: Default::default(), network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: false, @@ -161,6 +163,7 @@ mod tests { let policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![], + read_only_access: Default::default(), network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: false, @@ -188,6 +191,7 @@ mod tests { let policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![], + read_only_access: Default::default(), network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: false, @@ -213,6 +217,7 @@ mod tests { let policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![], + read_only_access: Default::default(), network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: false, diff --git a/codex-rs/windows-sandbox-rs/src/audit.rs b/codex-rs/windows-sandbox-rs/src/audit.rs index 1af9f1b9199..2aefb7a3fd6 100644 --- a/codex-rs/windows-sandbox-rs/src/audit.rs +++ b/codex-rs/windows-sandbox-rs/src/audit.rs @@ -262,7 +262,7 @@ pub fn apply_capability_denies_for_world_writable( } (sid, roots) } - SandboxPolicy::ReadOnly => ( + SandboxPolicy::ReadOnly { .. } => ( unsafe { convert_string_sid_to_sid(&caps.readonly) }.ok_or_else(|| { anyhow!("ConvertStringSidToSidW failed for readonly capability") })?, diff --git a/codex-rs/windows-sandbox-rs/src/command_runner_win.rs b/codex-rs/windows-sandbox-rs/src/command_runner_win.rs index 9ff61de932b..da949f87789 100644 --- a/codex-rs/windows-sandbox-rs/src/command_runner_win.rs +++ b/codex-rs/windows-sandbox-rs/src/command_runner_win.rs @@ -114,6 +114,11 @@ pub fn main() -> Result<()> { ); let policy = parse_policy(&req.policy_json_or_preset).context("parse policy_json_or_preset")?; + if !policy.has_full_disk_read_access() { + anyhow::bail!( + "Restricted read-only access is not yet supported by the Windows sandbox backend" + ); + } let mut cap_psids: Vec<*mut c_void> = Vec::new(); for sid in &req.cap_sids { let Some(psid) = (unsafe { convert_string_sid_to_sid(sid) }) else { @@ -129,7 +134,9 @@ pub fn main() -> Result<()> { let base = unsafe { get_current_token_for_restriction()? }; let token_res: Result = unsafe { match &policy { - SandboxPolicy::ReadOnly => create_readonly_token_with_caps_from(base, &cap_psids), + SandboxPolicy::ReadOnly { .. } => { + create_readonly_token_with_caps_from(base, &cap_psids) + } SandboxPolicy::WorkspaceWrite { .. } => { create_workspace_write_token_with_caps_from(base, &cap_psids) } diff --git a/codex-rs/windows-sandbox-rs/src/elevated_impl.rs b/codex-rs/windows-sandbox-rs/src/elevated_impl.rs index ecd5732c09b..8f8de37e0e2 100644 --- a/codex-rs/windows-sandbox-rs/src/elevated_impl.rs +++ b/codex-rs/windows-sandbox-rs/src/elevated_impl.rs @@ -238,9 +238,14 @@ mod windows_impl { ) { anyhow::bail!("DangerFullAccess and ExternalSandbox are not supported for sandboxing") } + if !policy.has_full_disk_read_access() { + anyhow::bail!( + "Restricted read-only access is not yet supported by the Windows sandbox backend" + ); + } let caps = load_or_create_cap_sids(codex_home)?; let (psid_to_use, cap_sids) = match &policy { - SandboxPolicy::ReadOnly => ( + SandboxPolicy::ReadOnly { .. } => ( unsafe { convert_string_sid_to_sid(&caps.readonly).unwrap() }, vec![caps.readonly.clone()], ), @@ -469,6 +474,7 @@ mod windows_impl { fn workspace_policy(network_access: bool) -> SandboxPolicy { SandboxPolicy::WorkspaceWrite { writable_roots: Vec::new(), + read_only_access: Default::default(), network_access, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, @@ -487,7 +493,7 @@ mod windows_impl { #[test] fn applies_network_block_for_read_only() { - assert!(!SandboxPolicy::ReadOnly.has_full_network_access()); + assert!(!SandboxPolicy::new_read_only_policy().has_full_network_access()); } } } diff --git a/codex-rs/windows-sandbox-rs/src/lib.rs b/codex-rs/windows-sandbox-rs/src/lib.rs index 525f216ae46..3e15c7a272c 100644 --- a/codex-rs/windows-sandbox-rs/src/lib.rs +++ b/codex-rs/windows-sandbox-rs/src/lib.rs @@ -262,10 +262,15 @@ mod windows_impl { ) { anyhow::bail!("DangerFullAccess and ExternalSandbox are not supported for sandboxing") } + if !policy.has_full_disk_read_access() { + anyhow::bail!( + "Restricted read-only access is not yet supported by the Windows sandbox backend" + ); + } let caps = load_or_create_cap_sids(codex_home)?; let (h_token, psid_generic, psid_workspace): (HANDLE, *mut c_void, Option<*mut c_void>) = unsafe { match &policy { - SandboxPolicy::ReadOnly => { + SandboxPolicy::ReadOnly { .. } => { let psid = convert_string_sid_to_sid(&caps.readonly).unwrap(); let (h, _) = super::token::create_readonly_token_with_cap(psid)?; (h, psid, None) @@ -558,6 +563,7 @@ mod windows_impl { fn workspace_policy(network_access: bool) -> SandboxPolicy { SandboxPolicy::WorkspaceWrite { writable_roots: Vec::new(), + read_only_access: Default::default(), network_access, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, @@ -576,7 +582,9 @@ mod windows_impl { #[test] fn applies_network_block_for_read_only() { - assert!(should_apply_network_block(&SandboxPolicy::ReadOnly)); + assert!(should_apply_network_block( + &SandboxPolicy::new_read_only_policy() + )); } } } diff --git a/codex-rs/windows-sandbox-rs/src/policy.rs b/codex-rs/windows-sandbox-rs/src/policy.rs index 64fc56052f5..3cee37cdf7d 100644 --- a/codex-rs/windows-sandbox-rs/src/policy.rs +++ b/codex-rs/windows-sandbox-rs/src/policy.rs @@ -3,7 +3,7 @@ pub use codex_protocol::protocol::SandboxPolicy; pub fn parse_policy(value: &str) -> Result { match value { - "read-only" => Ok(SandboxPolicy::ReadOnly), + "read-only" => Ok(SandboxPolicy::new_read_only_policy()), "workspace-write" => Ok(SandboxPolicy::new_workspace_write_policy()), "danger-full-access" | "external-sandbox" => anyhow::bail!( "DangerFullAccess and ExternalSandbox are not supported for sandboxing" @@ -52,6 +52,9 @@ mod tests { #[test] fn parses_read_only_policy() { - assert_eq!(parse_policy("read-only").unwrap(), SandboxPolicy::ReadOnly); + assert_eq!( + parse_policy("read-only").unwrap(), + SandboxPolicy::new_read_only_policy() + ); } }