From a28b32a8357b4bd67aa3ade5bb9a4e2ddb5cae1a Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Tue, 2 Jun 2026 16:26:36 -0700 Subject: [PATCH] config: express implicit sandbox defaults as permission profiles (#25926) ## Why `PermissionProfile` is becoming the default way to represent Codex permissions, but the implicit default behavior should stay the same for now: - trusted projects use `:workspace` - untrusted projects also use `:workspace` - roots without a trust decision use `:read-only` - unsandboxed Windows falls back to `:read-only` This keeps the existing sandbox semantics while making silent config defaults observable as built-in permission profiles instead of treating the legacy `SandboxPolicy` projection as the primary shape. ## What Changed - Refactored legacy sandbox derivation to resolve the configured sandbox mode once, then apply the implicit project fallback only when no sandbox mode was configured. - Preserved the existing trust-decision fallback: trusted and untrusted projects default to workspace-write where supported. - Added empty-config coverage asserting that an untrusted project resolves to the built-in active permission profile (`:workspace` outside unsandboxed Windows). ## Verification - `just fmt` - `just test -p codex-core 'config::'` - `just test -p codex-config` --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/25926). * __->__ #25926 --- codex-rs/config/src/config_toml.rs | 26 ++++------- codex-rs/core/src/config/config_tests.rs | 59 ++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 16 deletions(-) diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index 0311482cf..7c060cadb 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -728,29 +728,23 @@ impl ConfigToml { active_project: Option<&ProjectConfig>, permission_profile_constraint: Option<&crate::Constrained>, ) -> PermissionProfile { - let sandbox_mode_was_explicit = - sandbox_mode_override.is_some() || self.sandbox_mode.is_some(); - let resolved_sandbox_mode = sandbox_mode_override - .or(self.sandbox_mode) - .or(if sandbox_mode_was_explicit { - None - } else { + let configured_sandbox_mode = sandbox_mode_override.or(self.sandbox_mode); + let resolved_sandbox_mode = configured_sandbox_mode + .or_else(|| { // If no sandbox_mode is set but this directory has a trust decision, // default to workspace-write except on unsandboxed Windows where we // default to read-only. - active_project.and_then(|p| { - if p.is_trusted() || p.is_untrusted() { + active_project + .filter(|project| project.is_trusted() || project.is_untrusted()) + .map(|_| { if cfg!(target_os = "windows") && windows_sandbox_level == WindowsSandboxLevel::Disabled { - Some(SandboxMode::ReadOnly) + SandboxMode::ReadOnly } else { - Some(SandboxMode::WorkspaceWrite) + SandboxMode::WorkspaceWrite } - } else { - None - } - }) + }) }) .unwrap_or_default(); let effective_sandbox_mode = if cfg!(target_os = "windows") @@ -788,7 +782,7 @@ impl ConfigToml { }, SandboxMode::DangerFullAccess => PermissionProfile::Disabled, }; - if !sandbox_mode_was_explicit + if configured_sandbox_mode.is_none() && let Some(constraint) = permission_profile_constraint && let Err(err) = constraint.can_set(&permission_profile) { diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 192e449ff..8bc33bbb4 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -2548,6 +2548,65 @@ async fn empty_config_defaults_to_builtin_profile_for_trusted_project() -> std:: Ok(()) } +#[tokio::test] +async fn empty_config_defaults_to_builtin_profile_for_untrusted_project() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + let project_key = cwd.path().to_string_lossy().to_string(); + + let config = Config::load_from_base_config_with_overrides( + ConfigToml { + projects: Some(HashMap::from([( + project_key, + ProjectConfig { + trust_level: Some(TrustLevel::Untrusted), + }, + )])), + ..Default::default() + }, + ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + ..Default::default() + }, + codex_home.abs(), + ) + .await?; + + let policy = config.permissions.file_system_sandbox_policy(); + assert_eq!( + config + .permissions + .active_permission_profile() + .as_ref() + .map(|active| active.id.as_str()), + Some(if cfg!(target_os = "windows") { + BUILT_IN_PERMISSION_PROFILE_READ_ONLY + } else { + BUILT_IN_PERMISSION_PROFILE_WORKSPACE + }) + ); + assert!( + policy.can_read_path_with_cwd(cwd.path(), cwd.path()), + "expected untrusted project fallback to allow reads, policy: {policy:?}" + ); + if cfg!(target_os = "windows") { + assert!( + !policy.can_write_path_with_cwd(cwd.path(), cwd.path()), + "expected untrusted project fallback to stay read-only without Windows sandbox support, policy: {policy:?}" + ); + } else { + assert!( + policy.can_write_path_with_cwd(cwd.path(), cwd.path()), + "expected untrusted project fallback to use :workspace, policy: {policy:?}" + ); + assert!( + !policy.can_write_path_with_cwd(&cwd.path().join(".codex"), cwd.path()), + "expected :workspace metadata carveouts, policy: {policy:?}" + ); + } + Ok(()) +} + #[tokio::test] async fn implicit_builtin_workspace_profile_preserves_sandbox_workspace_write_settings() -> std::io::Result<()> {