From 4b39a275085b29e61f0cad44de7ef98db5ab256b Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Thu, 14 May 2026 17:24:22 -0700 Subject: [PATCH] permissions: support workspace roots in profiles --- codex-rs/Cargo.lock | 1 + .../codex_app_server_protocol.schemas.json | 33 -- .../codex_app_server_protocol.v2.schemas.json | 33 -- .../schema/json/v2/ThreadForkResponse.json | 33 -- .../schema/json/v2/ThreadResumeResponse.json | 33 -- .../schema/json/v2/ThreadStartResponse.json | 33 -- .../typescript/v2/ActivePermissionProfile.ts | 8 +- .../v2/ActivePermissionProfileModification.ts | 6 - .../schema/typescript/v2/index.ts | 1 - .../src/protocol/v2/permissions.rs | 46 --- codex-rs/app-server/src/lib.rs | 2 +- .../command_exec_processor.rs | 8 +- .../request_processors/thread_processor.rs | 2 +- .../src/request_processors/turn_processor.rs | 2 +- codex-rs/cli/src/debug_sandbox.rs | 38 ++- codex-rs/config/src/permissions_toml.rs | 15 + codex-rs/core/config.schema.json | 6 + codex-rs/core/src/config/config_tests.rs | 236 ++++++++++--- codex-rs/core/src/config/mod.rs | 319 +++++++++++++----- codex-rs/core/src/config/permissions.rs | 56 ++- codex-rs/core/src/config/permissions_tests.rs | 36 ++ .../src/context/permissions_instructions.rs | 3 +- codex-rs/core/src/guardian/review_session.rs | 6 +- codex-rs/core/src/guardian/tests.rs | 8 +- codex-rs/core/src/session/mod.rs | 16 +- codex-rs/core/src/session/session.rs | 4 +- codex-rs/core/src/session/tests.rs | 57 +++- codex-rs/core/src/session/turn_context.rs | 10 +- .../src/tools/handlers/multi_agents_tests.rs | 4 +- .../core/tests/suite/permissions_messages.rs | 5 +- codex-rs/core/tests/suite/unified_exec.rs | 18 +- codex-rs/core/tests/suite/user_shell_cmd.rs | 8 +- codex-rs/exec/Cargo.toml | 1 + .../src/event_processor_with_human_output.rs | 87 +---- ...event_processor_with_human_output_tests.rs | 26 +- codex-rs/exec/src/lib.rs | 35 +- codex-rs/exec/src/lib_tests.rs | 24 ++ codex-rs/protocol/src/models.rs | 46 ++- codex-rs/protocol/src/permissions.rs | 228 ++++++++++++- codex-rs/thread-manager-sample/src/main.rs | 19 +- codex-rs/tui/src/app.rs | 2 +- codex-rs/tui/src/app/config_persistence.rs | 12 +- codex-rs/tui/src/app/event_dispatch.rs | 5 +- codex-rs/tui/src/app/startup_prompts.rs | 2 +- codex-rs/tui/src/app/tests.rs | 18 +- codex-rs/tui/src/app/thread_routing.rs | 5 +- codex-rs/tui/src/app/thread_session_state.rs | 12 +- codex-rs/tui/src/app_server_session.rs | 91 +++-- .../tui/src/chatwidget/input_submission.rs | 2 +- .../tui/src/chatwidget/permission_popups.rs | 2 +- codex-rs/tui/src/chatwidget/session_flow.rs | 29 +- .../tui/src/chatwidget/status_surfaces.rs | 8 +- .../src/chatwidget/tests/history_replay.rs | 70 +++- .../src/chatwidget/windows_sandbox_prompts.rs | 13 +- codex-rs/tui/src/history_cell.rs | 2 +- codex-rs/tui/src/lib.rs | 2 +- codex-rs/tui/src/status/card.rs | 88 +++-- codex-rs/tui/src/status/tests.rs | 97 ++++-- codex-rs/utils/sandbox-summary/Cargo.toml | 1 + .../sandbox-summary/src/sandbox_summary.rs | 73 +++- 60 files changed, 1355 insertions(+), 731 deletions(-) delete mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfileModification.ts diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 4d1f66008fdf..50de8dc94695 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2715,6 +2715,7 @@ dependencies = [ "codex-utils-cargo-bin", "codex-utils-cli", "codex-utils-oss", + "codex-utils-sandbox-summary", "core_test_support", "libc", "opentelemetry", 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 e996ca54172f..8bcd2edd6d87 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 @@ -5607,14 +5607,6 @@ "id": { "description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", "type": "string" - }, - "modifications": { - "default": [], - "description": "Bounded user-requested modifications applied on top of the named profile, if any.", - "items": { - "$ref": "#/definitions/v2/ActivePermissionProfileModification" - }, - "type": "array" } }, "required": [ @@ -5622,31 +5614,6 @@ ], "type": "object" }, - "ActivePermissionProfileModification": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/v2/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootActivePermissionProfileModificationType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootActivePermissionProfileModification", - "type": "object" - } - ] - }, "AddCreditsNudgeCreditType": { "enum": [ "credits", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 7f73f9fe07bc..a7b8a260077a 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -143,14 +143,6 @@ "id": { "description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", "type": "string" - }, - "modifications": { - "default": [], - "description": "Bounded user-requested modifications applied on top of the named profile, if any.", - "items": { - "$ref": "#/definitions/ActivePermissionProfileModification" - }, - "type": "array" } }, "required": [ @@ -158,31 +150,6 @@ ], "type": "object" }, - "ActivePermissionProfileModification": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootActivePermissionProfileModificationType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootActivePermissionProfileModification", - "type": "object" - } - ] - }, "AddCreditsNudgeCreditType": { "enum": [ "credits", 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 6e74ab4ac8f3..4eb85f4ed33e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -18,14 +18,6 @@ "id": { "description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", "type": "string" - }, - "modifications": { - "default": [], - "description": "Bounded user-requested modifications applied on top of the named profile, if any.", - "items": { - "$ref": "#/definitions/ActivePermissionProfileModification" - }, - "type": "array" } }, "required": [ @@ -33,31 +25,6 @@ ], "type": "object" }, - "ActivePermissionProfileModification": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootActivePermissionProfileModificationType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootActivePermissionProfileModification", - "type": "object" - } - ] - }, "AgentPath": { "type": "string" }, 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 727b7a3fb2fd..312d289e4198 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -18,14 +18,6 @@ "id": { "description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", "type": "string" - }, - "modifications": { - "default": [], - "description": "Bounded user-requested modifications applied on top of the named profile, if any.", - "items": { - "$ref": "#/definitions/ActivePermissionProfileModification" - }, - "type": "array" } }, "required": [ @@ -33,31 +25,6 @@ ], "type": "object" }, - "ActivePermissionProfileModification": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootActivePermissionProfileModificationType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootActivePermissionProfileModification", - "type": "object" - } - ] - }, "AgentPath": { "type": "string" }, 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 bf03f0fb5575..c363f2e78d41 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -18,14 +18,6 @@ "id": { "description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", "type": "string" - }, - "modifications": { - "default": [], - "description": "Bounded user-requested modifications applied on top of the named profile, if any.", - "items": { - "$ref": "#/definitions/ActivePermissionProfileModification" - }, - "type": "array" } }, "required": [ @@ -33,31 +25,6 @@ ], "type": "object" }, - "ActivePermissionProfileModification": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootActivePermissionProfileModificationType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootActivePermissionProfileModification", - "type": "object" - } - ] - }, "AgentPath": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfile.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfile.ts index cbc8c6ef0a7f..73f9efcab574 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfile.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfile.ts @@ -1,7 +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 { ActivePermissionProfileModification } from "./ActivePermissionProfileModification"; export type ActivePermissionProfile = { /** @@ -13,9 +12,4 @@ id: string, * Parent profile identifier once permissions profiles support * inheritance. This is currently always `null`. */ -extends: string | null, -/** - * Bounded user-requested modifications applied on top of the named - * profile, if any. - */ -modifications: Array, }; +extends: string | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfileModification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfileModification.ts deleted file mode 100644 index 1cbee6868a26..000000000000 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfileModification.ts +++ /dev/null @@ -1,6 +0,0 @@ -// 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 ActivePermissionProfileModification = { "type": "additionalWritableRoot", path: AbsolutePathBuf, }; 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 984154ba04f1..0a6d868ad0fc 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -5,7 +5,6 @@ export type { AccountLoginCompletedNotification } from "./AccountLoginCompletedN export type { AccountRateLimitsUpdatedNotification } from "./AccountRateLimitsUpdatedNotification"; export type { AccountUpdatedNotification } from "./AccountUpdatedNotification"; export type { ActivePermissionProfile } from "./ActivePermissionProfile"; -export type { ActivePermissionProfileModification } from "./ActivePermissionProfileModification"; export type { AddCreditsNudgeCreditType } from "./AddCreditsNudgeCreditType"; export type { AddCreditsNudgeEmailStatus } from "./AddCreditsNudgeEmailStatus"; export type { AdditionalFileSystemPermissions } from "./AdditionalFileSystemPermissions"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs b/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs index 86614a6aeb21..0796ee4e893a 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs @@ -5,7 +5,6 @@ use codex_protocol::approvals::NetworkApprovalProtocol as CoreNetworkApprovalPro use codex_protocol::approvals::NetworkPolicyAmendment as CoreNetworkPolicyAmendment; use codex_protocol::approvals::NetworkPolicyRuleAction as CoreNetworkPolicyRuleAction; use codex_protocol::models::ActivePermissionProfile as CoreActivePermissionProfile; -use codex_protocol::models::ActivePermissionProfileModification as CoreActivePermissionProfileModification; use codex_protocol::models::AdditionalPermissionProfile as CoreAdditionalPermissionProfile; use codex_protocol::models::FileSystemPermissions as CoreFileSystemPermissions; use codex_protocol::models::ManagedFileSystemPermissions as CoreManagedFileSystemPermissions; @@ -437,41 +436,6 @@ pub struct ActivePermissionProfile { /// inheritance. This is currently always `null`. #[serde(default)] pub extends: Option, - /// Bounded user-requested modifications applied on top of the named - /// profile, if any. - #[serde(default)] - pub modifications: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum ActivePermissionProfileModification { - /// Additional concrete directory that should be writable. - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - AdditionalWritableRoot { path: AbsolutePathBuf }, -} - -impl From for ActivePermissionProfileModification { - fn from(value: CoreActivePermissionProfileModification) -> Self { - match value { - CoreActivePermissionProfileModification::AdditionalWritableRoot { path } => { - Self::AdditionalWritableRoot { path } - } - } - } -} - -impl From for CoreActivePermissionProfileModification { - fn from(value: ActivePermissionProfileModification) -> Self { - match value { - ActivePermissionProfileModification::AdditionalWritableRoot { path } => { - Self::AdditionalWritableRoot { path } - } - } - } } impl From for ActivePermissionProfile { @@ -479,11 +443,6 @@ impl From for ActivePermissionProfile { Self { id: value.id, extends: value.extends, - modifications: value - .modifications - .into_iter() - .map(ActivePermissionProfileModification::from) - .collect(), } } } @@ -493,11 +452,6 @@ impl From for CoreActivePermissionProfile { Self { id: value.id, extends: value.extends, - modifications: value - .modifications - .into_iter() - .map(CoreActivePermissionProfileModification::from) - .collect(), } } } diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 350acf002fab..f2ac6ca00afa 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -589,7 +589,7 @@ pub async fn run_main_with_transport_options( }); } if let Some(warning) = - codex_core::config::system_bwrap_warning(config.permissions.permission_profile.get()) + codex_core::config::system_bwrap_warning(config.permissions.permission_profile().get()) { config_warnings.push(ConfigWarningNotification { summary: warning, diff --git a/codex-rs/app-server/src/request_processors/command_exec_processor.rs b/codex-rs/app-server/src/request_processors/command_exec_processor.rs index 3236a67627d6..d1781db5ff7c 100644 --- a/codex-rs/app-server/src/request_processors/command_exec_processor.rs +++ b/codex-rs/app-server/src/request_processors/command_exec_processor.rs @@ -164,7 +164,7 @@ impl CommandExecRequestProcessor { let started_network_proxy = match self.config.permissions.network.as_ref() { Some(spec) => match spec .start_proxy( - self.config.permissions.permission_profile.get(), + self.config.permissions.permission_profile().get(), /*policy_decider*/ None, /*blocked_request_observer*/ None, managed_network_requirements_enabled, @@ -243,7 +243,7 @@ impl CommandExecRequestProcessor { ); self.config .permissions - .permission_profile + .permission_profile() .can_set(&effective_permission_profile) .map_err(|err| invalid_request(format!("invalid permission profile: {err}")))?; effective_permission_profile @@ -264,12 +264,12 @@ impl CommandExecRequestProcessor { ); self.config .permissions - .permission_profile + .permission_profile() .can_set(&permission_profile) .map_err(|err| invalid_request(format!("invalid sandbox policy: {err}")))?; permission_profile } else { - self.config.permissions.permission_profile() + self.config.permissions.effective_permission_profile() }; let codex_linux_sandbox_exe = self.arg0_paths.codex_linux_sandbox_exe.clone(); diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index 92da7cdd8fae..85b2e5757108 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -977,7 +977,7 @@ impl ThreadRequestProcessor { let requested_permissions_trust_project = requested_permissions_trust_project(&typesafe_overrides, config.cwd.as_path()); let effective_permissions_trust_project = permission_profile_trusts_project( - &config.permissions.permission_profile(), + &config.permissions.effective_permission_profile(), config.cwd.as_path(), ); diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index d1dae4ef46ea..110406cc9ead 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -413,7 +413,7 @@ impl TurnRequestProcessor { ))); } ( - Some(config.permissions.permission_profile()), + Some(config.permissions.effective_permission_profile()), config.permissions.active_permission_profile(), ) } else { diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index 30c60d7b9512..bdcf0a191f0f 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -230,7 +230,7 @@ async fn run_command_under_sandbox( let network_proxy = match config.permissions.network.as_ref() { Some(spec) => Some( spec.start_proxy( - config.permissions.permission_profile.get(), + config.permissions.permission_profile().get(), /*policy_decider*/ None, /*blocked_request_observer*/ None, managed_network_requirements_enabled, @@ -285,7 +285,7 @@ async fn run_command_under_sandbox( let args = create_linux_sandbox_command_args_for_permission_profile( command, cwd.as_path(), - &config.permissions.permission_profile(), + &config.permissions.effective_permission_profile(), sandbox_policy_cwd.as_path(), use_legacy_landlock, allow_network_for_proxy(managed_network_requirements_enabled), @@ -962,10 +962,19 @@ mod tests { ) .await?; - assert_eq!( - config.permissions.file_system_sandbox_policy(), - codex_protocol::models::PermissionProfile::workspace_write() - .file_system_sandbox_policy() + let actual = config + .permissions + .permission_profile() + .get() + .file_system_sandbox_policy(); + let expected = codex_protocol::models::PermissionProfile::workspace_write() + .file_system_sandbox_policy(); + assert!( + expected + .entries + .iter() + .all(|entry| actual.entries.contains(entry)), + "explicit workspace profile should preserve the built-in workspace rules" ); Ok(()) @@ -996,10 +1005,19 @@ mod tests { ) .await?; - assert_eq!( - config.permissions.file_system_sandbox_policy(), - codex_protocol::models::PermissionProfile::workspace_write() - .file_system_sandbox_policy() + let actual = config + .permissions + .permission_profile() + .get() + .file_system_sandbox_policy(); + let expected = codex_protocol::models::PermissionProfile::workspace_write() + .file_system_sandbox_policy(); + assert!( + expected + .entries + .iter() + .all(|entry| actual.entries.contains(entry)), + "explicit workspace profile should preserve the built-in workspace rules" ); Ok(()) diff --git a/codex-rs/config/src/permissions_toml.rs b/codex-rs/config/src/permissions_toml.rs index cee68d7abba0..fff8c677061b 100644 --- a/codex-rs/config/src/permissions_toml.rs +++ b/codex-rs/config/src/permissions_toml.rs @@ -25,10 +25,25 @@ impl PermissionsToml { #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] #[schemars(deny_unknown_fields)] pub struct PermissionProfileToml { + pub workspace_roots: Option, pub filesystem: Option, pub network: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +pub struct WorkspaceRootsToml { + #[serde(flatten)] + pub entries: BTreeMap, +} + +impl WorkspaceRootsToml { + pub fn enabled_roots(&self) -> impl Iterator { + self.entries + .iter() + .filter_map(|(path, enabled)| (*enabled).then_some(path)) + } +} + #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] pub struct FilesystemPermissionsToml { /// Optional maximum depth for expanding unreadable glob patterns on diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index dd2b01d0af7f..d802fa4e65f2 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1965,6 +1965,9 @@ }, "network": { "$ref": "#/definitions/NetworkToml" + }, + "workspace_roots": { + "$ref": "#/definitions/WorkspaceRootsToml" } }, "type": "object" @@ -3960,6 +3963,9 @@ "type": "string" } ] + }, + "WorkspaceRootsToml": { + "type": "object" } }, "description": "Base config deserialized from ~/.codex/config.toml.", diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 7760c2d32d6a..2e077bae35b3 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -29,6 +29,7 @@ use codex_config::permissions_toml::NetworkDomainPermissionsToml; use codex_config::permissions_toml::NetworkToml; use codex_config::permissions_toml::PermissionProfileToml; use codex_config::permissions_toml::PermissionsToml; +use codex_config::permissions_toml::WorkspaceRootsToml; use codex_config::profile_toml::ConfigProfile; use codex_config::types::AppToolApproval; use codex_config::types::ApprovalsReviewer; @@ -70,7 +71,6 @@ use codex_model_provider_info::WireApi; use codex_models_manager::bundled_models_response; use codex_protocol::config_types::ServiceTier; use codex_protocol::models::ActivePermissionProfile; -use codex_protocol::models::ActivePermissionProfileModification; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; @@ -722,6 +722,10 @@ fn config_toml_deserializes_permission_profiles() { let toml = r#" default_permissions = "workspace" +[permissions.workspace.workspace_roots] +"~/code/openai" = true +"~/code/ignored" = false + [permissions.workspace.filesystem] ":minimal" = "read" @@ -748,6 +752,12 @@ allow_upstream_proxy = false entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + workspace_roots: Some(WorkspaceRootsToml { + entries: BTreeMap::from([ + ("~/code/ignored".to_string(), false), + ("~/code/openai".to_string(), true), + ]), + }), filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([ @@ -803,6 +813,7 @@ async fn permissions_profiles_proxy_policy_does_not_start_managed_network_proxy_ entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( @@ -956,6 +967,7 @@ async fn network_proxy_feature_matrix_preserves_sandbox_network_semantics() -> s entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( @@ -1106,6 +1118,7 @@ async fn network_proxy_feature_uses_profile_network_proxy_settings() -> std::io: entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( @@ -1208,6 +1221,7 @@ enabled = false entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( @@ -1256,6 +1270,7 @@ async fn permissions_profiles_network_disabled_by_default_does_not_start_proxy() entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( @@ -1302,6 +1317,7 @@ async fn default_permissions_profile_populates_runtime_sandbox_policy() -> std:: entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([ @@ -1335,6 +1351,7 @@ async fn default_permissions_profile_populates_runtime_sandbox_policy() -> std:: ) .await?; + let cwd_root = cwd.path().abs(); let memories_root = codex_home.path().join("memories").abs(); assert_eq!( config.permissions.file_system_sandbox_policy(), @@ -1346,14 +1363,14 @@ async fn default_permissions_profile_populates_runtime_sandbox_policy() -> std:: access: FileSystemAccessMode::Read, }, FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::project_roots(/*subpath*/ None), + path: FileSystemPath::Path { + path: cwd_root.clone(), }, access: FileSystemAccessMode::Write, }, FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::project_roots(Some("docs".into())), + path: FileSystemPath::Path { + path: cwd_root.join("docs"), }, access: FileSystemAccessMode::Read, }, @@ -1374,6 +1391,12 @@ async fn default_permissions_profile_populates_runtime_sandbox_policy() -> std:: exclude_slash_tmp: true, } ); + assert!( + !config + .permissions + .file_system_sandbox_policy() + .can_write_path_with_cwd(&cwd.path().join(".git"), cwd.path()) + ); assert_eq!( config.permissions.network_sandbox_policy(), NetworkSandboxPolicy::Restricted @@ -1406,7 +1429,10 @@ async fn permission_profile_override_populates_runtime_permissions() -> std::io: ) .await?; - assert_eq!(config.permissions.permission_profile(), permission_profile); + assert_eq!( + config.permissions.effective_permission_profile(), + permission_profile + ); assert_eq!(config.permissions.active_permission_profile(), None); assert_eq!( &config.legacy_sandbox_policy(), @@ -1436,7 +1462,10 @@ async fn permission_profile_override_preserves_managed_unrestricted_filesystem() ) .await?; - assert_eq!(config.permissions.permission_profile(), permission_profile); + assert_eq!( + config.permissions.effective_permission_profile(), + permission_profile + ); assert_eq!( &config.legacy_sandbox_policy(), &SandboxPolicy::ExternalSandbox { @@ -1568,6 +1597,7 @@ async fn permission_profile_override_preserves_configured_network_policy_without entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( @@ -1605,7 +1635,10 @@ async fn permission_profile_override_preserves_configured_network_policy_without config.permissions.network.is_none(), "profile network.enabled should not start the managed network proxy" ); - assert_eq!(config.permissions.permission_profile(), permission_profile); + assert_eq!( + config.permissions.effective_permission_profile(), + permission_profile + ); Ok(()) } @@ -1613,7 +1646,9 @@ async fn permission_profile_override_preserves_configured_network_policy_without async fn workspace_root_glob_none_compiles_to_filesystem_pattern_entry() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; + let extra_root = TempDir::new()?; tokio::fs::write(cwd.path().join(".git"), "gitdir: nowhere").await?; + tokio::fs::write(extra_root.path().join(".git"), "gitdir: nowhere").await?; let config = Config::load_from_base_config_with_overrides( ConfigToml { @@ -1622,6 +1657,7 @@ async fn workspace_root_glob_none_compiles_to_filesystem_pattern_entry() -> std: entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: Some(2), entries: BTreeMap::from([( @@ -1640,6 +1676,7 @@ async fn workspace_root_glob_none_compiles_to_filesystem_pattern_entry() -> std: }, ConfigOverrides { cwd: Some(cwd.path().to_path_buf()), + additional_writable_roots: vec![extra_root.path().to_path_buf()], ..Default::default() }, codex_home.abs(), @@ -1653,21 +1690,23 @@ async fn workspace_root_glob_none_compiles_to_filesystem_pattern_entry() -> std: .glob_scan_max_depth, Some(2) ); - let expected_pattern = AbsolutePathBuf::resolve_path_against_base("**/*.env", cwd.path()) - .to_string_lossy() - .into_owned(); - assert!( - config - .permissions - .file_system_sandbox_policy() - .entries - .contains(&FileSystemSandboxEntry { - path: FileSystemPath::GlobPattern { - pattern: expected_pattern, - }, - access: FileSystemAccessMode::None, - }) - ); + for root in [cwd.path(), extra_root.path()] { + let expected_pattern = AbsolutePathBuf::resolve_path_against_base("**/*.env", root) + .to_string_lossy() + .into_owned(); + assert!( + config + .permissions + .file_system_sandbox_policy() + .entries + .contains(&FileSystemSandboxEntry { + path: FileSystemPath::GlobPattern { + pattern: expected_pattern, + }, + access: FileSystemAccessMode::None, + }) + ); + } assert!( !config .permissions @@ -1697,6 +1736,7 @@ async fn permissions_profiles_require_default_permissions() -> std::io::Result<( entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( @@ -1767,8 +1807,7 @@ async fn default_permissions_can_select_builtin_profile_without_permissions_tabl } #[tokio::test] -async fn default_permissions_read_only_applies_additional_writable_roots_as_modifications() --> std::io::Result<()> { +async fn default_permissions_read_only_keeps_add_dir_read_only() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; let extra_root = TempDir::new()?; @@ -1790,20 +1829,88 @@ async fn default_permissions_read_only_applies_additional_writable_roots_as_modi let policy = config.permissions.file_system_sandbox_policy(); assert!( - policy.can_write_path_with_cwd(extra_root.as_path(), cwd.path()), - "expected additional writable root to modify :read-only, policy: {policy:?}" + !policy.can_write_path_with_cwd(extra_root.as_path(), cwd.path()), + "expected :read-only to stay read-only for runtime workspace roots, policy: {policy:?}" ); assert_eq!( config.permissions.active_permission_profile(), - Some( - ActivePermissionProfile::new(BUILT_IN_PERMISSION_PROFILE_READ_ONLY).with_modifications( - vec![ - ActivePermissionProfileModification::AdditionalWritableRoot { - path: extra_root, + Some(ActivePermissionProfile::new( + BUILT_IN_PERMISSION_PROFILE_READ_ONLY, + )) + ); + Ok(()) +} + +#[tokio::test] +async fn workspace_profile_applies_rules_to_runtime_and_profile_workspace_roots() +-> std::io::Result<()> { + let temp_dir = TempDir::new()?; + let codex_home = temp_dir.path().join("codex-home"); + let cwd = temp_dir.path().join("frontend"); + let runtime_root = temp_dir.path().join("backend"); + let profile_root = temp_dir.path().join("shared"); + for root in [&cwd, &runtime_root, &profile_root] { + std::fs::create_dir_all(root.join(".git"))?; + std::fs::create_dir_all(root.join(".codex"))?; + } + + let config = Config::load_from_base_config_with_overrides( + ConfigToml { + default_permissions: Some("dev".to_string()), + permissions: Some(PermissionsToml { + entries: BTreeMap::from([( + "dev".to_string(), + PermissionProfileToml { + workspace_roots: Some(WorkspaceRootsToml { + entries: BTreeMap::from([( + profile_root.to_string_lossy().into_owned(), + true, + )]), + }), + filesystem: Some(FilesystemPermissionsToml { + glob_scan_max_depth: None, + entries: BTreeMap::from([( + ":workspace_roots".to_string(), + FilesystemPermissionToml::Scoped(BTreeMap::from([ + (".".to_string(), FileSystemAccessMode::Write), + (".git".to_string(), FileSystemAccessMode::Read), + (".codex".to_string(), FileSystemAccessMode::Read), + ])), + )]), + }), + network: None, }, - ] - ) - ) + )]), + }), + ..Default::default() + }, + ConfigOverrides { + cwd: Some(cwd.clone()), + additional_writable_roots: vec![runtime_root.clone()], + ..Default::default() + }, + codex_home.abs(), + ) + .await?; + + let policy = config.permissions.file_system_sandbox_policy(); + for root in [cwd.abs(), runtime_root.abs(), profile_root.abs()] { + assert!( + policy.can_write_path_with_cwd(root.as_path(), cwd.as_path()), + "expected workspace root to be writable, policy: {policy:?}" + ); + assert!( + !policy.can_write_path_with_cwd(&root.join(".git"), cwd.as_path()), + "expected .git carveout under {root:?}, policy: {policy:?}" + ); + assert!( + !policy.can_write_path_with_cwd(&root.join(".codex"), cwd.as_path()), + "expected .codex carveout under {root:?}, policy: {policy:?}" + ); + } + assert_eq!( + config.permissions.active_permission_profile(), + Some(ActivePermissionProfile::new("dev")) ); Ok(()) } @@ -2071,7 +2178,7 @@ async fn default_permissions_can_select_builtin_full_access_profile() -> std::io .await?; assert_eq!( - config.permissions.permission_profile(), + config.permissions.effective_permission_profile(), PermissionProfile::Disabled ); assert_eq!( @@ -2188,6 +2295,7 @@ async fn permissions_profiles_allow_direct_write_roots_outside_workspace_root() entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( @@ -2244,6 +2352,7 @@ async fn permissions_profiles_reject_nested_entries_for_non_workspace_roots() -> entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( @@ -2304,6 +2413,7 @@ async fn load_workspace_permission_profile( #[tokio::test] async fn permissions_profiles_allow_unknown_special_paths() -> std::io::Result<()> { let config = load_workspace_permission_profile(PermissionProfileToml { + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( @@ -2347,6 +2457,7 @@ async fn permissions_profiles_allow_unknown_special_paths() -> std::io::Result<( async fn permissions_profiles_allow_unknown_special_paths_with_nested_entries() -> std::io::Result<()> { let config = load_workspace_permission_profile(PermissionProfileToml { + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( @@ -2383,6 +2494,7 @@ async fn permissions_profiles_allow_unknown_special_paths_with_nested_entries() #[tokio::test] async fn permissions_profiles_allow_missing_filesystem_with_warning() -> std::io::Result<()> { let config = load_workspace_permission_profile(PermissionProfileToml { + workspace_roots: None, filesystem: None, network: None, }) @@ -2411,6 +2523,7 @@ async fn permissions_profiles_allow_missing_filesystem_with_warning() -> std::io #[tokio::test] async fn permissions_profiles_allow_empty_filesystem_with_warning() -> std::io::Result<()> { let config = load_workspace_permission_profile(PermissionProfileToml { + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::new(), @@ -2446,6 +2559,7 @@ async fn permissions_profiles_reject_workspace_root_parent_traversal() -> std::i entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( @@ -2492,6 +2606,7 @@ async fn permissions_profiles_allow_network_enablement() -> std::io::Result<()> entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( @@ -3087,13 +3202,15 @@ exclude_slash_tmp = true ); continue; } + assert_eq!( + config.permissions.workspace_roots(), + &[cwd.abs(), extra_root.clone()] + ); assert!( file_system_policy .entries .contains(&FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::project_roots(/*subpath*/ None), - }, + path: FileSystemPath::Path { path: cwd.abs() }, access: FileSystemAccessMode::Write, }) ); @@ -3112,15 +3229,16 @@ exclude_slash_tmp = true file_system_policy .entries .contains(&FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::project_roots(Some( - subpath.into() - )), + path: FileSystemPath::Path { + path: AbsolutePathBuf::resolve_path_against_base( + subpath, + cwd.path() + ), }, access: FileSystemAccessMode::Read, }), - "case `{name}` should preserve `{subpath}` as a symbolic project-root \ - metadata carveout" + "case `{name}` should materialize `{subpath}` for the runtime workspace \ + root" ); } } @@ -7423,10 +7541,13 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { model_provider: fixture.openai_provider.clone(), permissions: Permissions { approval_policy: Constrained::allow_any(AskForApproval::Never), - permission_profile: Constrained::allow_any(PermissionProfile::read_only()), + constrained_permissions_profile: Constrained::allow_any( + PermissionProfile::read_only() + ), active_permission_profile: Some(ActivePermissionProfile::new( BUILT_IN_PERMISSION_PROFILE_READ_ONLY, )), + workspace_roots: vec![fixture.cwd()], network: None, allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), @@ -7438,6 +7559,8 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { user_instructions: None, notify: None, cwd: fixture.cwd(), + workspace_roots: vec![fixture.cwd()], + workspace_roots_explicit: false, cli_auth_credentials_store_mode: Default::default(), mcp_servers: Constrained::allow_any(HashMap::new()), mcp_oauth_credentials_store_mode: resolve_mcp_oauth_credentials_store_mode( @@ -7870,10 +7993,11 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { model_provider: fixture.openai_custom_provider.clone(), permissions: Permissions { approval_policy: Constrained::allow_any(AskForApproval::UnlessTrusted), - permission_profile: Constrained::allow_any(PermissionProfile::read_only()), + constrained_permissions_profile: Constrained::allow_any(PermissionProfile::read_only()), active_permission_profile: Some(ActivePermissionProfile::new( BUILT_IN_PERMISSION_PROFILE_READ_ONLY, )), + workspace_roots: vec![fixture.cwd()], network: None, allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), @@ -7885,6 +8009,8 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { user_instructions: None, notify: None, cwd: fixture.cwd(), + workspace_roots: vec![fixture.cwd()], + workspace_roots_explicit: false, cli_auth_credentials_store_mode: Default::default(), mcp_servers: Constrained::allow_any(HashMap::new()), mcp_oauth_credentials_store_mode: resolve_mcp_oauth_credentials_store_mode( @@ -8031,10 +8157,11 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { model_provider: fixture.openai_provider.clone(), permissions: Permissions { approval_policy: Constrained::allow_any(AskForApproval::OnFailure), - permission_profile: Constrained::allow_any(PermissionProfile::read_only()), + constrained_permissions_profile: Constrained::allow_any(PermissionProfile::read_only()), active_permission_profile: Some(ActivePermissionProfile::new( BUILT_IN_PERMISSION_PROFILE_READ_ONLY, )), + workspace_roots: vec![fixture.cwd()], network: None, allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), @@ -8046,6 +8173,8 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { user_instructions: None, notify: None, cwd: fixture.cwd(), + workspace_roots: vec![fixture.cwd()], + workspace_roots_explicit: false, cli_auth_credentials_store_mode: Default::default(), mcp_servers: Constrained::allow_any(HashMap::new()), mcp_oauth_credentials_store_mode: resolve_mcp_oauth_credentials_store_mode( @@ -8177,10 +8306,11 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { model_provider: fixture.openai_provider.clone(), permissions: Permissions { approval_policy: Constrained::allow_any(AskForApproval::OnFailure), - permission_profile: Constrained::allow_any(PermissionProfile::read_only()), + constrained_permissions_profile: Constrained::allow_any(PermissionProfile::read_only()), active_permission_profile: Some(ActivePermissionProfile::new( BUILT_IN_PERMISSION_PROFILE_READ_ONLY, )), + workspace_roots: vec![fixture.cwd()], network: None, allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), @@ -8192,6 +8322,8 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { user_instructions: None, notify: None, cwd: fixture.cwd(), + workspace_roots: vec![fixture.cwd()], + workspace_roots_explicit: false, cli_auth_credentials_store_mode: Default::default(), mcp_servers: Constrained::allow_any(HashMap::new()), mcp_oauth_credentials_store_mode: resolve_mcp_oauth_credentials_store_mode( @@ -9127,7 +9259,7 @@ async fn permission_profile_override_falls_back_when_disallowed_by_requirements( let expected_sandbox_policy = SandboxPolicy::new_read_only_policy(); assert_eq!(config.legacy_sandbox_policy(), expected_sandbox_policy); assert_eq!( - config.permissions.permission_profile(), + config.permissions.effective_permission_profile(), PermissionProfile::read_only() ); Ok(()) @@ -9155,7 +9287,7 @@ async fn active_profile_is_cleared_when_requirements_force_fallback() -> std::io .await?; assert_eq!( - config.permissions.permission_profile(), + config.permissions.effective_permission_profile(), PermissionProfile::read_only() ); assert_eq!(config.permissions.active_permission_profile(), None); @@ -9275,7 +9407,7 @@ async fn requirements_web_search_mode_overrides_danger_full_access_default() -> assert_eq!( resolve_web_search_mode_for_turn( &config.web_search_mode, - &config.permissions.permission_profile(), + &config.permissions.effective_permission_profile(), ), WebSearchMode::Cached, ); diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index c6b35e7a018e..0f22768876d3 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -89,7 +89,6 @@ use codex_protocol::config_types::WebSearchConfig; use codex_protocol::config_types::WebSearchMode; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::ActivePermissionProfile; -use codex_protocol::models::ActivePermissionProfileModification; use codex_protocol::models::PermissionProfile; use codex_protocol::models::SandboxEnforcement; use codex_protocol::openai_models::ModelsResponse; @@ -117,6 +116,7 @@ use crate::config::permissions::BUILT_IN_WORKSPACE_PROFILE; use crate::config::permissions::apply_network_proxy_feature_config; use crate::config::permissions::builtin_permission_profile; use crate::config::permissions::compile_permission_profile_selection; +use crate::config::permissions::compile_permission_profile_workspace_roots; use crate::config::permissions::default_builtin_permission_profile_name; use crate::config::permissions::get_readable_roots_required_for_codex_runtime; use crate::config::permissions::network_proxy_config_for_profile_selection; @@ -247,12 +247,16 @@ pub(crate) async fn test_config() -> Config { pub struct Permissions { /// Approval policy for executing commands. pub approval_policy: Constrained, - /// Canonical effective runtime permissions after config requirements and - /// runtime readable-root additions have been applied. - pub permission_profile: Constrained, + /// Canonical constrained permissions profile before runtime workspace-root + /// materialization has been applied. + constrained_permissions_profile: Constrained, /// Named or implicit built-in profile selected by config, rather than an /// ad-hoc override. - pub active_permission_profile: Option, + active_permission_profile: Option, + /// Thread-scoped runtime workspace roots. Symbolic `:workspace_roots` + /// entries in `constrained_permissions_profile` are materialized against + /// these roots. + workspace_roots: Vec, /// Effective network configuration applied to all spawned processes. pub network: Option, /// Whether the model may request a login shell for shell-based tools. @@ -274,10 +278,67 @@ pub struct Permissions { } impl Permissions { + /// Build permissions from the constrained values required for a minimal + /// in-process configuration. + pub fn from_approval_and_profile( + approval_policy: Constrained, + permission_profile: Constrained, + ) -> Self { + Self { + approval_policy, + constrained_permissions_profile: permission_profile, + active_permission_profile: None, + workspace_roots: Vec::new(), + network: None, + allow_login_shell: true, + shell_environment_policy: ShellEnvironmentPolicy::default(), + windows_sandbox_mode: None, + windows_sandbox_private_desktop: true, + } + } + + /// Borrow the constrained canonical profile. This preserves the raw + /// symbolic `:workspace_roots` form for session/thread state. + pub fn permission_profile(&self) -> &Constrained { + &self.constrained_permissions_profile + } + + /// Set the full constrained profile value and preserve the active profile + /// sidecar when the caller has already validated both together. + pub fn set_constrained_permission_profile_with_active_profile( + &mut self, + permission_profile: Constrained, + active_permission_profile: Option, + ) { + self.constrained_permissions_profile = permission_profile; + self.active_permission_profile = active_permission_profile; + } + + pub fn set_workspace_roots(&mut self, workspace_roots: Vec) { + self.workspace_roots = workspace_roots; + } + + pub fn workspace_roots(&self) -> &[AbsolutePathBuf] { + &self.workspace_roots + } + + /// Workspace roots that came from user-visible configuration or runtime + /// selection. Internal Codex-only writable roots are intentionally excluded. + pub fn user_visible_workspace_roots(&self) -> &[AbsolutePathBuf] { + &self.workspace_roots + } + + fn materialized_permission_profile(&self) -> PermissionProfile { + self.constrained_permissions_profile + .get() + .clone() + .materialize_project_roots_with_workspace_roots(&self.workspace_roots) + } + /// Effective runtime permissions after config requirements and runtime - /// readable-root additions have been applied. - pub fn permission_profile(&self) -> PermissionProfile { - self.permission_profile.get().clone() + /// workspace-root materialization have been applied. + pub fn effective_permission_profile(&self) -> PermissionProfile { + self.materialized_permission_profile() } /// Named profile selected by config, if the current profile has one. @@ -287,20 +348,23 @@ impl Permissions { /// Effective filesystem sandbox policy derived from the canonical profile. pub fn file_system_sandbox_policy(&self) -> FileSystemSandboxPolicy { - self.permission_profile.get().file_system_sandbox_policy() + self.materialized_permission_profile() + .file_system_sandbox_policy() } /// Effective network sandbox policy derived from the canonical profile. pub fn network_sandbox_policy(&self) -> NetworkSandboxPolicy { - self.permission_profile.get().network_sandbox_policy() + self.constrained_permissions_profile + .get() + .network_sandbox_policy() } /// Legacy compatibility projection derived from the canonical profile. pub fn legacy_sandbox_policy(&self, cwd: &Path) -> SandboxPolicy { - let permission_profile = self.permission_profile.get(); + let permission_profile = self.materialized_permission_profile(); let file_system_sandbox_policy = permission_profile.file_system_sandbox_policy(); compatibility_sandbox_policy_for_permission_profile( - permission_profile, + &permission_profile, &file_system_sandbox_policy, permission_profile.network_sandbox_policy(), cwd, @@ -322,11 +386,12 @@ impl Permissions { &file_system_sandbox_policy, network_sandbox_policy, ); - self.permission_profile.can_set(&permission_profile) + self.constrained_permissions_profile + .can_set(&permission_profile) } - /// Replace permissions from a legacy sandbox policy and keep every - /// permission projection in sync. + /// Set permissions from a legacy sandbox policy and keep every permission + /// projection in sync. pub fn set_legacy_sandbox_policy( &mut self, sandbox_policy: SandboxPolicy, @@ -341,13 +406,34 @@ impl Permissions { &file_system_sandbox_policy, network_sandbox_policy, ); + self.workspace_roots = match &sandbox_policy { + SandboxPolicy::WorkspaceWrite { writable_roots, .. } => { + let mut workspace_roots = vec![ + AbsolutePathBuf::from_absolute_path(cwd) + .unwrap_or_else(|_| AbsolutePathBuf::resolve_path_against_base(cwd, "/")), + ]; + for root in writable_roots { + if !workspace_roots.iter().any(|existing| existing == root) { + workspace_roots.push(root.clone()); + } + } + workspace_roots + } + SandboxPolicy::DangerFullAccess + | SandboxPolicy::ExternalSandbox { .. } + | SandboxPolicy::ReadOnly { .. } => vec![ + AbsolutePathBuf::from_absolute_path(cwd) + .unwrap_or_else(|_| AbsolutePathBuf::resolve_path_against_base(cwd, "/")), + ], + }; - self.permission_profile.set(permission_profile)?; + self.constrained_permissions_profile + .set(permission_profile)?; self.active_permission_profile = None; Ok(()) } - /// Replace permissions from the canonical profile. + /// Set permissions from the canonical profile. pub fn set_permission_profile( &mut self, permission_profile: PermissionProfile, @@ -358,16 +444,15 @@ impl Permissions { ) } - /// Replace permissions from the canonical profile and record the named - /// source profile, if one is known. + /// Set permissions from the canonical profile and record the named source + /// profile, if one is known. pub fn set_permission_profile_with_active_profile( &mut self, permission_profile: PermissionProfile, active_permission_profile: Option, ) -> ConstraintResult<()> { - self.permission_profile.can_set(&permission_profile)?; - - self.permission_profile.set(permission_profile)?; + self.constrained_permissions_profile + .set(permission_profile)?; self.active_permission_profile = active_permission_profile; Ok(()) } @@ -577,6 +662,15 @@ pub struct Config { /// layer are resolved against this path. pub cwd: AbsolutePathBuf, + /// Absolute runtime workspace roots for the session. Symbolic + /// `:workspace_roots` permission entries are materialized against these + /// roots while profile-defined workspace roots remain encoded directly in + /// the permission profile. + pub workspace_roots: Vec, + /// Whether runtime workspace roots were supplied explicitly by the caller + /// or legacy config, rather than defaulting to `cwd`. + pub workspace_roots_explicit: bool, + /// Preferred store for CLI auth credentials. /// file (default): Use a file in the Codex home directory. /// keyring: Use an OS-specific keyring service. @@ -1076,8 +1170,14 @@ impl Config { &mut self, sandbox_policy: SandboxPolicy, ) -> ConstraintResult<()> { + self.workspace_roots_explicit = matches!( + &sandbox_policy, + SandboxPolicy::WorkspaceWrite { writable_roots, .. } if !writable_roots.is_empty() + ); self.permissions - .set_legacy_sandbox_policy(sandbox_policy, self.cwd.as_path()) + .set_legacy_sandbox_policy(sandbox_policy, self.cwd.as_path())?; + self.workspace_roots = self.permissions.workspace_roots().to_vec(); + Ok(()) } pub fn to_models_manager_config(&self) -> ModelsManagerConfig { @@ -1926,6 +2026,14 @@ pub struct ConfigOverrides { pub bypass_hook_trust: Option, /// Additional directories that should be treated as writable roots for this session. pub additional_writable_roots: Vec, + /// Explicit runtime workspace roots for this session. When set, this is + /// the full runtime root list rather than an additive override. + pub workspace_roots: Option>, +} + +fn dedupe_absolute_paths(paths: &mut Vec) { + let mut seen = HashSet::new(); + paths.retain(|path| seen.insert(path.clone())); } /// Resolves the OSS provider from CLI override, profile config, or global config. @@ -2239,6 +2347,7 @@ impl Config { ephemeral, bypass_hook_trust, additional_writable_roots, + workspace_roots: workspace_roots_override, } = overrides; let bypass_hook_trust = bypass_hook_trust.unwrap_or_default(); @@ -2329,11 +2438,10 @@ impl Config { } } }))?; - let mut additional_writable_roots: Vec = additional_writable_roots + let requested_additional_writable_roots: Vec = additional_writable_roots .into_iter() .map(|path| AbsolutePathBuf::resolve_path_against_base(path, resolved_cwd.as_path())) .collect(); - let requested_additional_writable_roots = additional_writable_roots.clone(); let repo_root = resolve_root_git_project_for_trust(fs, &resolved_cwd).await; let active_project = cfg .get_active_project( @@ -2375,12 +2483,7 @@ impl Config { }; let memories_root = memory_root(&codex_home); std::fs::create_dir_all(&memories_root)?; - if !additional_writable_roots - .iter() - .any(|existing| existing == &memories_root) - { - additional_writable_roots.push(memories_root); - } + let internal_writable_roots = vec![memories_root]; let profiles_are_active = default_permissions_override.is_some() || matches!( @@ -2390,6 +2493,40 @@ impl Config { || permission_config_syntax.is_none(); let using_implicit_builtin_profile = permission_config_syntax.is_none() && default_permissions.is_none(); + let should_seed_legacy_workspace_roots = default_permissions.is_none() + && matches!( + permission_config_syntax, + None | Some(PermissionConfigSyntax::Legacy) + ); + let legacy_workspace_roots_explicit = should_seed_legacy_workspace_roots + && cfg + .sandbox_workspace_write + .as_ref() + .is_some_and(|sandbox_workspace_write| { + !sandbox_workspace_write.writable_roots.is_empty() + }); + let workspace_roots_explicit = workspace_roots_override.is_some() + || !requested_additional_writable_roots.is_empty() + || legacy_workspace_roots_explicit; + let mut workspace_roots = match workspace_roots_override { + Some(workspace_roots) => workspace_roots + .into_iter() + .map(|path| { + AbsolutePathBuf::resolve_path_against_base(path, resolved_cwd.as_path()) + }) + .collect(), + None => { + let mut workspace_roots = vec![resolved_cwd.clone()]; + workspace_roots.extend(requested_additional_writable_roots.clone()); + if should_seed_legacy_workspace_roots + && let Some(sandbox_workspace_write) = cfg.sandbox_workspace_write.as_ref() + { + workspace_roots.extend(sandbox_workspace_write.writable_roots.clone()); + } + workspace_roots + } + }; + dedupe_absolute_paths(&mut workspace_roots); let ( mut configured_network_proxy_config, permission_profile, @@ -2418,18 +2555,24 @@ impl Config { } else { NetworkProxyConfig::default() }; + let materialized_file_system_sandbox_policy = file_system_sandbox_policy + .clone() + .materialize_project_roots_with_workspace_roots(&workspace_roots); + let materialized_permission_profile = + PermissionProfile::from_runtime_permissions_with_enforcement( + permission_profile.enforcement(), + &materialized_file_system_sandbox_policy, + network_sandbox_policy, + ); let sandbox_policy = compatibility_sandbox_policy_for_permission_profile( - &permission_profile, - &file_system_sandbox_policy, + &materialized_permission_profile, + &materialized_file_system_sandbox_policy, network_sandbox_policy, resolved_cwd.as_path(), ); if matches!(sandbox_policy, SandboxPolicy::WorkspaceWrite { .. }) { file_system_sandbox_policy = file_system_sandbox_policy - .with_additional_writable_roots( - resolved_cwd.as_path(), - &additional_writable_roots, - ); + .with_additional_legacy_workspace_writable_roots(&internal_writable_roots); permission_profile = PermissionProfile::from_runtime_permissions_with_enforcement( permission_profile.enforcement(), &file_system_sandbox_policy, @@ -2463,6 +2606,22 @@ impl Config { resolved_cwd.as_path(), &mut startup_warnings, )?; + let mut configured_workspace_roots = compile_permission_profile_workspace_roots( + cfg.permissions.as_ref(), + default_permissions, + resolved_cwd.as_path(), + )?; + if using_implicit_builtin_profile + && default_permissions == BUILT_IN_WORKSPACE_PROFILE + && let Some(sandbox_workspace_write) = cfg.sandbox_workspace_write.as_ref() + { + configured_workspace_roots.extend(sandbox_workspace_write.writable_roots.clone()); + } + dedupe_absolute_paths(&mut configured_workspace_roots); + workspace_roots.extend(configured_workspace_roots.iter().cloned()); + dedupe_absolute_paths(&mut workspace_roots); + file_system_sandbox_policy = file_system_sandbox_policy + .with_materialized_project_roots_for_workspace_roots(&configured_workspace_roots); let mut permission_profile = if let Some(permission_profile) = builtin_permission_profile(default_permissions, builtin_workspace_write_settings) { @@ -2473,36 +2632,26 @@ impl Config { network_sandbox_policy, ) }; + let materialized_file_system_sandbox_policy = file_system_sandbox_policy + .clone() + .materialize_project_roots_with_workspace_roots(&workspace_roots); + let materialized_permission_profile = + PermissionProfile::from_runtime_permissions_with_enforcement( + permission_profile.enforcement(), + &materialized_file_system_sandbox_policy, + network_sandbox_policy, + ); let sandbox_policy = compatibility_sandbox_policy_for_permission_profile( - &permission_profile, - &file_system_sandbox_policy, + &materialized_permission_profile, + &materialized_file_system_sandbox_policy, network_sandbox_policy, resolved_cwd.as_path(), ); if matches!(sandbox_policy, SandboxPolicy::WorkspaceWrite { .. }) { - file_system_sandbox_policy = if using_implicit_builtin_profile { - file_system_sandbox_policy - .with_additional_legacy_workspace_writable_roots( - &additional_writable_roots, - ) - } else { - file_system_sandbox_policy.with_additional_writable_roots( - resolved_cwd.as_path(), - &additional_writable_roots, - ) - }; - permission_profile = PermissionProfile::from_runtime_permissions( - &file_system_sandbox_policy, - network_sandbox_policy, - ); - } else if matches!(permission_profile, PermissionProfile::Managed { .. }) - && !requested_additional_writable_roots.is_empty() - { - file_system_sandbox_policy = file_system_sandbox_policy.with_additional_writable_roots( - resolved_cwd.as_path(), - &requested_additional_writable_roots, - ); - permission_profile = PermissionProfile::from_runtime_permissions( + file_system_sandbox_policy = file_system_sandbox_policy + .with_additional_legacy_workspace_writable_roots(&internal_writable_roots); + permission_profile = PermissionProfile::from_runtime_permissions_with_enforcement( + permission_profile.enforcement(), &file_system_sandbox_policy, network_sandbox_policy, ); @@ -2518,22 +2667,7 @@ impl Config { // when doing so would lose roots, network, or tmp settings. None } else { - let active_permission_profile = if !requested_additional_writable_roots.is_empty() - && matches!(permission_profile, PermissionProfile::Managed { .. }) - { - ActivePermissionProfile::new(default_permissions).with_modifications( - requested_additional_writable_roots - .iter() - .cloned() - .map(|path| { - ActivePermissionProfileModification::AdditionalWritableRoot { path } - }) - .collect(), - ) - } else { - ActivePermissionProfile::new(default_permissions) - }; - Some(active_permission_profile) + Some(ActivePermissionProfile::new(default_permissions)) }; ( configured_network_proxy_config, @@ -2572,25 +2706,21 @@ impl Config { } let (mut file_system_sandbox_policy, network_sandbox_policy) = permission_profile.to_runtime_permissions(); - // `additional_writable_roots` is a legacy workspace-write knob. It - // only applies when the derived managed profile has workspace-style - // write access to the project roots; read-only, disabled, external, - // and future non-workspace profiles must not silently grow extra - // write access. + let materialized_file_system_sandbox_policy = permission_profile + .clone() + .materialize_project_roots_with_workspace_roots(&workspace_roots) + .file_system_sandbox_policy(); if matches!(permission_profile.enforcement(), SandboxEnforcement::Managed) - && file_system_sandbox_policy.can_write_path_with_cwd( + && materialized_file_system_sandbox_policy.can_write_path_with_cwd( resolved_cwd.as_path(), resolved_cwd.as_path(), ) - && !file_system_sandbox_policy.has_full_disk_write_access() + && !materialized_file_system_sandbox_policy.has_full_disk_write_access() { - // Keep legacy behavior for extra writable roots while storing - // the result as the canonical permission profile. Explicit - // extra roots are concrete paths, so their metadata carveouts - // are also concrete rather than symbolic `:workspace_roots` - // entries. + // Keep Codex runtime write access while storing the runtime + // workspace roots separately on the thread. file_system_sandbox_policy = file_system_sandbox_policy - .with_additional_legacy_workspace_writable_roots(&additional_writable_roots); + .with_additional_legacy_workspace_writable_roots(&internal_writable_roots); permission_profile = PermissionProfile::from_runtime_permissions_with_enforcement( permission_profile.enforcement(), &file_system_sandbox_policy, @@ -3105,11 +3235,14 @@ impl Config { model_provider_id, model_provider, cwd: resolved_cwd, + workspace_roots: workspace_roots.clone(), + workspace_roots_explicit, startup_warnings, permissions: Permissions { approval_policy: constrained_approval_policy.value, - permission_profile: constrained_permission_profile.value, + constrained_permissions_profile: constrained_permission_profile.value, active_permission_profile, + workspace_roots, network, allow_login_shell, shell_environment_policy, @@ -3393,7 +3526,7 @@ impl Config { pub fn managed_network_requirements_enabled(&self) -> bool { !matches!( - self.permissions.permission_profile.get(), + self.permissions.permission_profile().get(), PermissionProfile::Disabled ) && self .config_layer_stack diff --git a/codex-rs/core/src/config/permissions.rs b/codex-rs/core/src/config/permissions.rs index b93b8745d746..9f8fcd9ee3ae 100644 --- a/codex-rs/core/src/config/permissions.rs +++ b/codex-rs/core/src/config/permissions.rs @@ -13,6 +13,7 @@ use codex_config::permissions_toml::NetworkUnixSocketPermissionToml; use codex_config::permissions_toml::NetworkUnixSocketPermissionsToml; use codex_config::permissions_toml::PermissionProfileToml; use codex_config::permissions_toml::PermissionsToml; +use codex_config::permissions_toml::WorkspaceRootsToml; use codex_config::types::SandboxWorkspaceWrite; use codex_features::NetworkProxyConfigToml; use codex_features::NetworkProxyDomainPermissionToml; @@ -33,6 +34,7 @@ use codex_protocol::permissions::FileSystemSandboxEntry; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_protocol::permissions::project_roots_glob_pattern; use codex_utils_absolute_path::AbsolutePathBuf; use super::ProjectConfig; @@ -72,12 +74,12 @@ pub(crate) fn builtin_permission_profile( BUILT_IN_READ_ONLY_PROFILE => Some(PermissionProfile::read_only()), BUILT_IN_WORKSPACE_PROFILE => Some(match workspace_write { Some(SandboxWorkspaceWrite { - writable_roots, + writable_roots: _, network_access, exclude_tmpdir_env_var, exclude_slash_tmp, }) => PermissionProfile::workspace_write_with( - writable_roots, + &[], if *network_access { NetworkSandboxPolicy::Enabled } else { @@ -303,6 +305,41 @@ pub(crate) fn compile_permission_profile_selection( compile_permission_profile(permissions, profile_name, policy_cwd, startup_warnings) } +pub(crate) fn compile_permission_profile_workspace_roots( + permissions: Option<&PermissionsToml>, + profile_name: &str, + policy_cwd: &Path, +) -> io::Result> { + if is_builtin_permission_profile_name(profile_name) { + return Ok(Vec::new()); + } + reject_unknown_builtin_permission_profile(profile_name)?; + + let permissions = permissions.ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "default_permissions requires a `[permissions]` table", + ) + })?; + let profile = resolve_permission_profile(permissions, profile_name)?; + Ok(compile_workspace_roots( + profile.workspace_roots.as_ref(), + policy_cwd, + )) +} + +fn compile_workspace_roots( + workspace_roots: Option<&WorkspaceRootsToml>, + policy_cwd: &Path, +) -> Vec { + workspace_roots.map_or_else(Vec::new, |workspace_roots| { + workspace_roots + .enabled_roots() + .map(|path| AbsolutePathBuf::resolve_path_against_base(path, policy_cwd)) + .collect() + }) +} + fn reject_unknown_builtin_permission_profile(profile_name: &str) -> io::Result<()> { if profile_name.starts_with(':') { return Err(io::Error::new( @@ -478,7 +515,7 @@ fn compile_scoped_filesystem_pattern( path: &str, subpath: &str, access: FileSystemAccessMode, - policy_cwd: &Path, + _policy_cwd: &Path, ) -> io::Result { // Pattern entries currently mean deny-read only. Supporting broader access // modes here would imply glob-based read/write allow semantics that the @@ -493,15 +530,10 @@ fn compile_scoped_filesystem_pattern( match parse_special_path(path) { Some(FileSystemSpecialPath::ProjectRoots { .. }) => { - // `:workspace_roots` is represented as a special path, but current - // filesystem-policy resolution defines it relative to the session - // cwd. Use the same policy cwd here so glob entries and exact - // scoped entries resolve consistently. - Ok( - AbsolutePathBuf::resolve_path_against_base(&subpath, policy_cwd) - .to_string_lossy() - .to_string(), - ) + // Keep `:workspace_roots` glob patterns symbolic until the active + // workspace roots are known, then materialize them for cwd and any + // runtime/profile-added workspace roots together. + Ok(project_roots_glob_pattern(&subpath)) } Some(_) => Err(io::Error::new( io::ErrorKind::InvalidInput, diff --git a/codex-rs/core/src/config/permissions_tests.rs b/codex-rs/core/src/config/permissions_tests.rs index 86a3c604dda9..51cf13912e7f 100644 --- a/codex-rs/core/src/config/permissions_tests.rs +++ b/codex-rs/core/src/config/permissions_tests.rs @@ -11,6 +11,7 @@ use codex_config::permissions_toml::NetworkUnixSocketPermissionToml; use codex_config::permissions_toml::NetworkUnixSocketPermissionsToml; use codex_config::permissions_toml::PermissionProfileToml; use codex_config::permissions_toml::PermissionsToml; +use codex_config::permissions_toml::WorkspaceRootsToml; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry; @@ -66,6 +67,7 @@ async fn restricted_read_implicitly_allows_helper_executables() -> std::io::Resu entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::new(), @@ -275,6 +277,39 @@ fn profile_network_proxy_config_keeps_proxy_disabled_for_proxy_policy() { ); } +#[test] +fn compile_permission_profile_workspace_roots_resolves_enabled_entries() -> std::io::Result<()> { + let cwd = TempDir::new()?; + let workspace_roots = compile_permission_profile_workspace_roots( + Some(&PermissionsToml { + entries: BTreeMap::from([( + "workspace".to_string(), + PermissionProfileToml { + workspace_roots: Some(WorkspaceRootsToml { + entries: BTreeMap::from([ + ("backend".to_string(), true), + ("disabled".to_string(), false), + ]), + }), + filesystem: None, + network: None, + }, + )]), + }), + "workspace", + cwd.path(), + )?; + + assert_eq!( + workspace_roots, + vec![AbsolutePathBuf::resolve_path_against_base( + "backend", + cwd.path() + )] + ); + Ok(()) +} + #[test] fn read_write_glob_warnings_skip_supported_deny_read_globs_and_trailing_subpaths() { let filesystem = FilesystemPermissionsToml { @@ -359,6 +394,7 @@ fn read_write_trailing_glob_suffix_compiles_as_subpath() -> std::io::Result<()> entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( diff --git a/codex-rs/core/src/context/permissions_instructions.rs b/codex-rs/core/src/context/permissions_instructions.rs index 0ccd6c33a731..cd5a8ac33868 100644 --- a/codex-rs/core/src/context/permissions_instructions.rs +++ b/codex-rs/core/src/context/permissions_instructions.rs @@ -251,10 +251,11 @@ fn sandbox_text(mode: SandboxMode, network_access: NetworkAccess) -> String { } fn writable_roots_text(writable_roots: Option>) -> Option { - let roots = writable_roots?; + let mut roots = writable_roots?; if roots.is_empty() { return None; } + roots.sort_by(|left, right| left.root.as_path().cmp(right.root.as_path())); let roots_list: Vec = roots .iter() diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 253ead5b41f3..25b84420dccf 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -9,7 +9,6 @@ use codex_analytics::GuardianReviewAnalyticsResult; use codex_analytics::GuardianReviewSessionKind; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; -use codex_protocol::models::PermissionProfile; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::protocol::AskForApproval; @@ -894,9 +893,6 @@ pub(crate) fn build_guardian_review_session_config( guardian_config.developer_instructions = None; guardian_config.permissions.approval_policy = Constrained::allow_only(AskForApproval::Never); let sandbox_policy = SandboxPolicy::new_read_only_policy(); - guardian_config.permissions.permission_profile = Constrained::allow_only( - PermissionProfile::from_legacy_sandbox_policy(&sandbox_policy), - ); guardian_config .permissions .set_legacy_sandbox_policy(sandbox_policy, guardian_config.cwd.as_path()) @@ -922,7 +918,7 @@ pub(crate) fn build_guardian_review_session_config( guardian_config.permissions.network = Some(NetworkProxySpec::from_config_and_constraints( live_network_config, network_constraints, - guardian_config.permissions.permission_profile.get(), + guardian_config.permissions.permission_profile().get(), )?); } for feature in [ diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index 7601ef44c373..18400fa23055 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -2163,7 +2163,7 @@ async fn guardian_review_session_config_preserves_parent_network_proxy() { }), ..Default::default() }), - parent_config.permissions.permission_profile.get(), + parent_config.permissions.permission_profile().get(), ) .expect("network proxy spec"); parent_config.permissions.network = Some(network.clone()); @@ -2190,8 +2190,8 @@ async fn guardian_review_session_config_preserves_parent_network_proxy() { Constrained::allow_only(AskForApproval::Never) ); assert_eq!( - guardian_config.permissions.permission_profile, - Constrained::allow_only(PermissionProfile::from_legacy_sandbox_policy( + guardian_config.permissions.permission_profile(), + &Constrained::allow_only(PermissionProfile::from_legacy_sandbox_policy( &SandboxPolicy::new_read_only_policy(), )) ); @@ -2230,7 +2230,7 @@ async fn guardian_review_session_config_uses_live_network_proxy_state() { NetworkProxySpec::from_config_and_constraints( parent_network, /*requirements*/ None, - parent_config.permissions.permission_profile.get(), + parent_config.permissions.permission_profile().get(), ) .expect("parent network proxy spec"), ); diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 0a29e9788364..5db9474721bc 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -617,7 +617,7 @@ impl Codex { compact_prompt: config.compact_prompt.clone(), approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, - permission_profile: config.permissions.permission_profile.clone(), + permission_profile: session_permission_profile_from_config(&config)?, active_permission_profile: config.permissions.active_permission_profile(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), @@ -818,6 +818,20 @@ fn get_service_tier( .then_some(ServiceTier::Fast.request_value().to_string()) } +fn session_permission_profile_from_config( + config: &Config, +) -> CodexResult> { + let mut session_permission_profile = config.permissions.permission_profile().clone(); + session_permission_profile + .set(config.permissions.effective_permission_profile()) + .map_err(|err| { + CodexErr::Fatal(format!( + "failed to materialize workspace roots for session permissions: {err}" + )) + })?; + Ok(session_permission_profile) +} + fn is_enterprise_default_service_tier_plan(plan_type: AccountPlanType) -> bool { plan_type == AccountPlanType::Enterprise || plan_type.is_business_like() diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 5b1b8c83e496..c682c1021ebc 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -769,7 +769,7 @@ impl Session { let (network_proxy, session_network_proxy) = Self::start_managed_network_proxy( spec, current_exec_policy.as_ref(), - config.permissions.permission_profile.get(), + config.permissions.permission_profile().get(), network_policy_decider.as_ref().map(Arc::clone), blocked_request_observer.as_ref().map(Arc::clone), managed_network_requirements_configured, @@ -833,7 +833,7 @@ impl Session { // setup is straightforward enough and performs well. mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::new_uninitialized( &config.permissions.approval_policy, - &config.permissions.permission_profile, + config.permissions.permission_profile(), ))), mcp_startup_cancellation_token: Mutex::new(CancellationToken::new()), unified_exec_manager: UnifiedExecProcessManager::new( diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 38988329e98b..50632ce81a21 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -36,6 +36,7 @@ use codex_protocol::account::PlanType as AccountPlanType; use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::TrustLevel; use codex_protocol::exec_output::ExecToolCallOutput; +use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; use codex_protocol::models::FileSystemPermissions; use codex_protocol::models::FunctionCallOutputBody; use codex_protocol::models::FunctionCallOutputPayload; @@ -2128,9 +2129,12 @@ async fn session_configured_reports_permission_profile_for_external_sandbox() -> }; let expected_sandbox_policy = sandbox_policy.clone(); let mut builder = test_codex().with_config(move |config| { - config.permissions.permission_profile = codex_config::Constrained::allow_any( - PermissionProfile::from_legacy_sandbox_policy(&sandbox_policy), - ); + config + .permissions + .set_permission_profile(PermissionProfile::from_legacy_sandbox_policy( + &sandbox_policy, + )) + .expect("set permission profile"); config .set_legacy_sandbox_policy(sandbox_policy) .expect("set sandbox policy"); @@ -2149,6 +2153,33 @@ async fn session_configured_reports_permission_profile_for_external_sandbox() -> Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn session_permission_profile_materializes_runtime_workspace_roots() -> anyhow::Result<()> { + let codex_home = tempfile::TempDir::new()?; + let cwd = tempfile::TempDir::new()?; + let extra_root = tempfile::TempDir::new()?; + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .harness_overrides(crate::config::ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + default_permissions: Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string()), + additional_writable_roots: vec![extra_root.path().to_path_buf()], + ..Default::default() + }) + .build() + .await?; + let session_permission_profile = session_permission_profile_from_config(&config)?; + let file_system_policy = session_permission_profile + .get() + .file_system_sandbox_policy(); + + assert!( + file_system_policy.can_write_path_with_cwd(extra_root.path(), config.cwd.as_path()), + "session permission profile should carry materialized runtime workspace roots" + ); + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result<()> { let server = start_mock_server().await; @@ -2884,7 +2915,7 @@ async fn set_rate_limits_retains_previous_credits() { compact_prompt: config.compact_prompt.clone(), approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, - permission_profile: config.permissions.permission_profile.clone(), + permission_profile: config.permissions.permission_profile().clone(), active_permission_profile: config.permissions.active_permission_profile(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), @@ -2988,7 +3019,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { compact_prompt: config.compact_prompt.clone(), approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, - permission_profile: config.permissions.permission_profile.clone(), + permission_profile: config.permissions.permission_profile().clone(), active_permission_profile: config.permissions.active_permission_profile(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), @@ -3461,7 +3492,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati compact_prompt: config.compact_prompt.clone(), approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, - permission_profile: config.permissions.permission_profile.clone(), + permission_profile: config.permissions.permission_profile().clone(), active_permission_profile: config.permissions.active_permission_profile(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), @@ -3994,7 +4025,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { compact_prompt: config.compact_prompt.clone(), approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, - permission_profile: config.permissions.permission_profile.clone(), + permission_profile: config.permissions.permission_profile().clone(), active_permission_profile: config.permissions.active_permission_profile(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), @@ -4103,7 +4134,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { compact_prompt: config.compact_prompt.clone(), approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, - permission_profile: config.permissions.permission_profile.clone(), + permission_profile: config.permissions.permission_profile().clone(), active_permission_profile: config.permissions.active_permission_profile(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), @@ -4150,7 +4181,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { let services = SessionServices { mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::new_uninitialized( &config.permissions.approval_policy, - &config.permissions.permission_profile, + config.permissions.permission_profile(), ))), mcp_startup_cancellation_token: Mutex::new(CancellationToken::new()), unified_exec_manager: UnifiedExecProcessManager::new( @@ -4335,7 +4366,7 @@ async fn make_session_with_config_and_rx( compact_prompt: config.compact_prompt.clone(), approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, - permission_profile: config.permissions.permission_profile.clone(), + permission_profile: config.permissions.permission_profile().clone(), active_permission_profile: config.permissions.active_permission_profile(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), @@ -4438,7 +4469,7 @@ async fn make_session_with_history_source_and_agent_control_and_rx( compact_prompt: config.compact_prompt.clone(), approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, - permission_profile: config.permissions.permission_profile.clone(), + permission_profile: config.permissions.permission_profile().clone(), active_permission_profile: config.permissions.active_permission_profile(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), @@ -5955,7 +5986,7 @@ where compact_prompt: config.compact_prompt.clone(), approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, - permission_profile: config.permissions.permission_profile.clone(), + permission_profile: config.permissions.permission_profile().clone(), active_permission_profile: config.permissions.active_permission_profile(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), @@ -6002,7 +6033,7 @@ where let services = SessionServices { mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::new_uninitialized( &config.permissions.approval_policy, - &config.permissions.permission_profile, + config.permissions.permission_profile(), ))), mcp_startup_cancellation_token: Mutex::new(CancellationToken::new()), unified_exec_manager: UnifiedExecProcessManager::new( diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index c646b5833df9..45dc06c7760a 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -438,8 +438,12 @@ impl Session { per_turn_config.service_tier = session_configuration.service_tier.clone(); per_turn_config.personality = session_configuration.personality; per_turn_config.approvals_reviewer = session_configuration.approvals_reviewer; - per_turn_config.permissions.permission_profile = - session_configuration.permission_profile.clone(); + per_turn_config + .permissions + .set_constrained_permission_profile_with_active_profile( + session_configuration.permission_profile.clone(), + session_configuration.active_permission_profile.clone(), + ); let permission_profile = session_configuration.permission_profile(); let resolved_web_search_mode = resolve_web_search_mode_for_turn(&per_turn_config.web_search_mode, &permission_profile); @@ -466,8 +470,6 @@ impl Session { Self::build_per_turn_config(session_configuration, session_configuration.cwd.clone()); config.model = Some(session_configuration.collaboration_mode.model().to_string()); config.permissions.approval_policy = session_configuration.approval_policy.clone(); - config.permissions.active_permission_profile = - session_configuration.active_permission_profile.clone(); config } diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 12c25aa8108b..4fc6db14e456 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -2111,7 +2111,7 @@ async fn spawn_agent_reapplies_runtime_sandbox_after_role_config() { turn.permission_profile = expected_permission_profile.clone(); assert_ne!( expected_permission_profile, - turn.config.permissions.permission_profile(), + turn.config.permissions.effective_permission_profile(), "test requires a runtime profile override that differs from base config" ); @@ -3948,7 +3948,7 @@ async fn build_agent_spawn_config_uses_turn_context_values() { #[allow(deprecated)] let turn_cwd = turn.cwd.clone(); let sandbox_policy = pick_allowed_sandbox_policy( - &turn.config.permissions.permission_profile, + turn.config.permissions.permission_profile(), turn.config.legacy_sandbox_policy(), turn_cwd.as_path(), ); diff --git a/codex-rs/core/tests/suite/permissions_messages.rs b/codex-rs/core/tests/suite/permissions_messages.rs index 4d6259a5997f..05fbaad89f9d 100644 --- a/codex-rs/core/tests/suite/permissions_messages.rs +++ b/codex-rs/core/tests/suite/permissions_messages.rs @@ -542,8 +542,9 @@ async fn permissions_message_includes_writable_roots() -> Result<()> { .await; let writable = TempDir::new()?; let writable_root = AbsolutePathBuf::try_from(writable.path())?; + let writable_root_for_config = writable_root.clone(); let permission_profile = PermissionProfile::workspace_write_with( - &[writable_root], + std::slice::from_ref(&writable_root), NetworkSandboxPolicy::Restricted, /*exclude_tmpdir_env_var*/ false, /*exclude_slash_tmp*/ false, @@ -555,6 +556,8 @@ async fn permissions_message_includes_writable_roots() -> Result<()> { .permissions .set_permission_profile(permission_profile) .expect("test permission profile should be allowed"); + let workspace_roots = vec![config.cwd.clone(), writable_root_for_config]; + config.permissions.set_workspace_roots(workspace_roots); config.config_layer_stack = ConfigLayerStack::default(); }); let test = builder.build(&server).await?; diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index eeccd5b46532..8687b33ff024 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -905,9 +905,12 @@ allow_local_binding = true .enable(Feature::UnifiedExec) .expect("test config should allow feature update"); config.permissions.approval_policy = Constrained::allow_any(AskForApproval::Never); - config.permissions.permission_profile = Constrained::allow_any( - PermissionProfile::from_legacy_sandbox_policy(&sandbox_policy_for_config), - ); + config + .permissions + .set_permission_profile(PermissionProfile::from_legacy_sandbox_policy( + &sandbox_policy_for_config, + )) + .expect("set permission profile"); }); let test = builder.build_with_remote_env(server).await?; assert!( @@ -2720,7 +2723,6 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { #[cfg(unix)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn unified_exec_enforces_glob_deny_read_policy() -> Result<()> { - use codex_config::Constrained; use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; @@ -2751,11 +2753,13 @@ async fn unified_exec_enforces_glob_deny_read_policy() -> Result<()> { }, access: FileSystemAccessMode::None, }); - config.permissions.permission_profile = - Constrained::allow_any(PermissionProfile::from_runtime_permissions( + config + .permissions + .set_permission_profile(PermissionProfile::from_runtime_permissions( &file_system_sandbox_policy, NetworkSandboxPolicy::Restricted, - )); + )) + .expect("set permission profile"); }); let TestCodex { codex, diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index 1285b9f9251e..4fe3586d7fec 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -344,11 +344,13 @@ async fn user_shell_command_does_not_set_network_sandbox_env_var() -> anyhow::Re let server = responses::start_mock_server().await; let mut builder = core_test_support::test_codex::test_codex().with_config(|config| { let file_system_sandbox_policy = config.permissions.file_system_sandbox_policy(); - config.permissions.permission_profile = - codex_config::Constrained::allow_any(PermissionProfile::from_runtime_permissions( + config + .permissions + .set_permission_profile(PermissionProfile::from_runtime_permissions( &file_system_sandbox_policy, NetworkSandboxPolicy::Restricted, - )); + )) + .expect("set permission profile"); }); let test = builder.build(&server).await?; diff --git a/codex-rs/exec/Cargo.toml b/codex-rs/exec/Cargo.toml index 546e4e44fb69..37a577e2c850 100644 --- a/codex-rs/exec/Cargo.toml +++ b/codex-rs/exec/Cargo.toml @@ -39,6 +39,7 @@ codex-protocol = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-cli = { workspace = true } codex-utils-oss = { workspace = true } +codex-utils-sandbox-summary = { workspace = true } owo-colors = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index 92248c19ec81..755d754f08ff 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -1,5 +1,4 @@ use std::io::IsTerminal; -use std::path::Path; use std::path::PathBuf; use codex_app_server_protocol::CommandExecutionStatus; @@ -11,11 +10,9 @@ use codex_app_server_protocol::ThreadTokenUsage; use codex_app_server_protocol::TurnStatus; use codex_core::config::Config; use codex_model_provider_info::WireApi; -use codex_protocol::models::PermissionProfile; use codex_protocol::num_format::format_with_separators; -use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::SessionConfiguredEvent; -use codex_utils_absolute_path::canonicalize_preserving_symlinks; +use codex_utils_sandbox_summary::summarize_permission_profile; use owo_colors::OwoColorize; use owo_colors::Style; @@ -437,8 +434,9 @@ fn config_summary_entries( ( "sandbox", summarize_permission_profile( - config.permissions.permission_profile.get(), - config.cwd.as_path(), + &config.permissions.effective_permission_profile(), + &config.cwd, + config.permissions.user_visible_workspace_roots(), ), ), ]; @@ -465,83 +463,6 @@ fn config_summary_entries( entries } -fn summarize_permission_profile(permission_profile: &PermissionProfile, cwd: &Path) -> String { - match permission_profile { - PermissionProfile::Disabled => "danger-full-access".to_string(), - PermissionProfile::External { network } => { - let mut summary = "external-sandbox".to_string(); - append_network_summary(&mut summary, *network); - summary - } - PermissionProfile::Managed { .. } => { - let file_system_policy = permission_profile.file_system_sandbox_policy(); - let network_policy = permission_profile.network_sandbox_policy(); - if file_system_policy.has_full_disk_write_access() { - let mut summary = "workspace-write [/]".to_string(); - append_network_summary(&mut summary, network_policy); - return summary; - } - - let writable_roots = file_system_policy.get_writable_roots_with_cwd(cwd); - if writable_roots.is_empty() { - let mut summary = "read-only".to_string(); - append_network_summary(&mut summary, network_policy); - return summary; - } - - let mut summary = "workspace-write".to_string(); - let writable_entries = writable_roots - .iter() - .map(|root| writable_root_label(root.root.as_path(), cwd)) - .collect::>(); - summary.push_str(&format!(" [{}]", writable_entries.join(", "))); - append_network_summary(&mut summary, network_policy); - summary - } - } -} - -fn append_network_summary(summary: &mut String, network_policy: NetworkSandboxPolicy) { - if network_policy.is_enabled() { - summary.push_str(" (network access enabled)"); - } -} - -fn writable_root_label(root: &Path, cwd: &Path) -> String { - if paths_match_after_canonicalization(root, cwd) { - return "workdir".to_string(); - } - if paths_match_after_canonicalization(root, Path::new("/tmp")) { - return "/tmp".to_string(); - } - if std::env::var_os("TMPDIR") - .filter(|tmpdir| !tmpdir.is_empty()) - .is_some_and(|tmpdir| paths_match_after_canonicalization(root, Path::new(&tmpdir))) - { - return "$TMPDIR".to_string(); - } - display_path_label(root) -} - -fn paths_match_after_canonicalization(left: &Path, right: &Path) -> bool { - match ( - canonicalize_preserving_symlinks(left), - canonicalize_preserving_symlinks(right), - ) { - (Ok(left), Ok(right)) if left == right => true, - _ => display_path_label(left) == display_path_label(right), - } -} - -fn display_path_label(path: &Path) -> String { - path.strip_prefix("/private/tmp") - .ok() - .map(|suffix| Path::new("/tmp").join(suffix)) - .unwrap_or_else(|| path.to_path_buf()) - .to_string_lossy() - .to_string() -} - fn reasoning_text( summary: &[String], content: &[String], diff --git a/codex-rs/exec/src/event_processor_with_human_output_tests.rs b/codex-rs/exec/src/event_processor_with_human_output_tests.rs index 479758f9a0b7..17cfda4550e9 100644 --- a/codex-rs/exec/src/event_processor_with_human_output_tests.rs +++ b/codex-rs/exec/src/event_processor_with_human_output_tests.rs @@ -10,16 +10,15 @@ use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_utils_absolute_path::test_support::PathBufExt; use codex_utils_absolute_path::test_support::test_path_buf; +use codex_utils_sandbox_summary::summarize_permission_profile; use owo_colors::Style; use pretty_assertions::assert_eq; use super::EventProcessorWithHumanOutput; use super::final_message_from_turn_items; -use super::paths_match_after_canonicalization; use super::reasoning_text; use super::should_print_final_message_to_stdout; use super::should_print_final_message_to_tty; -use super::summarize_permission_profile; use crate::event_processor::EventProcessor; #[test] @@ -101,10 +100,13 @@ fn reasoning_text_uses_raw_content_when_enabled() { #[test] fn summarizes_disabled_permission_profile_as_danger_full_access() { + let cwd = test_path_buf("/tmp").abs(); + assert_eq!( summarize_permission_profile( &PermissionProfile::Disabled, - test_path_buf("/tmp").as_path() + &cwd, + std::slice::from_ref(&cwd), ), "danger-full-access" ); @@ -112,12 +114,15 @@ fn summarizes_disabled_permission_profile_as_danger_full_access() { #[test] fn summarizes_external_permission_profile() { + let cwd = test_path_buf("/tmp").abs(); + assert_eq!( summarize_permission_profile( &PermissionProfile::External { network: NetworkSandboxPolicy::Enabled, }, - test_path_buf("/tmp").as_path(), + &cwd, + std::slice::from_ref(&cwd), ), "external-sandbox (network access enabled)" ); @@ -144,32 +149,25 @@ fn summarizes_managed_workspace_write_permission_profile() { ); assert_eq!( - summarize_permission_profile(&profile, cwd.as_path()), + summarize_permission_profile(&profile, &cwd, &[cwd.clone(), cache_root.clone()]), format!("workspace-write [workdir, {}]", cache_root.display()) ); } #[test] fn summarizes_managed_read_only_permission_profile() { + let cwd = test_path_buf("/tmp/project").abs(); let profile = PermissionProfile::from_runtime_permissions( &FileSystemSandboxPolicy::restricted(Vec::new()), NetworkSandboxPolicy::Restricted, ); assert_eq!( - summarize_permission_profile(&profile, test_path_buf("/tmp/project").as_path()), + summarize_permission_profile(&profile, &cwd, std::slice::from_ref(&cwd)), "read-only" ); } -#[test] -fn distinct_missing_paths_do_not_match_after_canonicalization() { - assert!(!paths_match_after_canonicalization( - test_path_buf("/tmp/codex-missing-left").as_path(), - test_path_buf("/tmp/codex-missing-right").as_path(), - )); -} - #[test] fn final_message_from_turn_items_uses_latest_agent_message() { let message = final_message_from_turn_items(&[ diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 2485a9752454..b4724edd297a 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -83,7 +83,6 @@ use codex_protocol::SessionId; use codex_protocol::ThreadId; use codex_protocol::config_types::SandboxMode; use codex_protocol::models::ActivePermissionProfile; -use codex_protocol::models::ActivePermissionProfileModification; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::ReviewRequest; @@ -419,6 +418,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result permission_profile: None, default_permissions: None, cwd: resolved_cwd, + workspace_roots: None, model_provider: model_provider.clone(), service_tier: None, codex_self_exe: arg0_paths.codex_self_exe.clone(), @@ -760,7 +760,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { event_processor.print_config_summary(&config, &prompt_summary, &session_configured); if !json_mode && let Some(message) = - codex_core::config::system_bwrap_warning(config.permissions.permission_profile.get()) + codex_core::config::system_bwrap_warning(config.permissions.permission_profile().get()) { event_processor.process_warning(message); } @@ -953,7 +953,7 @@ fn thread_start_params_from_config(config: &Config) -> ThreadStartParams { let permissions = permissions_selection_from_config(config); let sandbox = permissions.is_none().then(|| { sandbox_mode_from_permission_profile( - &config.permissions.permission_profile(), + &config.permissions.effective_permission_profile(), config.cwd.as_path(), ) }); @@ -975,7 +975,7 @@ fn thread_resume_params_from_config(config: &Config, thread_id: String) -> Threa let permissions = permissions_selection_from_config(config); let sandbox = permissions.is_none().then(|| { sandbox_mode_from_permission_profile( - &config.permissions.permission_profile(), + &config.permissions.effective_permission_profile(), config.cwd.as_path(), ) }); @@ -997,20 +997,25 @@ fn permissions_selection_from_config(config: &Config) -> Option PermissionProfileSelectionParams { - let modifications = active - .modifications - .into_iter() - .map(|modification| match modification { - ActivePermissionProfileModification::AdditionalWritableRoot { path } => { - PermissionProfileModificationParams::AdditionalWritableRoot { path } - } - }) + let modifications = workspace_roots + .iter() + .filter(|root| root.as_path() != cwd) + .cloned() + .map(|path| PermissionProfileModificationParams::AdditionalWritableRoot { path }) .collect::>(); PermissionProfileSelectionParams::Profile { id: active.id, @@ -1091,7 +1096,7 @@ fn session_configured_from_thread_start_response( .permission_profile .clone() .map(Into::into) - .unwrap_or_else(|| config.permissions.permission_profile()), + .unwrap_or_else(|| config.permissions.effective_permission_profile()), response.active_permission_profile.clone().map(Into::into), response.cwd.clone(), response.reasoning_effort, @@ -1116,7 +1121,7 @@ fn session_configured_from_thread_resume_response( .permission_profile .clone() .map(Into::into) - .unwrap_or_else(|| config.permissions.permission_profile()), + .unwrap_or_else(|| config.permissions.effective_permission_profile()), response.active_permission_profile.clone().map(Into::into), response.cwd.clone(), response.reasoning_effort, diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index 321f4b8f9e01..2aa26634355e 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -1,6 +1,8 @@ use super::*; use codex_otel::set_parent_from_w3c_trace_context; use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::models::ActivePermissionProfile; +use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; use codex_utils_absolute_path::test_support::PathBufExt; use codex_utils_absolute_path::test_support::test_path_buf; use opentelemetry::trace::TraceContextExt; @@ -456,6 +458,28 @@ async fn thread_start_params_include_review_policy_when_auto_review_is_enabled() ); } +#[test] +fn active_profile_selection_includes_extra_workspace_roots_as_modifications() { + let cwd = test_path_buf("/workspace/project").abs(); + let extra_root = test_path_buf("/workspace/cache").abs(); + + let selection = permissions_selection_from_active_profile( + ActivePermissionProfile::new(BUILT_IN_PERMISSION_PROFILE_WORKSPACE), + cwd.as_path(), + &[cwd.clone(), extra_root.clone()], + ); + + assert_eq!( + selection, + PermissionProfileSelectionParams::Profile { + id: BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string(), + modifications: Some(vec![ + PermissionProfileModificationParams::AdditionalWritableRoot { path: extra_root } + ]), + } + ); +} + #[tokio::test] async fn thread_lifecycle_params_include_legacy_sandbox_when_no_active_profile() { let codex_home = tempdir().expect("create temp codex home"); diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 6919ee43e770..512dbd544445 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -344,21 +344,6 @@ pub struct ActivePermissionProfile { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] pub extends: Option, - - /// Bounded user-requested modifications applied on top of the named - /// profile, if any. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub modifications: Vec, -} - -#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "snake_case")] -#[ts(tag = "type")] -pub enum ActivePermissionProfileModification { - /// Additional concrete directory that should be writable. - #[serde(rename_all = "snake_case")] - #[ts(rename_all = "snake_case")] - AdditionalWritableRoot { path: AbsolutePathBuf }, } impl ActivePermissionProfile { @@ -366,17 +351,8 @@ impl ActivePermissionProfile { Self { id: id.into(), extends: None, - modifications: Vec::new(), } } - - pub fn with_modifications( - mut self, - modifications: Vec, - ) -> Self { - self.modifications = modifications; - self - } } impl Default for PermissionProfile { @@ -444,6 +420,28 @@ impl PermissionProfile { } } + pub fn materialize_project_roots_with_workspace_roots( + self, + workspace_roots: &[AbsolutePathBuf], + ) -> Self { + match self { + Self::Managed { + file_system, + network, + } => { + let file_system = file_system + .to_sandbox_policy() + .materialize_project_roots_with_workspace_roots(workspace_roots); + Self::Managed { + file_system: ManagedFileSystemPermissions::from_sandbox_policy(&file_system), + network, + } + } + Self::Disabled => Self::Disabled, + Self::External { network } => Self::External { network }, + } + } + pub fn from_runtime_permissions( file_system_sandbox_policy: &FileSystemSandboxPolicy, network_sandbox_policy: NetworkSandboxPolicy, diff --git a/codex-rs/protocol/src/permissions.rs b/codex-rs/protocol/src/permissions.rs index e6d9503ea188..1d8a1e707e5a 100644 --- a/codex-rs/protocol/src/permissions.rs +++ b/codex-rs/protocol/src/permissions.rs @@ -350,6 +350,12 @@ pub enum FileSystemPath { }, } +const PROJECT_ROOTS_GLOB_PATTERN_PREFIX: &str = "codex-project-roots://"; + +pub fn project_roots_glob_pattern(subpath: &Path) -> String { + format!("{PROJECT_ROOTS_GLOB_PATTERN_PREFIX}{}", subpath.display()) +} + impl Default for FileSystemSandboxPolicy { fn default() -> Self { Self { @@ -703,15 +709,100 @@ impl FileSystemSandboxPolicy { pub fn materialize_project_roots_with_cwd(mut self, cwd: &Path) -> Self { let cwd = AbsolutePathBuf::from_absolute_path(cwd).ok(); for entry in &mut self.entries { - let FileSystemPath::Special { - value: FileSystemSpecialPath::ProjectRoots { .. }, - } = &entry.path - else { - continue; - }; + match &entry.path { + FileSystemPath::Special { + value: FileSystemSpecialPath::ProjectRoots { .. }, + } => { + if let Some(path) = resolve_file_system_path(&entry.path, cwd.as_ref()) { + entry.path = FileSystemPath::Path { path }; + } + } + FileSystemPath::GlobPattern { pattern } => { + if let (Some(cwd), Some(subpath)) = + (cwd.as_ref(), parse_project_roots_glob_pattern(pattern)) + { + entry.path = FileSystemPath::GlobPattern { + pattern: resolve_project_roots_glob_pattern(subpath, cwd), + }; + } + } + FileSystemPath::Special { value: _ } => {} + FileSystemPath::Path { .. } => {} + } + } + self + } + + /// Replaces symbolic `:workspace_roots` entries with concrete entries for + /// each workspace root. + pub fn materialize_project_roots_with_workspace_roots( + mut self, + workspace_roots: &[AbsolutePathBuf], + ) -> Self { + let mut entries = Vec::with_capacity(self.entries.len()); + for entry in self.entries { + match entry.path { + FileSystemPath::Special { + value: FileSystemSpecialPath::ProjectRoots { subpath }, + } => { + entries.extend(workspace_roots.iter().map(|root| FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: match subpath.as_ref() { + Some(subpath) => AbsolutePathBuf::resolve_path_against_base( + subpath, + root.as_path(), + ), + None => root.clone(), + }, + }, + access: entry.access, + })); + } + FileSystemPath::GlobPattern { pattern } => { + if let Some(subpath) = parse_project_roots_glob_pattern(&pattern) { + entries.extend(workspace_roots.iter().map(|root| FileSystemSandboxEntry { + path: FileSystemPath::GlobPattern { + pattern: resolve_project_roots_glob_pattern(subpath, root), + }, + access: entry.access, + })); + } else { + entries.push(FileSystemSandboxEntry { + path: FileSystemPath::GlobPattern { pattern }, + access: entry.access, + }); + } + } + FileSystemPath::Path { path } => { + entries.push(FileSystemSandboxEntry { + path: FileSystemPath::Path { path }, + access: entry.access, + }); + } + FileSystemPath::Special { value } => { + entries.push(FileSystemSandboxEntry { + path: FileSystemPath::Special { value }, + access: entry.access, + }); + } + } + } + self.entries = entries; + self + } - if let Some(path) = resolve_file_system_path(&entry.path, cwd.as_ref()) { - entry.path = FileSystemPath::Path { path }; + /// Preserves symbolic `:workspace_roots` entries while also adding concrete + /// entries for each provided workspace root. + pub fn with_materialized_project_roots_for_workspace_roots( + mut self, + workspace_roots: &[AbsolutePathBuf], + ) -> Self { + let materialized = self + .clone() + .materialize_project_roots_with_workspace_roots(workspace_roots); + for entry in materialized.entries { + if !self.entries.contains(&entry) { + self.entries.push(entry); } } self @@ -1209,6 +1300,18 @@ fn resolve_entry_path( } } +fn parse_project_roots_glob_pattern(pattern: &str) -> Option<&Path> { + pattern + .strip_prefix(PROJECT_ROOTS_GLOB_PATTERN_PREFIX) + .map(Path::new) +} + +fn resolve_project_roots_glob_pattern(subpath: &Path, root: &AbsolutePathBuf) -> String { + AbsolutePathBuf::resolve_path_against_base(subpath, root.as_path()) + .to_string_lossy() + .into_owned() +} + fn resolve_candidate_path(path: &Path, cwd: &Path) -> Option { if path.is_absolute() { AbsolutePathBuf::from_absolute_path(path).ok() @@ -2750,6 +2853,115 @@ mod tests { ); } + #[test] + fn materialize_project_roots_with_workspace_roots_expands_exact_and_glob_entries() { + let temp_dir = TempDir::new().expect("tempdir"); + let first = AbsolutePathBuf::from_absolute_path(temp_dir.path().join("first")) + .expect("resolve first root"); + let second = AbsolutePathBuf::from_absolute_path(temp_dir.path().join("second")) + .expect("resolve second root"); + let policy = FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::project_roots(/*subpath*/ None), + }, + access: FileSystemAccessMode::Write, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::project_roots(Some(".git".into())), + }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::GlobPattern { + pattern: project_roots_glob_pattern(Path::new("**/*.env")), + }, + access: FileSystemAccessMode::None, + }, + ]); + + let actual = + policy.materialize_project_roots_with_workspace_roots(&[first.clone(), second.clone()]); + + assert_eq!( + actual, + FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: first.clone(), + }, + access: FileSystemAccessMode::Write, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: second.clone(), + }, + access: FileSystemAccessMode::Write, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: first.join(".git"), + }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: second.join(".git"), + }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::GlobPattern { + pattern: AbsolutePathBuf::resolve_path_against_base( + "**/*.env", + first.as_path(), + ) + .to_string_lossy() + .into_owned(), + }, + access: FileSystemAccessMode::None, + }, + FileSystemSandboxEntry { + path: FileSystemPath::GlobPattern { + pattern: AbsolutePathBuf::resolve_path_against_base( + "**/*.env", + second.as_path(), + ) + .to_string_lossy() + .into_owned(), + }, + access: FileSystemAccessMode::None, + }, + ]) + ); + } + + #[test] + fn materialize_project_roots_with_cwd_expands_symbolic_glob_entries() { + let cwd = TempDir::new().expect("tempdir"); + let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { + path: FileSystemPath::GlobPattern { + pattern: project_roots_glob_pattern(Path::new("**/*.env")), + }, + access: FileSystemAccessMode::None, + }]); + + let actual = policy.materialize_project_roots_with_cwd(cwd.path()); + + assert_eq!( + actual, + FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { + path: FileSystemPath::GlobPattern { + pattern: AbsolutePathBuf::resolve_path_against_base("**/*.env", cwd.path()) + .to_string_lossy() + .into_owned(), + }, + access: FileSystemAccessMode::None, + }]) + ); + } + #[test] fn with_additional_legacy_workspace_writable_roots_protects_metadata() { let temp_dir = TempDir::new().expect("tempdir"); diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index 4dde7379222c..b8b7b0a519e2 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -41,7 +41,6 @@ use codex_core_api::RealtimeAudioConfig; use codex_core_api::RealtimeConfig; use codex_core_api::SessionPickerViewMode; use codex_core_api::SessionSource; -use codex_core_api::ShellEnvironmentPolicy; use codex_core_api::TerminalResizeReflowConfig; use codex_core_api::ThreadManager; use codex_core_api::ThreadStoreConfig; @@ -172,16 +171,10 @@ fn new_config(model: Option, arg0_paths: Arg0DispatchPaths) -> anyhow::R model_provider_id, model_provider, personality: None, - permissions: Permissions { - approval_policy: Constrained::allow_any(AskForApproval::Never), - permission_profile: Constrained::allow_any(PermissionProfile::read_only()), - active_permission_profile: None, - network: None, - allow_login_shell: true, - shell_environment_policy: ShellEnvironmentPolicy::default(), - windows_sandbox_mode: None, - windows_sandbox_private_desktop: true, - }, + permissions: Permissions::from_approval_and_profile( + Constrained::allow_any(AskForApproval::Never), + Constrained::allow_any(PermissionProfile::read_only()), + ), approvals_reviewer: ApprovalsReviewer::User, enforce_residency: Constrained::allow_any(/*initial_value*/ None), hide_agent_reasoning: false, @@ -213,7 +206,9 @@ fn new_config(model: Option, arg0_paths: Arg0DispatchPaths) -> anyhow::R tui_keymap: TuiKeymap::default(), tui_session_picker_view: SessionPickerViewMode::Dense, tui_vim_mode_default: false, - cwd, + cwd: cwd.clone(), + workspace_roots: vec![cwd], + workspace_roots_explicit: false, cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, mcp_servers: Constrained::allow_any(HashMap::new()), mcp_oauth_credentials_store_mode: OAuthCredentialsStoreMode::File, diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 77154d3e5a35..1f160fa3c310 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -957,7 +957,7 @@ See the Codex keymap documentation for supported actions and examples." // world-writable dirs on Windows. #[cfg(target_os = "windows")] { - let startup_permission_profile = app.config.permissions.permission_profile(); + let startup_permission_profile = app.config.permissions.effective_permission_profile(); let should_check = WindowsSandboxLevel::from_config(&app.config) != WindowsSandboxLevel::Disabled && managed_filesystem_sandbox_is_restricted(&startup_permission_profile) diff --git a/codex-rs/tui/src/app/config_persistence.rs b/codex-rs/tui/src/app/config_persistence.rs index 26cf16a9e0a8..807663189ddd 100644 --- a/codex-rs/tui/src/app/config_persistence.rs +++ b/codex-rs/tui/src/app/config_persistence.rs @@ -299,10 +299,13 @@ impl App { self.config.permissions.approval_policy.value(), )); } - if permission_profile_override.is_some() + let permission_profile_override_value = permission_profile_override + .is_some() + .then(|| self.config.permissions.permission_profile().get().clone()); + if let Some(permission_profile) = permission_profile_override_value.as_ref() && let Err(err) = self .chat_widget - .set_permission_profile(self.config.permissions.permission_profile()) + .set_permission_profile(permission_profile.clone()) { tracing::error!( error = %err, @@ -311,9 +314,8 @@ impl App { self.chat_widget .add_error_message(format!("Failed to enable Auto-review: {err}")); } - if permission_profile_override.is_some() { - self.runtime_permission_profile_override = - Some(self.config.permissions.permission_profile()); + if let Some(permission_profile) = permission_profile_override_value { + self.runtime_permission_profile_override = Some(permission_profile); } if approval_policy_override.is_some() diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index bff4479f067a..fb351991b613 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -1426,7 +1426,7 @@ impl App { return Ok(AppRunControl::Continue); } self.runtime_permission_profile_override = - Some(self.config.permissions.permission_profile()); + Some(self.config.permissions.permission_profile().get().clone()); self.sync_active_thread_permission_settings_to_cached_session() .await; @@ -1450,7 +1450,8 @@ impl App { std::env::vars().collect(); let tx = self.app_event_tx.clone(); let logs_base_dir = self.config.codex_home.clone(); - let permission_profile = self.config.permissions.permission_profile(); + let permission_profile = + self.config.permissions.effective_permission_profile(); Self::spawn_world_writable_scan( cwd, env_map, diff --git a/codex-rs/tui/src/app/startup_prompts.rs b/codex-rs/tui/src/app/startup_prompts.rs index c04c16fdc80e..e2b347775631 100644 --- a/codex-rs/tui/src/app/startup_prompts.rs +++ b/codex-rs/tui/src/app/startup_prompts.rs @@ -67,7 +67,7 @@ pub(super) fn emit_project_config_warnings(app_event_tx: &AppEventSender, config pub(super) fn emit_system_bwrap_warning(app_event_tx: &AppEventSender, config: &Config) { let Some(message) = - codex_sandboxing::system_bwrap_warning(config.permissions.permission_profile.get()) + codex_sandboxing::system_bwrap_warning(config.permissions.permission_profile().get()) else { return; }; diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 2abecc3585a8..abdde1c9957c 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -1639,8 +1639,9 @@ async fn update_feature_flags_enabling_guardian_selects_auto_review() -> Result< app.chat_widget .config_ref() .permissions - .permission_profile(), - auto_review.permission_profile + .permission_profile() + .get(), + &auto_review.permission_profile ); assert_eq!( app.chat_widget.config_ref().approvals_reviewer, @@ -1816,8 +1817,9 @@ async fn update_feature_flags_enabling_guardian_overrides_explicit_manual_review app.chat_widget .config_ref() .permissions - .permission_profile(), - auto_review.permission_profile + .permission_profile() + .get(), + &auto_review.permission_profile ); assert_eq!( op_rx.try_recv(), @@ -3001,7 +3003,9 @@ async fn thread_read_session_state_does_not_reuse_primary_permission_profile() { .chat_widget .config_ref() .permissions - .permission_profile(); + .permission_profile() + .get() + .clone(); assert_eq!( session.permission_profile, expected_permission_profile, "thread/read does not return fresh server permissions; the fallback profile must use the \ @@ -3136,7 +3140,7 @@ async fn side_fork_config_inherits_parent_thread_runtime_settings() { fork_config.model_reasoning_effort, fork_config.service_tier.as_deref(), fork_config.permissions.approval_policy.value(), - fork_config.permissions.permission_profile(), + fork_config.permissions.permission_profile().get(), fork_config.approvals_reviewer, ), ( @@ -3144,7 +3148,7 @@ async fn side_fork_config_inherits_parent_thread_runtime_settings() { Some(ReasoningEffortConfig::High), Some(parent_service_tier), AskForApproval::OnRequest.to_core(), - parent_permission_profile, + &parent_permission_profile, ApprovalsReviewer::AutoReview, ) ); diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index f25398b0b837..9d6cda012d67 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -589,7 +589,9 @@ impl App { let approvals_reviewer = approvals_reviewer.unwrap_or(config.approvals_reviewer); let active_permission_profile = - if config.permissions.permission_profile() == permission_profile.clone() { + if config.permissions.effective_permission_profile() + == permission_profile.clone() + { config.permissions.active_permission_profile() } else { None @@ -603,6 +605,7 @@ impl App { approvals_reviewer, permission_profile.clone(), active_permission_profile, + config.permissions.user_visible_workspace_roots(), model.to_string(), *effort, *summary, diff --git a/codex-rs/tui/src/app/thread_session_state.rs b/codex-rs/tui/src/app/thread_session_state.rs index 4f5f48cfc812..d057baa40c6e 100644 --- a/codex-rs/tui/src/app/thread_session_state.rs +++ b/codex-rs/tui/src/app/thread_session_state.rs @@ -19,7 +19,9 @@ impl App { .chat_widget .config_ref() .permissions - .permission_profile(); + .permission_profile() + .get() + .clone(); let active_permission_profile = self .chat_widget .config_ref() @@ -101,6 +103,8 @@ impl App { .config_ref() .permissions .permission_profile() + .get() + .clone() } fn current_active_permission_profile(&self) -> Option { @@ -350,11 +354,13 @@ mod tests { .chat_widget .config_ref() .permissions - .permission_profile(); + .permission_profile() + .get() + .clone(); assert_eq!(session.permission_profile, expected_permission_profile); assert_ne!( session.permission_profile, - app.config.permissions.permission_profile(), + app.config.permissions.permission_profile().get().clone(), "thread/read fallback must use the active widget permissions rather than stale app \ config defaults" ); diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 56ad0ccdea62..6a0af955ba89 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -108,7 +108,6 @@ use codex_otel::TelemetryAuthMode; use codex_protocol::ThreadId; use codex_protocol::approvals::GuardianAssessmentEvent; use codex_protocol::models::ActivePermissionProfile; -use codex_protocol::models::ActivePermissionProfileModification; use codex_protocol::models::PermissionProfile; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ModelAvailabilityNux; @@ -552,6 +551,7 @@ impl AppServerSession { approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer, permission_profile: PermissionProfile, active_permission_profile: Option, + workspace_roots: &[AbsolutePathBuf], model: String, effort: Option, summary: Option, @@ -565,6 +565,7 @@ impl AppServerSession { &permission_profile, active_permission_profile, cwd.as_path(), + workspace_roots, self.thread_params_mode(), ); self.client @@ -1174,15 +1175,14 @@ fn sandbox_mode_from_permission_profile( fn permissions_selection_from_active_profile( active: ActivePermissionProfile, + cwd: &std::path::Path, + workspace_roots: &[AbsolutePathBuf], ) -> PermissionProfileSelectionParams { - let modifications = active - .modifications - .into_iter() - .map(|modification| match modification { - ActivePermissionProfileModification::AdditionalWritableRoot { path } => { - PermissionProfileModificationParams::AdditionalWritableRoot { path } - } - }) + let modifications = workspace_roots + .iter() + .filter(|root| root.as_path() != cwd) + .cloned() + .map(|path| PermissionProfileModificationParams::AdditionalWritableRoot { path }) .collect::>(); PermissionProfileSelectionParams::Profile { id: active.id, @@ -1194,13 +1194,15 @@ fn turn_permissions_overrides( permission_profile: &PermissionProfile, active_permission_profile: Option, cwd: &std::path::Path, + workspace_roots: &[AbsolutePathBuf], thread_params_mode: ThreadParamsMode, ) -> ( Option, Option, ) { let permissions = if matches!(thread_params_mode, ThreadParamsMode::Embedded) { - active_permission_profile.map(permissions_selection_from_active_profile) + active_permission_profile + .map(|active| permissions_selection_from_active_profile(active, cwd, workspace_roots)) } else { None }; @@ -1229,7 +1231,13 @@ fn permissions_selection_from_config( config .permissions .active_permission_profile() - .map(permissions_selection_from_active_profile) + .map(|active| { + permissions_selection_from_active_profile( + active, + config.cwd.as_path(), + config.permissions.user_visible_workspace_roots(), + ) + }) } fn thread_start_params_from_config( @@ -1243,7 +1251,7 @@ fn thread_start_params_from_config( .is_none() .then(|| { sandbox_mode_from_permission_profile( - &config.permissions.permission_profile(), + &config.permissions.effective_permission_profile(), config.cwd.as_path(), ) }) @@ -1277,7 +1285,7 @@ fn thread_resume_params_from_config( .is_none() .then(|| { sandbox_mode_from_permission_profile( - &config.permissions.permission_profile(), + &config.permissions.effective_permission_profile(), config.cwd.as_path(), ) }) @@ -1309,7 +1317,7 @@ fn thread_fork_params_from_config( .is_none() .then(|| { sandbox_mode_from_permission_profile( - &config.permissions.permission_profile(), + &config.permissions.effective_permission_profile(), config.cwd.as_path(), ) }) @@ -1499,7 +1507,7 @@ fn permission_profile_from_thread_response( return permission_profile.clone().into(); } match thread_params_mode { - ThreadParamsMode::Embedded => config.permissions.permission_profile(), + ThreadParamsMode::Embedded => config.permissions.effective_permission_profile(), ThreadParamsMode::Remote => { PermissionProfile::from_legacy_sandbox_policy_for_cwd(&sandbox.to_core(), cwd) } @@ -1635,7 +1643,13 @@ mod tests { config .permissions .active_permission_profile() - .map(permissions_selection_from_active_profile) + .map(|active| { + permissions_selection_from_active_profile( + active, + config.cwd.as_path(), + config.permissions.user_visible_workspace_roots(), + ) + }) ); assert_eq!(params.model_provider, Some(config.model_provider_id)); assert_eq!(params.thread_source, Some(ThreadSource::User)); @@ -1661,13 +1675,18 @@ mod tests { let cwd = test_path_buf("/workspace/project").abs(); let active_permission_profile = ActivePermissionProfile::new(BUILT_IN_PERMISSION_PROFILE_WORKSPACE); - let expected_permissions = - permissions_selection_from_active_profile(active_permission_profile.clone()); + let workspace_roots = vec![cwd.clone()]; + let expected_permissions = permissions_selection_from_active_profile( + active_permission_profile.clone(), + cwd.as_path(), + &workspace_roots, + ); let (sandbox_policy, permissions) = turn_permissions_overrides( &PermissionProfile::workspace_write(), Some(active_permission_profile), cwd.as_path(), + &workspace_roots, ThreadParamsMode::Embedded, ); @@ -1675,6 +1694,36 @@ mod tests { assert_eq!(permissions, Some(expected_permissions)); } + #[test] + fn embedded_turn_permissions_include_extra_workspace_roots_as_modifications() { + let cwd = test_path_buf("/workspace/project").abs(); + let extra_root = test_path_buf("/workspace/cache").abs(); + let active_permission_profile = + ActivePermissionProfile::new(BUILT_IN_PERMISSION_PROFILE_WORKSPACE); + let workspace_roots = vec![cwd.clone(), extra_root.clone()]; + + let (sandbox_policy, permissions) = turn_permissions_overrides( + &PermissionProfile::workspace_write(), + Some(active_permission_profile), + cwd.as_path(), + &workspace_roots, + ThreadParamsMode::Embedded, + ); + + assert_eq!(sandbox_policy, None); + assert_eq!( + permissions, + Some(PermissionProfileSelectionParams::Profile { + id: BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string(), + modifications: Some(vec![ + PermissionProfileModificationParams::AdditionalWritableRoot { + path: extra_root + } + ]), + }) + ); + } + #[test] fn embedded_turn_permissions_fall_back_to_sandbox_without_active_profile() { let cwd = test_path_buf("/workspace/project").abs(); @@ -1683,6 +1732,7 @@ mod tests { &PermissionProfile::read_only(), /*active_permission_profile*/ None, cwd.as_path(), + std::slice::from_ref(&cwd), ThreadParamsMode::Embedded, ); @@ -1705,6 +1755,7 @@ mod tests { BUILT_IN_PERMISSION_PROFILE_READ_ONLY, )), cwd.as_path(), + std::slice::from_ref(&cwd), ThreadParamsMode::Remote, ); @@ -1723,7 +1774,7 @@ mod tests { let config = build_config(&temp_dir).await; let thread_id = ThreadId::new(); let expected_sandbox = sandbox_mode_from_permission_profile( - &config.permissions.permission_profile(), + &config.permissions.effective_permission_profile(), config.cwd.as_path(), ); @@ -1830,7 +1881,7 @@ mod tests { let thread_id = ThreadId::new(); let remote_cwd = PathBuf::from("repo/on/server"); let expected_sandbox = sandbox_mode_from_permission_profile( - &config.permissions.permission_profile(), + &config.permissions.effective_permission_profile(), config.cwd.as_path(), ); diff --git a/codex-rs/tui/src/chatwidget/input_submission.rs b/codex-rs/tui/src/chatwidget/input_submission.rs index 9043cb3d9dc0..6e9860bb77f1 100644 --- a/codex-rs/tui/src/chatwidget/input_submission.rs +++ b/codex-rs/tui/src/chatwidget/input_submission.rs @@ -334,7 +334,7 @@ impl ChatWidget { None if self.config.notices.fast_default_opt_out == Some(true) => Some(None), None => None, }; - let permission_profile = self.config.permissions.permission_profile(); + let permission_profile = self.config.permissions.effective_permission_profile(); let op = AppCommand::user_turn( items, self.config.cwd.to_path_buf(), diff --git a/codex-rs/tui/src/chatwidget/permission_popups.rs b/codex-rs/tui/src/chatwidget/permission_popups.rs index dc428b4092e5..cf341cb8298f 100644 --- a/codex-rs/tui/src/chatwidget/permission_popups.rs +++ b/codex-rs/tui/src/chatwidget/permission_popups.rs @@ -17,7 +17,7 @@ impl ChatWidget { let include_read_only = cfg!(target_os = "windows"); let current_approval = AskForApproval::from(self.config.permissions.approval_policy.value()); - let current_permission_profile = self.config.permissions.permission_profile(); + let current_permission_profile = self.config.permissions.permission_profile().get().clone(); let guardian_approval_enabled = self.config.features.enabled(Feature::GuardianApproval); let current_review_policy = self.config.approvals_reviewer; let mut items: Vec = Vec::new(); diff --git a/codex-rs/tui/src/chatwidget/session_flow.rs b/codex-rs/tui/src/chatwidget/session_flow.rs index 892e43f3ec17..58cbe9bb73d8 100644 --- a/codex-rs/tui/src/chatwidget/session_flow.rs +++ b/codex-rs/tui/src/chatwidget/session_flow.rs @@ -32,7 +32,26 @@ impl ChatWidget { self.forked_from = session.forked_from_id; self.current_rollout_path = session.rollout_path.clone(); self.current_cwd = Some(session.cwd.to_path_buf()); + let previous_cwd = self.config.cwd.clone(); + let previous_workspace_roots = self.config.workspace_roots.clone(); self.config.cwd = session.cwd.clone(); + if !self.config.workspace_roots_explicit { + let mut workspace_roots = vec![session.cwd.clone()]; + if previous_workspace_roots + .iter() + .any(|root| root == &previous_cwd) + { + for root in previous_workspace_roots { + if root != previous_cwd + && !workspace_roots.iter().any(|existing| existing == &root) + { + workspace_roots.push(root); + } + } + } + self.config.workspace_roots = workspace_roots.clone(); + self.config.permissions.set_workspace_roots(workspace_roots); + } self.effective_service_tier = session.service_tier.clone(); if let Err(err) = self .config @@ -53,10 +72,12 @@ impl ChatWidget { ); if let Err(err) = permission_sync { tracing::warn!(%err, "failed to sync permissions from SessionConfigured"); - self.config.permissions.permission_profile = - Constrained::allow_only(session.permission_profile.clone()); - self.config.permissions.active_permission_profile = - session.active_permission_profile.clone(); + self.config + .permissions + .set_constrained_permission_profile_with_active_profile( + Constrained::allow_only(session.permission_profile.clone()), + session.active_permission_profile.clone(), + ); } self.config.approvals_reviewer = session.approvals_reviewer; self.status_line_project_root_name_cache = None; diff --git a/codex-rs/tui/src/chatwidget/status_surfaces.rs b/codex-rs/tui/src/chatwidget/status_surfaces.rs index 12ed07cf985d..8dd5f327d3de 100644 --- a/codex-rs/tui/src/chatwidget/status_surfaces.rs +++ b/codex-rs/tui/src/chatwidget/status_surfaces.rs @@ -901,8 +901,12 @@ fn permissions_display(config: &Config) -> String { return active_permission_profile.id.clone(); } - let permission_profile = config.permissions.permission_profile(); - let summary = summarize_permission_profile(&permission_profile, config.cwd.as_path()); + let permission_profile = config.permissions.effective_permission_profile(); + let summary = summarize_permission_profile( + &permission_profile, + &config.cwd, + config.permissions.workspace_roots(), + ); if let Some(details) = summary.strip_prefix("read-only") && !details.contains("(network access enabled)") { diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index d77823c3cd6d..e6c8fcad88bf 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -282,7 +282,9 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() { let actual_sandbox = SandboxPolicy::from(chat.config_ref().legacy_sandbox_policy()); assert_eq!(&actual_sandbox, &expected_sandbox); assert_eq!( - AppServerPermissionProfile::from(chat.config_ref().permissions.permission_profile()), + AppServerPermissionProfile::from( + chat.config_ref().permissions.effective_permission_profile() + ), expected_app_server_permission_profile ); assert_eq!(&chat.config_ref().cwd, &expected_cwd); @@ -291,9 +293,65 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() { chat.set_permission_profile(updated_profile.clone()) .expect("set permission profile"); assert_eq!( - chat.config_ref().permissions.permission_profile(), - updated_profile, - "local permission changes should replace SessionConfigured profile-derived runtime permissions" + chat.config_ref().permissions.permission_profile().get(), + &updated_profile, + "local permission changes should replace SessionConfigured canonical permissions" + ); + assert_eq!( + chat.config_ref().permissions.effective_permission_profile(), + updated_profile + .materialize_project_roots_with_workspace_roots(std::slice::from_ref(&expected_cwd)), + "effective permissions should still use the current thread runtime workspace roots" + ); +} + +#[tokio::test] +async fn session_configured_preserves_profile_workspace_roots() { + let (mut chat, _rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; + + let previous_cwd = test_path_buf("/home/user/main").abs(); + let profile_root = test_path_buf("/home/user/shared").abs(); + chat.config.cwd = previous_cwd.clone(); + chat.config.workspace_roots = vec![previous_cwd, profile_root.clone()]; + chat.config.workspace_roots_explicit = false; + chat.config + .permissions + .set_workspace_roots(chat.config.workspace_roots.clone()); + + let session_cwd = test_path_buf("/home/user/sub-agent").abs(); + let session_workspace_roots = vec![session_cwd.clone(), profile_root]; + let session_permission_profile = PermissionProfile::workspace_write() + .materialize_project_roots_with_workspace_roots(&session_workspace_roots); + let configured = crate::session_state::ThreadSessionState { + thread_id: ThreadId::new(), + forked_from_id: None, + fork_parent_title: None, + thread_name: None, + model: "test-model".to_string(), + model_provider_id: "test-provider".to_string(), + service_tier: None, + approval_policy: AskForApproval::Never, + approvals_reviewer: ApprovalsReviewer::User, + permission_profile: session_permission_profile.clone(), + active_permission_profile: None, + cwd: session_cwd.clone(), + instruction_source_paths: Vec::new(), + reasoning_effort: Some(ReasoningEffortConfig::default()), + message_history: None, + network_proxy: None, + rollout_path: None, + }; + + chat.handle_thread_session(configured); + + assert_eq!(&chat.config_ref().cwd, &session_cwd); + assert_eq!( + chat.config_ref().permissions.user_visible_workspace_roots(), + session_workspace_roots.as_slice() + ); + assert_eq!( + chat.config_ref().permissions.effective_permission_profile(), + session_permission_profile ); } @@ -334,7 +392,9 @@ async fn session_configured_external_sandbox_keeps_external_runtime_policy() { let actual_sandbox = SandboxPolicy::from(chat.config_ref().legacy_sandbox_policy()); assert_eq!(&actual_sandbox, &expected_sandbox); assert_eq!( - AppServerPermissionProfile::from(chat.config_ref().permissions.permission_profile()), + AppServerPermissionProfile::from( + chat.config_ref().permissions.effective_permission_profile() + ), expected_app_server_permission_profile ); } diff --git a/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs b/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs index 3f6de0879fd3..401e2a787d7e 100644 --- a/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs +++ b/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs @@ -15,14 +15,7 @@ impl ChatWidget { } let cwd = self.config.cwd.clone(); let env_map: std::collections::HashMap = std::env::vars().collect(); - let Ok(policy) = self - .config - .permissions - .permission_profile() - .to_legacy_sandbox_policy(self.config.cwd.as_path()) - else { - return Some((Vec::new(), 0, true)); - }; + let policy = self.config.legacy_sandbox_policy(); match codex_windows_sandbox::apply_world_writable_scan_and_denies( self.config.codex_home.as_path(), cwd.as_path(), @@ -72,7 +65,9 @@ impl ChatWidget { let mode_label = preset .as_ref() .map(|p| describe_profile(&p.permission_profile)) - .unwrap_or_else(|| describe_profile(&self.config.permissions.permission_profile())); + .unwrap_or_else(|| { + describe_profile(&self.config.permissions.effective_permission_profile()) + }); let info_line = if failed_scan { Line::from(vec![ "We couldn't complete the world-writable scan, so protections cannot be verified. " diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 7b97151edb2e..d78d3f86b009 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -1683,7 +1683,7 @@ pub(crate) fn new_session_info( pub(crate) fn is_yolo_mode(config: &Config) -> bool { has_yolo_permissions( AskForApproval::from(config.permissions.approval_policy.value()), - &config.permissions.permission_profile(), + &config.permissions.effective_permission_profile(), ) } diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 47ff11d3741b..c3ec77620605 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -1047,7 +1047,7 @@ pub async fn run_main( if let Some(warning) = add_dir_warning_message( &cli.add_dir, - &config.permissions.permission_profile(), + &config.permissions.effective_permission_profile(), config.cwd.as_path(), ) { #[allow(clippy::print_stderr)] diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index 0e19a158215a..9a7c23606f44 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -15,17 +15,16 @@ use codex_protocol::ThreadId; use codex_protocol::account::PlanType; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::models::ActivePermissionProfile; -use codex_protocol::models::ActivePermissionProfileModification; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ReasoningEffort; +use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_sandbox_summary::summarize_permission_profile; use ratatui::prelude::*; use ratatui::style::Stylize; use std::collections::BTreeSet; -use std::path::Path; use std::path::PathBuf; use url::Url; @@ -256,7 +255,8 @@ impl StatusHistoryCell { refreshing_rate_limits: bool, ) -> (Self, StatusHistoryHandle) { let approval_policy = AskForApproval::from(config.permissions.approval_policy.value()); - let permission_profile = config.permissions.permission_profile(); + let permission_profile = config.permissions.effective_permission_profile(); + let workspace_roots = config.permissions.user_visible_workspace_roots(); let mut config_entries = vec![ ("workdir", config.cwd.display().to_string()), ("model", model_name.to_string()), @@ -267,7 +267,7 @@ impl StatusHistoryCell { ), ( "sandbox", - summarize_permission_profile(&permission_profile, config.cwd.as_path()), + summarize_permission_profile(&permission_profile, &config.cwd, workspace_roots), ), ]; if config.model_provider.wire_api == WireApi::Responses { @@ -291,7 +291,8 @@ impl StatusHistoryCell { .map(|(_, v)| v.clone()) .unwrap_or_else(|| "".to_string()); let active_permission_profile = config.permissions.active_permission_profile(); - let sandbox = status_permission_summary(&permission_profile, config.cwd.as_path()); + let sandbox = status_permission_summary(&permission_profile, &config.cwd, workspace_roots); + let workspace_root_suffix = workspace_root_suffix(workspace_roots, &config.cwd); let approval = status_approval_label(approval_policy, config.approvals_reviewer, &approval); let permissions = status_permissions_label( active_permission_profile.as_ref(), @@ -299,6 +300,7 @@ impl StatusHistoryCell { approval_policy, &sandbox, &approval, + workspace_root_suffix.as_deref(), ); let model_provider = format_model_provider(config, runtime_model_provider_base_url); let account = compose_account_display(account_display); @@ -542,8 +544,12 @@ impl StatusHistoryCell { } } -fn status_permission_summary(permission_profile: &PermissionProfile, cwd: &Path) -> String { - let summary = summarize_permission_profile(permission_profile, cwd); +fn status_permission_summary( + permission_profile: &PermissionProfile, + cwd: &AbsolutePathBuf, + workspace_roots: &[AbsolutePathBuf], +) -> String { + let summary = summarize_permission_profile(permission_profile, cwd, workspace_roots); if let Some(details) = summary.strip_prefix("read-only") { if details.contains("(network access enabled)") { return "read-only with network access".to_string(); @@ -562,33 +568,31 @@ fn status_permission_summary(permission_profile: &PermissionProfile, cwd: &Path) summary } +fn workspace_root_suffix( + workspace_roots: &[AbsolutePathBuf], + cwd: &AbsolutePathBuf, +) -> Option { + let extra_roots = workspace_roots + .iter() + .filter(|root| *root != cwd) + .map(|root| root.to_string_lossy().to_string()) + .collect::>(); + if extra_roots.is_empty() { + None + } else { + Some(format!(" [{}]", extra_roots.join(", "))) + } +} + fn status_permissions_label( active_permission_profile: Option<&ActivePermissionProfile>, permission_profile: &PermissionProfile, approval_policy: AskForApproval, sandbox: &str, approval: &str, + workspace_root_suffix: Option<&str>, ) -> String { let active_id = active_permission_profile.map(|active| active.id.as_str()); - let writable_root_modifications = active_permission_profile - .map(|active| { - active - .modifications - .iter() - .filter(|modification| { - matches!( - modification, - ActivePermissionProfileModification::AdditionalWritableRoot { .. } - ) - }) - .count() - }) - .unwrap_or(0); - let modification_suffix = match writable_root_modifications { - 0 => String::new(), - 1 => " + 1 writable root".to_string(), - count => format!(" + {count} writable roots"), - }; match active_id { Some(BUILT_IN_PERMISSION_PROFILE_READ_ONLY) => { let label = if sandbox == "read-only with network access" { @@ -596,12 +600,20 @@ fn status_permissions_label( } else { "Read Only" }; - return format!("{label}{modification_suffix} ({approval})"); + return format!("{label} ({approval})"); } Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE) => match sandbox { - "workspace" => return format!("Workspace{modification_suffix} ({approval})"), + "workspace" => { + return format!( + "Workspace{} ({approval})", + workspace_root_suffix.unwrap_or("") + ); + } "workspace with network access" => { - return format!("Workspace with network access{modification_suffix} ({approval})"); + return format!( + "Workspace with network access{} ({approval})", + workspace_root_suffix.unwrap_or("") + ); } _ => {} }, @@ -614,7 +626,10 @@ fn status_permissions_label( format!("No Sandbox ({approval})") }; } - Some(id) => return format!("Profile {id}{modification_suffix} ({sandbox}, {approval})"), + Some(id) => { + let sandbox = decorate_workspace_sandbox_label(sandbox, workspace_root_suffix); + return format!("Profile {id} ({sandbox}, {approval})"); + } None => {} } @@ -622,16 +637,27 @@ fn status_permissions_label( return format!("Read Only ({approval})"); } if approval_policy == AskForApproval::OnRequest && sandbox == "workspace" { - return format!("Workspace ({approval})"); + return format!( + "Workspace{} ({approval})", + workspace_root_suffix.unwrap_or("") + ); } if approval_policy == AskForApproval::Never && permission_profile == &PermissionProfile::Disabled { return "Full Access".to_string(); } + let sandbox = decorate_workspace_sandbox_label(sandbox, workspace_root_suffix); format!("Custom ({sandbox}, {approval})") } +fn decorate_workspace_sandbox_label(sandbox: &str, workspace_root_suffix: Option<&str>) -> String { + match workspace_root_suffix { + Some(suffix) if sandbox.starts_with("workspace") => format!("{sandbox}{suffix}"), + _ => sandbox.to_string(), + } +} + fn status_approval_label( approval_policy: AskForApproval, approvals_reviewer: ApprovalsReviewer, diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index a1a63bc4b94e..e69f2da3c8ca 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -31,12 +31,12 @@ use codex_protocol::ThreadId; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::models::ActivePermissionProfile; -use codex_protocol::models::ActivePermissionProfileModification; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_utils_absolute_path::AbsolutePathBuf; use insta::assert_snapshot; use pretty_assertions::assert_eq; use ratatui::prelude::*; @@ -97,6 +97,14 @@ async fn test_config(temp_home: &TempDir) -> Config { config } +fn set_workspace_cwd(config: &mut Config, cwd: AbsolutePathBuf) { + config.cwd = cwd.clone(); + config.workspace_roots = vec![cwd]; + config + .permissions + .set_workspace_roots(config.workspace_roots.clone()); +} + fn test_status_account_display() -> Option { None } @@ -195,7 +203,7 @@ async fn status_snapshot_includes_reasoning_details() { config.model = Some("gpt-5.1-codex-max".to_string()); config.model_provider_id = "openai".to_string(); config.model_reasoning_summary = Some(ReasoningSummary::Detailed); - config.cwd = test_path_buf("/workspace/tests").abs(); + set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs()); config .permissions .set_permission_profile(PermissionProfile::workspace_write()) @@ -273,7 +281,7 @@ async fn status_permissions_non_default_workspace_write_uses_workspace_label() { .approval_policy .set(AskForApproval::OnRequest.to_core()) .expect("set approval policy"); - config.cwd = test_path_buf("/workspace/tests").abs(); + set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs()); config .permissions .set_permission_profile(app_server_workspace_write_profile( @@ -332,20 +340,15 @@ async fn status_permissions_read_only_profile_shows_additional_writable_roots() &file_system_policy, NetworkSandboxPolicy::Restricted, ), - Some( - ActivePermissionProfile::new(BUILT_IN_PERMISSION_PROFILE_READ_ONLY) - .with_modifications(vec![ - ActivePermissionProfileModification::AdditionalWritableRoot { - path: extra_root, - }, - ]), - ), + Some(ActivePermissionProfile::new( + BUILT_IN_PERMISSION_PROFILE_READ_ONLY, + )), ) .expect("set permission profile"); assert_eq!( permissions_text_for(&config).as_deref(), - Some("Read Only + 1 writable root (on-request)") + Some("Read Only (on-request)") ); } @@ -419,20 +422,44 @@ async fn status_permissions_named_profile_shows_additional_writable_roots() { /*exclude_tmpdir_env_var*/ false, /*exclude_slash_tmp*/ false, ), - Some( - ActivePermissionProfile::new(BUILT_IN_PERMISSION_PROFILE_WORKSPACE) - .with_modifications(vec![ - ActivePermissionProfileModification::AdditionalWritableRoot { - path: extra_root, - }, - ]), - ), + Some(ActivePermissionProfile::new( + BUILT_IN_PERMISSION_PROFILE_WORKSPACE, + )), ) .expect("set permission profile"); assert_eq!( permissions_text_for(&config).as_deref(), - Some("Workspace + 1 writable root (on-request)") + Some("Workspace (on-request)") + ); +} + +#[tokio::test] +async fn status_permissions_workspace_roots_show_additional_directories() { + let temp_home = TempDir::new().expect("temp home"); + let mut config = test_config(&temp_home).await; + set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs()); + config + .permissions + .approval_policy + .set(AskForApproval::OnRequest.to_core()) + .expect("set approval policy"); + let extra_root = test_path_buf("/workspace/extra").abs(); + config.workspace_roots = vec![config.cwd.clone(), extra_root.clone()]; + config + .permissions + .set_workspace_roots(config.workspace_roots.clone()); + config + .permissions + .set_permission_profile_with_active_profile( + PermissionProfile::workspace_write(), + Some(ActivePermissionProfile::new(":workspace")), + ) + .expect("set permission profile"); + + assert_eq!( + permissions_text_for(&config), + Some(format!("Workspace [{}] (on-request)", extra_root.display())) ); } @@ -489,7 +516,7 @@ async fn status_snapshot_shows_active_user_defined_profile() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex-max".to_string()); - config.cwd = test_path_buf("/workspace/tests").abs(); + set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs()); config .permissions .set_permission_profile_with_active_profile( @@ -586,7 +613,7 @@ async fn status_snapshot_shows_auto_review_permissions() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex-max".to_string()); - config.cwd = test_path_buf("/workspace/tests").abs(); + set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs()); config.approvals_reviewer = ApprovalsReviewer::AutoReview; config .permissions @@ -689,7 +716,7 @@ async fn status_snapshot_includes_forked_from() { let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex-max".to_string()); config.model_provider_id = "openai".to_string(); - config.cwd = test_path_buf("/workspace/tests").abs(); + set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs()); let account_display = test_status_account_display(); let usage = TokenUsage { @@ -743,7 +770,7 @@ async fn status_snapshot_includes_monthly_limit() { let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex-max".to_string()); config.model_provider_id = "openai".to_string(); - config.cwd = test_path_buf("/workspace/tests").abs(); + set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs()); let account_display = test_status_account_display(); let usage = TokenUsage { @@ -1001,7 +1028,7 @@ async fn status_card_token_usage_excludes_cached_tokens() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex-max".to_string()); - config.cwd = test_path_buf("/workspace/tests").abs(); + set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs()); let account_display = test_status_account_display(); let usage = TokenUsage { @@ -1049,7 +1076,7 @@ async fn status_snapshot_truncates_in_narrow_terminal() { config.model = Some("gpt-5.1-codex-max".to_string()); config.model_provider_id = "openai".to_string(); config.model_reasoning_summary = Some(ReasoningSummary::Detailed); - config.cwd = test_path_buf("/workspace/tests").abs(); + set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs()); let account_display = test_status_account_display(); let usage = TokenUsage { @@ -1113,7 +1140,7 @@ async fn status_snapshot_shows_missing_limits_message() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex-max".to_string()); - config.cwd = test_path_buf("/workspace/tests").abs(); + set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs()); let account_display = test_status_account_display(); let usage = TokenUsage { @@ -1161,7 +1188,7 @@ async fn status_snapshot_uses_default_reasoning_when_config_empty() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex-max".to_string()); - config.cwd = test_path_buf("/workspace/tests").abs(); + set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs()); let account_display = test_status_account_display(); let usage = TokenUsage { @@ -1212,7 +1239,7 @@ async fn status_snapshot_shows_refreshing_limits_notice() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex-max".to_string()); - config.cwd = test_path_buf("/workspace/tests").abs(); + set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs()); let usage = TokenUsage { input_tokens: 500, @@ -1277,7 +1304,7 @@ async fn status_snapshot_includes_credits_and_limits() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex".to_string()); - config.cwd = test_path_buf("/workspace/tests").abs(); + set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs()); let account_display = test_status_account_display(); let usage = TokenUsage { @@ -1347,7 +1374,7 @@ async fn status_snapshot_shows_unavailable_limits_message() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex-max".to_string()); - config.cwd = test_path_buf("/workspace/tests").abs(); + set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs()); let account_display = test_status_account_display(); let usage = TokenUsage { @@ -1405,7 +1432,7 @@ async fn status_snapshot_treats_refreshing_empty_limits_as_unavailable() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex-max".to_string()); - config.cwd = test_path_buf("/workspace/tests").abs(); + set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs()); let usage = TokenUsage { input_tokens: 500, @@ -1463,7 +1490,7 @@ async fn status_snapshot_shows_stale_limits_message() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex-max".to_string()); - config.cwd = test_path_buf("/workspace/tests").abs(); + set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs()); let account_display = test_status_account_display(); let usage = TokenUsage { @@ -1530,7 +1557,7 @@ async fn status_snapshot_cached_limits_hide_credits_without_flag() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex".to_string()); - config.cwd = test_path_buf("/workspace/tests").abs(); + set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs()); let account_display = test_status_account_display(); let usage = TokenUsage { diff --git a/codex-rs/utils/sandbox-summary/Cargo.toml b/codex-rs/utils/sandbox-summary/Cargo.toml index 758d779781e7..cb892238d9a7 100644 --- a/codex-rs/utils/sandbox-summary/Cargo.toml +++ b/codex-rs/utils/sandbox-summary/Cargo.toml @@ -11,6 +11,7 @@ workspace = true codex-core = { workspace = true } codex-model-provider-info = { workspace = true } codex-protocol = { workspace = true } +codex-utils-absolute-path = { workspace = true } [dev-dependencies] codex-utils-absolute-path = { workspace = true } diff --git a/codex-rs/utils/sandbox-summary/src/sandbox_summary.rs b/codex-rs/utils/sandbox-summary/src/sandbox_summary.rs index 0719773aad23..47e801bec446 100644 --- a/codex-rs/utils/sandbox-summary/src/sandbox_summary.rs +++ b/codex-rs/utils/sandbox-summary/src/sandbox_summary.rs @@ -1,7 +1,7 @@ use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::NetworkAccess; use codex_protocol::protocol::SandboxPolicy; -use std::path::Path; +use codex_utils_absolute_path::AbsolutePathBuf; pub fn summarize_sandbox_policy(sandbox_policy: &SandboxPolicy) -> String { match sandbox_policy { @@ -51,8 +51,39 @@ pub fn summarize_sandbox_policy(sandbox_policy: &SandboxPolicy) -> String { } } -pub fn summarize_permission_profile(permission_profile: &PermissionProfile, cwd: &Path) -> String { - match permission_profile.to_legacy_sandbox_policy(cwd) { +pub fn summarize_permission_profile( + permission_profile: &PermissionProfile, + cwd: &AbsolutePathBuf, + workspace_roots: &[AbsolutePathBuf], +) -> String { + match permission_profile.to_legacy_sandbox_policy(cwd.as_path()) { + Ok(SandboxPolicy::WorkspaceWrite { + network_access, + exclude_tmpdir_env_var, + exclude_slash_tmp, + .. + }) => { + let mut summary = "workspace-write".to_string(); + let mut writable_entries = vec!["workdir".to_string()]; + if !exclude_slash_tmp { + writable_entries.push("/tmp".to_string()); + } + if !exclude_tmpdir_env_var { + writable_entries.push("$TMPDIR".to_string()); + } + writable_entries.extend( + workspace_roots + .iter() + .filter(|root| *root != cwd) + .map(|root| root.to_string_lossy().to_string()), + ); + + summary.push_str(&format!(" [{}]", writable_entries.join(", "))); + if network_access { + summary.push_str(" (network access enabled)"); + } + summary + } Ok(policy) => summarize_sandbox_policy(&policy), Err(_) => { if permission_profile.network_sandbox_policy().is_enabled() { @@ -67,6 +98,7 @@ pub fn summarize_permission_profile(permission_profile: &PermissionProfile, cwd: #[cfg(test)] mod tests { use super::*; + use codex_protocol::permissions::NetworkSandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; @@ -112,4 +144,39 @@ mod tests { ) ); } + + #[test] + fn permission_profile_summary_uses_runtime_workspace_roots_and_hides_internal_writes() { + let cwd = + AbsolutePathBuf::try_from(if cfg!(windows) { "C:\\repo" } else { "/repo" }).unwrap(); + let extra_root = AbsolutePathBuf::try_from(if cfg!(windows) { + "C:\\repo-extra" + } else { + "/repo-extra" + }) + .unwrap(); + let hidden_root = AbsolutePathBuf::try_from(if cfg!(windows) { + "C:\\Users\\test\\.codex\\memories" + } else { + "/Users/test/.codex/memories" + }) + .unwrap(); + let profile = PermissionProfile::workspace_write_with( + std::slice::from_ref(&hidden_root), + NetworkSandboxPolicy::Restricted, + /*exclude_tmpdir_env_var*/ false, + /*exclude_slash_tmp*/ false, + ); + + let summary = + summarize_permission_profile(&profile, &cwd, &[cwd.clone(), extra_root.clone()]); + + assert_eq!( + summary, + format!( + "workspace-write [workdir, /tmp, $TMPDIR, {}]", + extra_root.display() + ) + ); + } }