From c25d905f61ca96ed575cd24e82fc4295465c66d0 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Thu, 14 May 2026 18:25:23 -0700 Subject: [PATCH] permissions: support workspace roots in profiles (#22610) ## Why This is the configuration/model half of the alternative permissions migration we discussed as a comparison point for [#22401](https://github.com/openai/codex/pull/22401) and [#22402](https://github.com/openai/codex/pull/22402). The old `workspace-write` model mixes three concerns that we want to keep separate: - reusable profile rules that should stay immutable once selected - user/runtime workspace roots from `cwd`, `--add-dir`, and legacy workspace-write config - internal Codex writable roots such as memories, which should not be shown as user workspace roots This PR gives permission profiles first-class `workspace_roots` so users can opt multiple repositories into the same `:workspace_roots` rules without using broad absolute-path write grants. It also starts separating the raw selected profile from the effective runtime profile by making `Permissions` expose explicit accessors instead of public mutable fields. A representative `config.toml` looks like this: ```toml default_permissions = "dev" [permissions.dev.workspace_roots] "~/code/openai" = true "~/code/developers-website" = true [permissions.dev.filesystem.":workspace_roots"] "." = "write" ".codex" = "read" ".git" = "read" ".vscode" = "read" ``` If Codex starts in `~/code/codex` with that profile selected, the effective workspace-root set becomes: - `~/code/codex` from the runtime `cwd` - `~/code/openai` from the profile - `~/code/developers-website` from the profile The `:workspace_roots` rules are materialized across each root, so `.git`, `.codex`, and `.vscode` stay scoped the same way everywhere. Runtime additions such as `--add-dir` can still layer on later stack entries without mutating the selected profile. ## Stack Shape This PR intentionally stops before the profile-identity cleanup in [#22683](https://github.com/openai/codex/pull/22683) so the base review stays focused on config loading, workspace-root materialization, and compatibility with legacy `workspace-write`. The representation in this PR is therefore transitional: `Permissions` carries enough state to distinguish the raw constrained profile from the effective runtime profile, and there are still call sites that must keep the active profile identity and constrained profile value in sync. The follow-up PR replaces that with a single resolved profile state (`ResolvedPermissionProfile` / `PermissionProfileState`) that keeps the profile id, immutable `PermissionProfile`, and profile-declared workspace roots together. That follow-up removes APIs such as `set_constrained_permission_profile_with_active_profile()` where separate arguments could drift out of sync. Downstream PRs then build on this base to switch app-server turn updates to profile ids plus runtime workspace roots and to finish the user-visible summary behavior. Reviewers should judge this PR as the workspace-roots foundation, not as the final in-memory shape of selected permission profiles. ## Review Guide Suggested review order: 1. Start with `codex-rs/core/src/config/mod.rs`. This is the main shape change in the base slice. `Permissions` now stores a private raw `Constrained` plus runtime `workspace_roots`. Callers use `permission_profile()` when they need the raw constrained value and `effective_permission_profile()` when they need a materialized runtime profile. As noted above, [#22683](https://github.com/openai/codex/pull/22683) replaces this transitional shape with a resolved profile state that keeps identity and profile data together. 2. Review `codex-rs/config/src/permissions_toml.rs` and `codex-rs/core/src/config/permissions.rs`. These add `[permissions..workspace_roots]`, resolve enabled entries relative to the policy cwd, and keep `:workspace_roots` deny-read glob patterns symbolic until the actual roots are known. 3. Review `codex-rs/protocol/src/permissions.rs` and `codex-rs/protocol/src/models.rs`. These add the policy/profile materialization helpers that expand exact `:workspace_roots` entries and scoped deny-read globs over every workspace root. This is also where `ActivePermissionProfileModification` is removed from the core model. 4. Review the legacy bridge in `Config::load_from_base_config_with_overrides` and `Config::set_legacy_sandbox_policy`. This is where legacy `workspace-write` roots become runtime workspace roots, while Codex internal writable roots stay internal and do not appear as user-facing workspace roots. 5. Then skim downstream call sites. The interesting pattern is raw-vs-effective access: state/proxy/bwrap paths keep the raw constrained profile, while execution, summaries, and user-visible status use the effective profile and workspace-root list. ## What Changed - added `[permissions..workspace_roots]` to the config model and schema - added runtime `workspace_roots` state to `Config`/`Permissions` and `ConfigOverrides` - made `Permissions` profile fields private and replaced direct mutation with accessors/setters - added `PermissionProfile` and `FileSystemSandboxPolicy` helpers for materializing `:workspace_roots` exact paths and deny-read globs across all roots - moved legacy additional writable roots into runtime workspace-root state instead of active profile modifications - removed `ActivePermissionProfileModification` and its app-server protocol/schema export - updated sandbox/status summary paths so internal writable roots are not reported as user workspace roots ## Verification Strategy The targeted tests cover the behavior at the layers where regressions are most likely: - `codex-rs/core/src/config/config_tests.rs` verifies config loading, legacy workspace-root seeding, effective profile materialization, and memory-root handling. - `codex-rs/core/src/config/permissions_tests.rs` verifies profile `workspace_roots` parsing and `:workspace_roots` scoped/glob compilation. - `codex-rs/protocol/src/permissions.rs` unit tests verify exact and glob materialization over multiple workspace roots. - `codex-rs/tui/src/status/tests.rs` and `codex-rs/utils/sandbox-summary/src/sandbox_summary.rs` verify the user-facing summaries show effective workspace roots and hide internal writes. I also ran `cargo check --tests` locally after the latest stack refresh to catch cross-crate API breakage from the private-field/accessor changes. --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/22610). * #22612 * #22611 * #22683 * __->__ #22610 --- 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 4d1f66008..50de8dc94 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 e996ca541..8bcd2edd6 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 7f73f9fe0..a7b8a2600 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 6e74ab4ac..4eb85f4ed 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 727b7a3fb..312d289e4 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 bf03f0fb5..c363f2e78 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 cbc8c6ef0..73f9efcab 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 1cbee6868..000000000 --- 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 984154ba0..0a6d868ad 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 86614a6ae..0796ee4e8 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 350acf002..f2ac6ca00 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 3236a6762..d1781db5f 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 92da7cdd8..85b2e5757 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 d1dae4ef4..110406cc9 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 30c60d7b9..bdcf0a191 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 cee68d7ab..fff8c6770 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 dd2b01d0a..d802fa4e6 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 7760c2d32..2e077bae3 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 0587da83e..7472e5c57 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 b93b8745d..9f8fcd9ee 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 86a3c604d..51cf13912 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 0ccd6c33a..cd5a8ac33 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 253ead5b4..25b84420d 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 7601ef44c..18400fa23 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 0a29e9788..5db947472 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 f7c7a91ca..a15fbedc0 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -756,7 +756,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, @@ -820,7 +820,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 38988329e..50632ce81 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 c646b5833..45dc06c77 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 12c25aa81..4fc6db14e 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 4d6259a59..05fbaad89 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 eeccd5b46..8687b33ff 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 1285b9f92..4fe3586d7 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 546e4e44f..37a577e2c 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 92248c19e..755d754f0 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 479758f9a..17cfda455 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 2485a9752..b4724edd2 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 321f4b8f9..2aa266343 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 6919ee43e..512dbd544 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 e6d9503ea..1d8a1e707 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 + } - if let Some(path) = resolve_file_system_path(&entry.path, cwd.as_ref()) { - entry.path = FileSystemPath::Path { path }; + /// 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 + } + + /// 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 4dde73792..b8b7b0a51 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 77154d3e5..1f160fa3c 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 26cf16a9e..807663189 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 bff4479f0..fb351991b 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 c04c16fdc..e2b347775 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 2abecc358..abdde1c99 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 f25398b0b..9d6cda012 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 4f5f48cfc..d057baa40 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 56ad0ccde..6a0af955b 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 9043cb3d9..6e9860bb7 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 dc428b409..cf341cb82 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 892e43f3e..58cbe9bb7 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 12ed07cf9..8dd5f327d 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 d77823c3c..e6c8fcad8 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 3f6de0879..401e2a787 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 7b97151ed..d78d3f86b 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 47ff11d37..c3ec77620 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 0e19a1582..9a7c23606 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 a1a63bc4b..e69f2da3c 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 758d77978..cb892238d 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 0719773aa..47e801bec 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() + ) + ); + } }