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 066e1f4870..c14226d5f1 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 95ce7e4aef..243d9a0b68 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 6e74ab4ac8..4eb85f4ed3 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 727b7a3fb2..312d289e41 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 bf03f0fb55..c363f2e78d 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 cbc8c6ef0a..73f9efcab5 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 1cbee6868a..0000000000 --- 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 ab6eaefb5a..fc0915c0aa 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 86614a6aeb..0796ee4e89 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 350acf002f..c3ec2f8131 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -588,9 +588,9 @@ pub async fn run_main_with_transport_options( range: None, }); } - if let Some(warning) = - codex_core::config::system_bwrap_warning(config.permissions.permission_profile.get()) - { + if let Some(warning) = codex_core::config::system_bwrap_warning( + config.permissions.permission_profile_constraint().get(), + ) { config_warnings.push(ConfigWarningNotification { summary: warning, details: None, 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 3236a67627..537213a2cd 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,10 @@ 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_constraint() + .get(), /*policy_decider*/ None, /*blocked_request_observer*/ None, managed_network_requirements_enabled, @@ -243,7 +246,7 @@ impl CommandExecRequestProcessor { ); self.config .permissions - .permission_profile + .permission_profile_constraint() .can_set(&effective_permission_profile) .map_err(|err| invalid_request(format!("invalid permission profile: {err}")))?; effective_permission_profile @@ -264,7 +267,7 @@ impl CommandExecRequestProcessor { ); self.config .permissions - .permission_profile + .permission_profile_constraint() .can_set(&permission_profile) .map_err(|err| invalid_request(format!("invalid sandbox policy: {err}")))?; permission_profile diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index 30c60d7b95..ccb49630dd 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_constraint().get(), /*policy_decider*/ None, /*blocked_request_observer*/ None, managed_network_requirements_enabled, diff --git a/codex-rs/config/src/permissions_toml.rs b/codex-rs/config/src/permissions_toml.rs index cee68d7abb..fff8c67706 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 d8c64a0d13..953121ac4f 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1943,6 +1943,9 @@ }, "network": { "$ref": "#/definitions/NetworkToml" + }, + "workspace_roots": { + "$ref": "#/definitions/WorkspaceRootsToml" } }, "type": "object" @@ -3930,6 +3933,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 27a67da68d..b8e7a54c1c 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; @@ -69,7 +70,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; @@ -719,6 +719,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" @@ -745,6 +749,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([ @@ -800,6 +810,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([( @@ -953,6 +964,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([( @@ -1103,6 +1115,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([( @@ -1205,6 +1218,7 @@ enabled = false entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( @@ -1253,6 +1267,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([( @@ -1299,6 +1314,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([ @@ -1332,6 +1348,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(), @@ -1343,14 +1360,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, }, @@ -1371,6 +1388,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 @@ -1565,6 +1588,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([( @@ -1610,7 +1634,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 { @@ -1619,6 +1645,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([( @@ -1637,6 +1664,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(), @@ -1650,21 +1678,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 @@ -1694,6 +1724,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([( @@ -1764,8 +1795,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()?; @@ -1787,20 +1817,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(()) } @@ -2185,6 +2283,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([( @@ -2241,6 +2340,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([( @@ -2301,6 +2401,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([( @@ -2344,6 +2445,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([( @@ -2380,6 +2482,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, }) @@ -2408,6 +2511,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(), @@ -2443,6 +2547,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([( @@ -2489,6 +2594,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([( @@ -7356,6 +7462,7 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { 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(), @@ -7367,6 +7474,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( @@ -7805,6 +7914,7 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { 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(), @@ -7816,6 +7926,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( @@ -7968,6 +8080,7 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { 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(), @@ -7979,6 +8092,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( @@ -8116,6 +8231,7 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { 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(), @@ -8127,6 +8243,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( diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index cc5203251c..b62fbf6f85 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; @@ -248,11 +248,14 @@ 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, + /// runtime workspace-root materialization have been applied. + permission_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 `permission_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 +277,89 @@ 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, + 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_constraint(&self) -> &Constrained { + &self.permission_profile + } + + /// Replace the full constrained profile value and clear any profile-name + /// sidecar because the new constraint may no longer match that name. + pub fn replace_permission_profile_constraint( + &mut self, + permission_profile: Constrained, + ) { + self.permission_profile = permission_profile; + self.active_permission_profile = None; + } + + /// Replace the full constrained profile value and preserve the active + /// profile sidecar when the caller has already validated both together. + pub fn replace_permission_profile_constraint_with_active_profile( + &mut self, + permission_profile: Constrained, + active_permission_profile: Option, + ) { + self.permission_profile = permission_profile; + self.active_permission_profile = active_permission_profile; + } + + /// Record the active profile id only if it still describes the current + /// canonical profile. + pub fn set_active_permission_profile_for_current_profile( + &mut self, + active_permission_profile: Option, + expected_permission_profile: Option<&PermissionProfile>, + ) { + self.active_permission_profile = + match (active_permission_profile, expected_permission_profile) { + (Some(active_permission_profile), Some(expected_permission_profile)) + if self.permission_profile.get() == expected_permission_profile => + { + Some(active_permission_profile) + } + _ => None, + }; + } + + pub fn set_workspace_roots(&mut self, workspace_roots: Vec) { + self.workspace_roots = workspace_roots; + } + + pub fn workspace_roots(&self) -> &[AbsolutePathBuf] { + &self.workspace_roots + } + + fn materialized_permission_profile(&self) -> PermissionProfile { + self.permission_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. + /// workspace-root materialization have been applied. pub fn permission_profile(&self) -> PermissionProfile { - self.permission_profile.get().clone() + self.materialized_permission_profile() } /// Named profile selected by config, if the current profile has one. @@ -287,7 +369,8 @@ 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. @@ -297,10 +380,10 @@ impl Permissions { /// 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, @@ -341,6 +424,26 @@ 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.active_permission_profile = None; @@ -577,6 +680,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. @@ -1085,8 +1197,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 { @@ -1935,6 +2053,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. @@ -2248,6 +2374,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(); @@ -2340,11 +2467,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( @@ -2386,12 +2512,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!( @@ -2401,6 +2522,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, @@ -2429,18 +2584,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, @@ -2474,6 +2635,20 @@ 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); + 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) { @@ -2484,36 +2659,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, ); @@ -2529,22 +2694,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, @@ -2583,25 +2733,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, @@ -3114,11 +3260,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, active_permission_profile, + workspace_roots, network, allow_login_shell, shell_environment_policy, @@ -3404,7 +3553,7 @@ impl Config { pub fn managed_network_requirements_enabled(&self) -> bool { !matches!( - self.permissions.permission_profile.get(), + self.permissions.permission_profile_constraint().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 b93b8745d7..9f8fcd9ee3 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 86a3c604dd..51cf13912e 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/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index d3fae0f2e5..763ce240c9 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -896,9 +896,11 @@ 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 + .replace_permission_profile_constraint(Constrained::allow_only( + PermissionProfile::from_legacy_sandbox_policy(&sandbox_policy), + )); guardian_config .permissions .set_legacy_sandbox_policy(sandbox_policy, guardian_config.cwd.as_path()) @@ -924,7 +926,10 @@ 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_constraint() + .get(), )?); } for feature in [ diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index 7601ef44c3..a62c5d49b0 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -2163,7 +2163,10 @@ async fn guardian_review_session_config_preserves_parent_network_proxy() { }), ..Default::default() }), - parent_config.permissions.permission_profile.get(), + parent_config + .permissions + .permission_profile_constraint() + .get(), ) .expect("network proxy spec"); parent_config.permissions.network = Some(network.clone()); @@ -2190,8 +2193,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_constraint(), + &Constrained::allow_only(PermissionProfile::from_legacy_sandbox_policy( &SandboxPolicy::new_read_only_policy(), )) ); @@ -2230,7 +2233,10 @@ 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_constraint() + .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 0a29e97883..66679a704b 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: config.permissions.permission_profile_constraint().clone(), active_permission_profile: config.permissions.active_permission_profile(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 5b1b8c83e4..603392dd1e 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -769,7 +769,7 @@ impl Session { let (network_proxy, session_network_proxy) = Self::start_managed_network_proxy( spec, current_exec_policy.as_ref(), - config.permissions.permission_profile.get(), + config.permissions.permission_profile_constraint().get(), network_policy_decider.as_ref().map(Arc::clone), blocked_request_observer.as_ref().map(Arc::clone), managed_network_requirements_configured, @@ -833,7 +833,7 @@ impl Session { // setup is straightforward enough and performs well. mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::new_uninitialized( &config.permissions.approval_policy, - &config.permissions.permission_profile, + config.permissions.permission_profile_constraint(), ))), 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 38988329e9..4a8df83a1a 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -2128,8 +2128,10 @@ 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.replace_permission_profile_constraint( + codex_config::Constrained::allow_any(PermissionProfile::from_legacy_sandbox_policy( + &sandbox_policy, + )), ); config .set_legacy_sandbox_policy(sandbox_policy) @@ -2884,7 +2886,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_constraint().clone(), active_permission_profile: config.permissions.active_permission_profile(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), @@ -2988,7 +2990,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_constraint().clone(), active_permission_profile: config.permissions.active_permission_profile(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), @@ -3461,7 +3463,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_constraint().clone(), active_permission_profile: config.permissions.active_permission_profile(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), @@ -3994,7 +3996,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_constraint().clone(), active_permission_profile: config.permissions.active_permission_profile(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), @@ -4103,7 +4105,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_constraint().clone(), active_permission_profile: config.permissions.active_permission_profile(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), @@ -4150,7 +4152,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_constraint(), ))), mcp_startup_cancellation_token: Mutex::new(CancellationToken::new()), unified_exec_manager: UnifiedExecProcessManager::new( @@ -4335,7 +4337,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_constraint().clone(), active_permission_profile: config.permissions.active_permission_profile(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), @@ -4438,7 +4440,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_constraint().clone(), active_permission_profile: config.permissions.active_permission_profile(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), @@ -5955,7 +5957,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_constraint().clone(), active_permission_profile: config.permissions.active_permission_profile(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), @@ -6002,7 +6004,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_constraint(), ))), 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 c646b5833d..86ec9da95d 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 + .replace_permission_profile_constraint_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,12 @@ 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 + .permissions + .set_active_permission_profile_for_current_profile( + session_configuration.active_permission_profile.clone(), + Some(session_configuration.permission_profile.get()), + ); 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 12c25aa810..6b8bdd2adc 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -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_constraint(), turn.config.legacy_sandbox_policy(), turn_cwd.as_path(), ); 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 92248c19ec..5983b9f42c 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -437,7 +437,7 @@ fn config_summary_entries( ( "sandbox", summarize_permission_profile( - config.permissions.permission_profile.get(), + config.permissions.permission_profile_constraint().get(), config.cwd.as_path(), ), ), diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 6919ee43e7..512dbd5444 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 e6d9503ea1..1d8a1e707e 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/tui/src/app/startup_prompts.rs b/codex-rs/tui/src/app/startup_prompts.rs index aa281d4533..dc03e64f59 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) = crate::legacy_core::config::system_bwrap_warning( - config.permissions.permission_profile.get(), + config.permissions.permission_profile_constraint().get(), ) else { return; }; diff --git a/codex-rs/tui/src/chatwidget/session_flow.rs b/codex-rs/tui/src/chatwidget/session_flow.rs index 892e43f3ec..55499f015a 100644 --- a/codex-rs/tui/src/chatwidget/session_flow.rs +++ b/codex-rs/tui/src/chatwidget/session_flow.rs @@ -53,10 +53,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 + .replace_permission_profile_constraint_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 12ed07cf98..293acd6448 100644 --- a/codex-rs/tui/src/chatwidget/status_surfaces.rs +++ b/codex-rs/tui/src/chatwidget/status_surfaces.rs @@ -902,7 +902,11 @@ fn permissions_display(config: &Config) -> String { } let permission_profile = config.permissions.permission_profile(); - let summary = summarize_permission_profile(&permission_profile, config.cwd.as_path()); + 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/status/card.rs b/codex-rs/tui/src/status/card.rs index 0e19a15821..a3eb171ff7 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; @@ -257,6 +256,7 @@ impl StatusHistoryCell { ) -> (Self, StatusHistoryHandle) { let approval_policy = AskForApproval::from(config.permissions.approval_policy.value()); let permission_profile = config.permissions.permission_profile(); + let workspace_roots = config.permissions.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 a1a63bc4b9..e69f2da3c8 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 758d779781..cb892238d9 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 0719773aad..47e801bec4 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() + ) + ); + } }