diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index e8b921980f4b..d7b2195837aa 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -1668,14 +1668,23 @@ impl CodexMessageProcessor { ..Default::default() }; - // Persist windows sandbox feature. + // Persist Windows sandbox mode. // TODO: persist default config in general. let mut request_overrides = request_overrides.unwrap_or_default(); - if cfg!(windows) && self.config.features.enabled(Feature::WindowsSandbox) { - request_overrides.insert( - "features.experimental_windows_sandbox".to_string(), - serde_json::json!(true), - ); + if cfg!(windows) { + match WindowsSandboxLevel::from_config(&self.config) { + WindowsSandboxLevel::Elevated => { + request_overrides + .insert("windows.sandbox".to_string(), serde_json::json!("elevated")); + } + WindowsSandboxLevel::RestrictedToken => { + request_overrides.insert( + "windows.sandbox".to_string(), + serde_json::json!("unelevated"), + ); + } + WindowsSandboxLevel::Disabled => {} + } } let cloud_requirements = self.current_cloud_requirements(); @@ -2770,13 +2779,22 @@ impl CodexMessageProcessor { read_history_cwd_from_state_db(&self.config, source_thread_id, rollout_path.as_path()) .await; - // Persist windows sandbox feature. + // Persist Windows sandbox mode. let mut cli_overrides = cli_overrides.unwrap_or_default(); - if cfg!(windows) && self.config.features.enabled(Feature::WindowsSandbox) { - cli_overrides.insert( - "features.experimental_windows_sandbox".to_string(), - serde_json::json!(true), - ); + if cfg!(windows) { + match WindowsSandboxLevel::from_config(&self.config) { + WindowsSandboxLevel::Elevated => { + cli_overrides + .insert("windows.sandbox".to_string(), serde_json::json!("elevated")); + } + WindowsSandboxLevel::RestrictedToken => { + cli_overrides.insert( + "windows.sandbox".to_string(), + serde_json::json!("unelevated"), + ); + } + WindowsSandboxLevel::Disabled => {} + } } let request_overrides = if cli_overrides.is_empty() { None @@ -3681,13 +3699,24 @@ impl CodexMessageProcessor { include_apply_patch_tool, } = overrides; - // Persist windows sandbox feature. + // Persist Windows sandbox mode. let mut request_overrides = request_overrides.unwrap_or_default(); - if cfg!(windows) && self.config.features.enabled(Feature::WindowsSandbox) { - request_overrides.insert( - "features.experimental_windows_sandbox".to_string(), - serde_json::json!(true), - ); + if cfg!(windows) { + match WindowsSandboxLevel::from_config(&self.config) { + WindowsSandboxLevel::Elevated => { + request_overrides.insert( + "windows.sandbox".to_string(), + serde_json::json!("elevated"), + ); + } + WindowsSandboxLevel::RestrictedToken => { + request_overrides.insert( + "windows.sandbox".to_string(), + serde_json::json!("unelevated"), + ); + } + WindowsSandboxLevel::Disabled => {} + } } let typesafe_overrides = ConfigOverrides { @@ -3860,13 +3889,24 @@ impl CodexMessageProcessor { include_apply_patch_tool, } = overrides; - // Persist windows sandbox feature. + // Persist Windows sandbox mode. let mut cli_overrides = cli_overrides.unwrap_or_default(); - if cfg!(windows) && self.config.features.enabled(Feature::WindowsSandbox) { - cli_overrides.insert( - "features.experimental_windows_sandbox".to_string(), - serde_json::json!(true), - ); + if cfg!(windows) { + match WindowsSandboxLevel::from_config(&self.config) { + WindowsSandboxLevel::Elevated => { + cli_overrides.insert( + "windows.sandbox".to_string(), + serde_json::json!("elevated"), + ); + } + WindowsSandboxLevel::RestrictedToken => { + cli_overrides.insert( + "windows.sandbox".to_string(), + serde_json::json!("unelevated"), + ); + } + WindowsSandboxLevel::Disabled => {} + } } let request_overrides = if cli_overrides.is_empty() { None diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 2a45bf48be8f..ff5cbcdf7d4b 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -331,6 +331,14 @@ }, "web_search": { "$ref": "#/definitions/WebSearchMode" + }, + "windows": { + "allOf": [ + { + "$ref": "#/definitions/WindowsToml" + } + ], + "default": null } }, "type": "object" @@ -1144,6 +1152,22 @@ ], "type": "string" }, + "WindowsSandboxModeToml": { + "enum": [ + "elevated", + "unelevated" + ], + "type": "string" + }, + "WindowsToml": { + "additionalProperties": false, + "properties": { + "sandbox": { + "$ref": "#/definitions/WindowsSandboxModeToml" + } + }, + "type": "object" + }, "WireApi": { "description": "Wire protocol that the provider speaks.", "oneOf": [ @@ -1636,6 +1660,15 @@ ], "description": "Controls the web search tool mode: disabled, cached, or live." }, + "windows": { + "allOf": [ + { + "$ref": "#/definitions/WindowsToml" + } + ], + "default": null, + "description": "Windows-specific configuration." + }, "windows_wsl_setup_acknowledged": { "description": "Tracks whether the Windows onboarding screen has been acknowledged.", "type": "boolean" diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index 3b9b984b2698..c9dbd0ac3003 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -819,6 +819,44 @@ impl ConfigEditsBuilder { self } + pub fn set_windows_sandbox_mode(mut self, mode: &str) -> Self { + let segments = if let Some(profile) = self.profile.as_ref() { + vec![ + "profiles".to_string(), + profile.clone(), + "windows".to_string(), + "sandbox".to_string(), + ] + } else { + vec!["windows".to_string(), "sandbox".to_string()] + }; + self.edits.push(ConfigEdit::SetPath { + segments, + value: value(mode), + }); + self + } + + pub fn clear_legacy_windows_sandbox_keys(mut self) -> Self { + for key in [ + "experimental_windows_sandbox", + "elevated_windows_sandbox", + "enable_experimental_windows_sandbox", + ] { + let mut segments = vec!["features".to_string(), key.to_string()]; + if let Some(profile) = self.profile.as_ref() { + segments = vec![ + "profiles".to_string(), + profile.clone(), + "features".to_string(), + key.to_string(), + ]; + } + self.edits.push(ConfigEdit::ClearPath { segments }); + } + self + } + pub fn with_edits(mut self, edits: I) -> Self where I: IntoIterator, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index a923b0ef7849..516b7ef8a4b8 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -19,6 +19,8 @@ use crate::config::types::ShellEnvironmentPolicyToml; use crate::config::types::SkillsConfig; use crate::config::types::Tui; use crate::config::types::UriBasedFileOpener; +use crate::config::types::WindowsSandboxModeToml; +use crate::config::types::WindowsToml; use crate::config_loader::CloudRequirementsLoader; use crate::config_loader::ConfigLayerStack; use crate::config_loader::ConfigRequirements; @@ -45,6 +47,7 @@ use crate::project_doc::LOCAL_PROJECT_DOC_FILENAME; use crate::protocol::AskForApproval; use crate::protocol::SandboxPolicy; use crate::windows_sandbox::WindowsSandboxLevelExt; +use crate::windows_sandbox::resolve_windows_sandbox_mode; use codex_app_server_protocol::Tools; use codex_app_server_protocol::UserSavedConfig; use codex_protocol::config_types::AltScreenMode; @@ -162,10 +165,6 @@ pub struct Config { /// for either of approval_policy or sandbox_mode. pub did_user_set_custom_approval_policy_or_sandbox_mode: bool, - /// On Windows, indicates that a previously configured workspace-write sandbox - /// was coerced to read-only because native auto mode is unsupported. - pub forced_auto_mode_downgraded_on_windows: bool, - pub shell_environment_policy: ShellEnvironmentPolicy, /// When `true`, `AgentReasoning` events emitted by the backend will be @@ -343,6 +342,10 @@ pub struct Config { /// Settings for ghost snapshots (used for undo). pub ghost_snapshot: GhostSnapshotConfig, + /// Effective Windows sandbox mode derived from `[windows].sandbox` or + /// legacy feature keys. + pub windows_sandbox_mode: Option, + /// Centralized feature flags; source of truth for feature gating. pub features: Features, @@ -1038,6 +1041,10 @@ pub struct ConfigToml { /// OTEL configuration. pub otel: Option, + /// Windows-specific configuration. + #[serde(default)] + pub windows: Option, + /// Tracks whether the Windows onboarding screen has been acknowledged. pub windows_wsl_setup_acknowledged: Option, @@ -1140,12 +1147,6 @@ pub struct GhostSnapshotToml { pub disable_warnings: Option, } -#[derive(Debug, PartialEq, Eq)] -pub struct SandboxPolicyResolution { - pub policy: SandboxPolicy, - pub forced_auto_mode_downgraded_on_windows: bool, -} - impl ConfigToml { /// Derive the effective sandbox policy from the configuration. fn derive_sandbox_policy( @@ -1155,7 +1156,7 @@ impl ConfigToml { windows_sandbox_level: WindowsSandboxLevel, resolved_cwd: &Path, sandbox_policy_constraint: Option<&Constrained>, - ) -> SandboxPolicyResolution { + ) -> SandboxPolicy { let sandbox_mode_was_explicit = sandbox_mode_override.is_some() || profile_sandbox_mode.is_some() || self.sandbox_mode.is_some(); @@ -1163,10 +1164,19 @@ impl ConfigToml { .or(profile_sandbox_mode) .or(self.sandbox_mode) .or_else(|| { - // if no sandbox_mode is set, but user has marked directory as trusted or untrusted, use WorkspaceWrite + // 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. self.get_active_project(resolved_cwd).and_then(|p| { if p.is_trusted() || p.is_untrusted() { - Some(SandboxMode::WorkspaceWrite) + if cfg!(target_os = "windows") + && windows_sandbox_level + == codex_protocol::config_types::WindowsSandboxLevel::Disabled + { + Some(SandboxMode::ReadOnly) + } else { + Some(SandboxMode::WorkspaceWrite) + } } else { None } @@ -1191,8 +1201,7 @@ impl ConfigToml { }, SandboxMode::DangerFullAccess => SandboxPolicy::DangerFullAccess, }; - let mut forced_auto_mode_downgraded_on_windows = false; - let mut downgrade_workspace_write_if_unsupported = |policy: &mut SandboxPolicy| { + let downgrade_workspace_write_if_unsupported = |policy: &mut SandboxPolicy| { if cfg!(target_os = "windows") // If the experimental Windows sandbox is enabled, do not force a downgrade. && windows_sandbox_level @@ -1200,7 +1209,6 @@ impl ConfigToml { && matches!(&*policy, SandboxPolicy::WorkspaceWrite { .. }) { *policy = SandboxPolicy::new_read_only_policy(); - forced_auto_mode_downgraded_on_windows = true; } }; if matches!(resolved_sandbox_mode, SandboxMode::WorkspaceWrite) { @@ -1217,10 +1225,7 @@ impl ConfigToml { sandbox_policy = constraint.get().clone(); downgrade_workspace_write_if_unsupported(&mut sandbox_policy); } - SandboxPolicyResolution { - policy: sandbox_policy, - forced_auto_mode_downgraded_on_windows, - } + sandbox_policy } /// Resolves the cwd to an existing project, or returns None if ConfigToml @@ -1439,6 +1444,7 @@ impl Config { }; let features = Features::from_config(&cfg, &config_profile, feature_overrides); + let windows_sandbox_mode = resolve_windows_sandbox_mode(&cfg, &config_profile); let resolved_cwd = { use std::env; @@ -1468,11 +1474,12 @@ impl Config { || config_profile.sandbox_mode.is_some() || cfg.sandbox_mode.is_some(); - let windows_sandbox_level = WindowsSandboxLevel::from_features(&features); - let SandboxPolicyResolution { - policy: mut sandbox_policy, - forced_auto_mode_downgraded_on_windows, - } = cfg.derive_sandbox_policy( + let windows_sandbox_level = match windows_sandbox_mode { + Some(WindowsSandboxModeToml::Elevated) => WindowsSandboxLevel::Elevated, + Some(WindowsSandboxModeToml::Unelevated) => WindowsSandboxLevel::RestrictedToken, + None => WindowsSandboxLevel::from_features(&features), + }; + let mut sandbox_policy = cfg.derive_sandbox_policy( sandbox_mode, config_profile.sandbox_mode, windows_sandbox_level, @@ -1712,7 +1719,6 @@ impl Config { enforce_residency: enforce_residency.value, network, did_user_set_custom_approval_policy_or_sandbox_mode, - forced_auto_mode_downgraded_on_windows, shell_environment_policy, notify: cfg.notify, user_instructions, @@ -1777,6 +1783,7 @@ impl Config { web_search_mode: constrained_web_search_mode.value, use_experimental_unified_exec_tool, ghost_snapshot, + windows_sandbox_mode, features, suppress_unstable_features_warning: cfg .suppress_unstable_features_warning @@ -1882,21 +1889,29 @@ impl Config { } pub fn set_windows_sandbox_enabled(&mut self, value: bool) { - if value { - self.features.enable(Feature::WindowsSandbox); - self.forced_auto_mode_downgraded_on_windows = false; + self.windows_sandbox_mode = if value { + Some(WindowsSandboxModeToml::Unelevated) + } else if matches!( + self.windows_sandbox_mode, + Some(WindowsSandboxModeToml::Unelevated) + ) { + None } else { - self.features.disable(Feature::WindowsSandbox); - } + self.windows_sandbox_mode + }; } pub fn set_windows_elevated_sandbox_enabled(&mut self, value: bool) { - if value { - self.features.enable(Feature::WindowsSandboxElevated); - self.forced_auto_mode_downgraded_on_windows = false; + self.windows_sandbox_mode = if value { + Some(WindowsSandboxModeToml::Elevated) + } else if matches!( + self.windows_sandbox_mode, + Some(WindowsSandboxModeToml::Elevated) + ) { + None } else { - self.features.disable(Feature::WindowsSandboxElevated); - } + self.windows_sandbox_mode + }; } } @@ -2077,13 +2092,7 @@ network_access = false # This should be ignored. &PathBuf::from("/tmp/test"), None, ); - assert_eq!( - resolution, - SandboxPolicyResolution { - policy: SandboxPolicy::DangerFullAccess, - forced_auto_mode_downgraded_on_windows: false, - } - ); + assert_eq!(resolution, SandboxPolicy::DangerFullAccess); let sandbox_read_only = r#" sandbox_mode = "read-only" @@ -2102,13 +2111,7 @@ network_access = true # This should be ignored. &PathBuf::from("/tmp/test"), None, ); - assert_eq!( - resolution, - SandboxPolicyResolution { - policy: SandboxPolicy::ReadOnly, - forced_auto_mode_downgraded_on_windows: false, - } - ); + assert_eq!(resolution, SandboxPolicy::ReadOnly); let writable_root = test_absolute_path("/my/workspace"); let sandbox_workspace_write = format!( @@ -2136,24 +2139,15 @@ exclude_slash_tmp = true None, ); if cfg!(target_os = "windows") { - assert_eq!( - resolution, - SandboxPolicyResolution { - policy: SandboxPolicy::ReadOnly, - forced_auto_mode_downgraded_on_windows: true, - } - ); + assert_eq!(resolution, SandboxPolicy::ReadOnly); } else { assert_eq!( resolution, - SandboxPolicyResolution { - policy: SandboxPolicy::WorkspaceWrite { - writable_roots: vec![writable_root.clone()], - network_access: false, - exclude_tmpdir_env_var: true, - exclude_slash_tmp: true, - }, - forced_auto_mode_downgraded_on_windows: false, + SandboxPolicy::WorkspaceWrite { + writable_roots: vec![writable_root.clone()], + network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, } ); } @@ -2186,24 +2180,15 @@ trust_level = "trusted" None, ); if cfg!(target_os = "windows") { - assert_eq!( - resolution, - SandboxPolicyResolution { - policy: SandboxPolicy::ReadOnly, - forced_auto_mode_downgraded_on_windows: true, - } - ); + assert_eq!(resolution, SandboxPolicy::ReadOnly); } else { assert_eq!( resolution, - SandboxPolicyResolution { - policy: SandboxPolicy::WorkspaceWrite { - writable_roots: vec![writable_root], - network_access: false, - exclude_tmpdir_env_var: true, - exclude_slash_tmp: true, - }, - forced_auto_mode_downgraded_on_windows: false, + SandboxPolicy::WorkspaceWrite { + writable_roots: vec![writable_root], + network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, } ); } @@ -2366,10 +2351,6 @@ trust_level = "trusted" let expected_backend = AbsolutePathBuf::try_from(backend).unwrap(); if cfg!(target_os = "windows") { - assert!( - config.forced_auto_mode_downgraded_on_windows, - "expected workspace-write request to be downgraded on Windows" - ); match config.sandbox_policy.get() { &SandboxPolicy::ReadOnly => {} other => panic!("expected read-only policy on Windows, got {other:?}"), @@ -2702,13 +2683,11 @@ profile = "project" config.sandbox_policy.get(), SandboxPolicy::ReadOnly )); - assert!(config.forced_auto_mode_downgraded_on_windows); } else { assert!(matches!( config.sandbox_policy.get(), SandboxPolicy::WorkspaceWrite { .. } )); - assert!(!config.forced_auto_mode_downgraded_on_windows); } Ok(()) @@ -4027,7 +4006,6 @@ model_verbosity = "high" enforce_residency: Constrained::allow_any(None), network: None, did_user_set_custom_approval_policy_or_sandbox_mode: true, - forced_auto_mode_downgraded_on_windows: false, shell_environment_policy: ShellEnvironmentPolicy::default(), user_instructions: None, notify: None, @@ -4070,6 +4048,7 @@ model_verbosity = "high" suppress_unstable_features_warning: false, active_profile: Some("o3".to_string()), active_project: ProjectConfig { trust_level: None }, + windows_sandbox_mode: None, windows_wsl_setup_acknowledged: false, notices: Default::default(), check_for_update_on_startup: true, @@ -4133,7 +4112,6 @@ model_verbosity = "high" enforce_residency: Constrained::allow_any(None), network: None, did_user_set_custom_approval_policy_or_sandbox_mode: true, - forced_auto_mode_downgraded_on_windows: false, shell_environment_policy: ShellEnvironmentPolicy::default(), user_instructions: None, notify: None, @@ -4176,6 +4154,7 @@ model_verbosity = "high" suppress_unstable_features_warning: false, active_profile: Some("gpt3".to_string()), active_project: ProjectConfig { trust_level: None }, + windows_sandbox_mode: None, windows_wsl_setup_acknowledged: false, notices: Default::default(), check_for_update_on_startup: true, @@ -4237,7 +4216,6 @@ model_verbosity = "high" enforce_residency: Constrained::allow_any(None), network: None, did_user_set_custom_approval_policy_or_sandbox_mode: true, - forced_auto_mode_downgraded_on_windows: false, shell_environment_policy: ShellEnvironmentPolicy::default(), user_instructions: None, notify: None, @@ -4280,6 +4258,7 @@ model_verbosity = "high" suppress_unstable_features_warning: false, active_profile: Some("zdr".to_string()), active_project: ProjectConfig { trust_level: None }, + windows_sandbox_mode: None, windows_wsl_setup_acknowledged: false, notices: Default::default(), check_for_update_on_startup: true, @@ -4327,7 +4306,6 @@ model_verbosity = "high" enforce_residency: Constrained::allow_any(None), network: None, did_user_set_custom_approval_policy_or_sandbox_mode: true, - forced_auto_mode_downgraded_on_windows: false, shell_environment_policy: ShellEnvironmentPolicy::default(), user_instructions: None, notify: None, @@ -4370,6 +4348,7 @@ model_verbosity = "high" suppress_unstable_features_warning: false, active_profile: Some("gpt5".to_string()), active_project: ProjectConfig { trust_level: None }, + windows_sandbox_mode: None, windows_wsl_setup_acknowledged: false, notices: Default::default(), check_for_update_on_startup: true, @@ -4672,15 +4651,13 @@ trust_level = "untrusted" // Verify that untrusted projects get WorkspaceWrite (or ReadOnly on Windows due to downgrade) if cfg!(target_os = "windows") { assert!( - matches!(resolution.policy, SandboxPolicy::ReadOnly), - "Expected ReadOnly on Windows, got {:?}", - resolution.policy + matches!(resolution, SandboxPolicy::ReadOnly), + "Expected ReadOnly on Windows, got {resolution:?}" ); } else { assert!( - matches!(resolution.policy, SandboxPolicy::WorkspaceWrite { .. }), - "Expected WorkspaceWrite for untrusted project, got {:?}", - resolution.policy + matches!(resolution, SandboxPolicy::WorkspaceWrite { .. }), + "Expected WorkspaceWrite for untrusted project, got {resolution:?}" ); } @@ -4723,7 +4700,7 @@ trust_level = "untrusted" Some(&constrained), ); - assert_eq!(resolution.policy, SandboxPolicy::DangerFullAccess); + assert_eq!(resolution, SandboxPolicy::DangerFullAccess); Ok(()) } @@ -4765,21 +4742,9 @@ trust_level = "untrusted" ); if cfg!(target_os = "windows") { - assert_eq!( - resolution, - SandboxPolicyResolution { - policy: SandboxPolicy::ReadOnly, - forced_auto_mode_downgraded_on_windows: true, - } - ); + assert_eq!(resolution, SandboxPolicy::ReadOnly); } else { - assert_eq!( - resolution, - SandboxPolicyResolution { - policy: SandboxPolicy::new_workspace_write_policy(), - forced_auto_mode_downgraded_on_windows: false, - } - ); + assert_eq!(resolution, SandboxPolicy::new_workspace_write_policy()); } Ok(()) } diff --git a/codex-rs/core/src/config/profile.rs b/codex-rs/core/src/config/profile.rs index e2575fd868bb..5ecad5c82251 100644 --- a/codex-rs/core/src/config/profile.rs +++ b/codex-rs/core/src/config/profile.rs @@ -4,6 +4,7 @@ use serde::Deserialize; use serde::Serialize; use crate::config::types::Personality; +use crate::config::types::WindowsToml; use crate::protocol::AskForApproval; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::SandboxMode; @@ -40,6 +41,8 @@ pub struct ConfigProfile { pub tools_view_image: Option, pub web_search: Option, pub analytics: Option, + #[serde(default)] + pub windows: Option, /// Optional feature toggles scoped to this profile. #[serde(default)] // Injects known feature keys into the schema and forbids unknown keys. diff --git a/codex-rs/core/src/config/types.rs b/codex-rs/core/src/config/types.rs index 32553d5a19c3..5f54c1df28d2 100644 --- a/codex-rs/core/src/config/types.rs +++ b/codex-rs/core/src/config/types.rs @@ -24,6 +24,19 @@ use serde::de::Error as SerdeError; pub const DEFAULT_OTEL_ENVIRONMENT: &str = "dev"; +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub enum WindowsSandboxModeToml { + Elevated, + Unelevated, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct WindowsToml { + pub sandbox: Option, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum McpServerDisabledReason { Unknown, diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index 3f6756667a43..25a07f579860 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -486,13 +486,13 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::WindowsSandbox, key: "experimental_windows_sandbox", - stage: Stage::UnderDevelopment, + stage: Stage::Removed, default_enabled: false, }, FeatureSpec { id: Feature::WindowsSandboxElevated, key: "elevated_windows_sandbox", - stage: Stage::UnderDevelopment, + stage: Stage::Removed, default_enabled: false, }, FeatureSpec { diff --git a/codex-rs/core/src/windows_sandbox.rs b/codex-rs/core/src/windows_sandbox.rs index 9a34a9f90f78..180cce6c2646 100644 --- a/codex-rs/core/src/windows_sandbox.rs +++ b/codex-rs/core/src/windows_sandbox.rs @@ -1,8 +1,13 @@ use crate::config::Config; +use crate::config::ConfigToml; +use crate::config::profile::ConfigProfile; +use crate::config::types::WindowsSandboxModeToml; use crate::features::Feature; use crate::features::Features; +use crate::features::FeaturesToml; use crate::protocol::SandboxPolicy; use codex_protocol::config_types::WindowsSandboxLevel; +use std::collections::BTreeMap; use std::collections::HashMap; use std::path::Path; @@ -19,7 +24,11 @@ pub trait WindowsSandboxLevelExt { impl WindowsSandboxLevelExt for WindowsSandboxLevel { fn from_config(config: &Config) -> WindowsSandboxLevel { - Self::from_features(&config.features) + match config.windows_sandbox_mode { + Some(WindowsSandboxModeToml::Elevated) => WindowsSandboxLevel::Elevated, + Some(WindowsSandboxModeToml::Unelevated) => WindowsSandboxLevel::RestrictedToken, + None => Self::from_features(&config.features), + } } fn from_features(features: &Features) -> WindowsSandboxLevel { @@ -42,6 +51,66 @@ pub fn windows_sandbox_level_from_features(features: &Features) -> WindowsSandbo WindowsSandboxLevel::from_features(features) } +pub fn resolve_windows_sandbox_mode( + cfg: &ConfigToml, + profile: &ConfigProfile, +) -> Option { + if let Some(mode) = legacy_windows_sandbox_mode(profile.features.as_ref()) { + return Some(mode); + } + if legacy_windows_sandbox_keys_present(profile.features.as_ref()) { + return None; + } + + profile + .windows + .as_ref() + .and_then(|windows| windows.sandbox) + .or_else(|| cfg.windows.as_ref().and_then(|windows| windows.sandbox)) + .or_else(|| legacy_windows_sandbox_mode(cfg.features.as_ref())) +} + +fn legacy_windows_sandbox_keys_present(features: Option<&FeaturesToml>) -> bool { + let Some(entries) = features.map(|features| &features.entries) else { + return false; + }; + entries.contains_key(Feature::WindowsSandboxElevated.key()) + || entries.contains_key(Feature::WindowsSandbox.key()) + || entries.contains_key("enable_experimental_windows_sandbox") +} + +pub fn legacy_windows_sandbox_mode( + features: Option<&FeaturesToml>, +) -> Option { + let entries = features.map(|features| &features.entries)?; + legacy_windows_sandbox_mode_from_entries(entries) +} + +pub fn legacy_windows_sandbox_mode_from_entries( + entries: &BTreeMap, +) -> Option { + if entries + .get(Feature::WindowsSandboxElevated.key()) + .copied() + .unwrap_or(false) + { + return Some(WindowsSandboxModeToml::Elevated); + } + if entries + .get(Feature::WindowsSandbox.key()) + .copied() + .unwrap_or(false) + || entries + .get("enable_experimental_windows_sandbox") + .copied() + .unwrap_or(false) + { + Some(WindowsSandboxModeToml::Unelevated) + } else { + None + } +} + #[cfg(target_os = "windows")] pub fn sandbox_setup_is_complete(codex_home: &Path) -> bool { codex_windows_sandbox::sandbox_setup_is_complete(codex_home) @@ -114,11 +183,42 @@ pub fn run_elevated_setup( anyhow::bail!("elevated Windows sandbox setup is only supported on Windows") } +#[cfg(target_os = "windows")] +pub fn run_legacy_setup_preflight( + policy: &SandboxPolicy, + policy_cwd: &Path, + command_cwd: &Path, + env_map: &HashMap, + codex_home: &Path, +) -> anyhow::Result<()> { + codex_windows_sandbox::run_windows_sandbox_legacy_preflight( + policy, + policy_cwd, + codex_home, + command_cwd, + env_map, + ) +} + +#[cfg(not(target_os = "windows"))] +pub fn run_legacy_setup_preflight( + _policy: &SandboxPolicy, + _policy_cwd: &Path, + _command_cwd: &Path, + _env_map: &HashMap, + _codex_home: &Path, +) -> anyhow::Result<()> { + anyhow::bail!("legacy Windows sandbox setup is only supported on Windows") +} + #[cfg(test)] mod tests { use super::*; + use crate::config::types::WindowsToml; use crate::features::Features; + use crate::features::FeaturesToml; use pretty_assertions::assert_eq; + use std::collections::BTreeMap; #[test] fn elevated_flag_works_by_itself() { @@ -163,4 +263,86 @@ mod tests { WindowsSandboxLevel::Elevated ); } + + #[test] + fn legacy_mode_prefers_elevated() { + let mut entries = BTreeMap::new(); + entries.insert("experimental_windows_sandbox".to_string(), true); + entries.insert("elevated_windows_sandbox".to_string(), true); + + assert_eq!( + legacy_windows_sandbox_mode_from_entries(&entries), + Some(WindowsSandboxModeToml::Elevated) + ); + } + + #[test] + fn legacy_mode_supports_alias_key() { + let mut entries = BTreeMap::new(); + entries.insert("enable_experimental_windows_sandbox".to_string(), true); + + assert_eq!( + legacy_windows_sandbox_mode_from_entries(&entries), + Some(WindowsSandboxModeToml::Unelevated) + ); + } + + #[test] + fn resolve_windows_sandbox_mode_prefers_profile_windows() { + let cfg = ConfigToml { + windows: Some(WindowsToml { + sandbox: Some(WindowsSandboxModeToml::Unelevated), + }), + ..Default::default() + }; + let profile = ConfigProfile { + windows: Some(WindowsToml { + sandbox: Some(WindowsSandboxModeToml::Elevated), + }), + ..Default::default() + }; + + assert_eq!( + resolve_windows_sandbox_mode(&cfg, &profile), + Some(WindowsSandboxModeToml::Elevated) + ); + } + + #[test] + fn resolve_windows_sandbox_mode_falls_back_to_legacy_keys() { + let mut entries = BTreeMap::new(); + entries.insert("experimental_windows_sandbox".to_string(), true); + let cfg = ConfigToml { + features: Some(FeaturesToml { entries }), + ..Default::default() + }; + + assert_eq!( + resolve_windows_sandbox_mode(&cfg, &ConfigProfile::default()), + Some(WindowsSandboxModeToml::Unelevated) + ); + } + + #[test] + fn resolve_windows_sandbox_mode_profile_legacy_false_blocks_top_level_legacy_true() { + let mut profile_entries = BTreeMap::new(); + profile_entries.insert("experimental_windows_sandbox".to_string(), false); + let profile = ConfigProfile { + features: Some(FeaturesToml { + entries: profile_entries, + }), + ..Default::default() + }; + + let mut cfg_entries = BTreeMap::new(); + cfg_entries.insert("experimental_windows_sandbox".to_string(), true); + let cfg = ConfigToml { + features: Some(FeaturesToml { + entries: cfg_entries, + }), + ..Default::default() + }; + + assert_eq!(resolve_windows_sandbox_mode(&cfg, &profile), None); + } } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 1ef3475e4482..6fe4c1f783f7 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -944,6 +944,7 @@ impl App { session_selection: SessionSelection, feedback: codex_feedback::CodexFeedback, is_first_run: bool, + should_prompt_windows_sandbox_nux_at_startup: bool, ) -> Result { use tokio_stream::StreamExt; let (app_event_tx, mut app_event_rx) = unbounded_channel(); @@ -1107,7 +1108,8 @@ impl App { } }; - chat_widget.maybe_prompt_windows_sandbox_enable(); + chat_widget + .maybe_prompt_windows_sandbox_enable(should_prompt_windows_sandbox_nux_at_startup); let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone()); #[cfg(not(debug_assertions))] @@ -1783,6 +1785,42 @@ impl App { let _ = preset; } } + AppEvent::BeginWindowsSandboxLegacySetup { preset } => { + #[cfg(target_os = "windows")] + { + let policy = preset.sandbox.clone(); + let policy_cwd = self.config.cwd.clone(); + let command_cwd = policy_cwd.clone(); + let env_map: std::collections::HashMap = + std::env::vars().collect(); + let codex_home = self.config.codex_home.clone(); + let tx = self.app_event_tx.clone(); + + self.chat_widget.show_windows_sandbox_setup_status(); + tokio::task::spawn_blocking(move || { + if let Err(err) = codex_core::windows_sandbox::run_legacy_setup_preflight( + &policy, + policy_cwd.as_path(), + command_cwd.as_path(), + &env_map, + codex_home.as_path(), + ) { + tracing::warn!( + error = %err, + "failed to preflight non-admin Windows sandbox setup" + ); + } + tx.send(AppEvent::EnableWindowsSandboxForAgentMode { + preset, + mode: WindowsSandboxEnableMode::Legacy, + }); + }); + } + #[cfg(not(target_os = "windows"))] + { + let _ = preset; + } + } AppEvent::EnableWindowsSandboxForAgentMode { preset, mode } => { #[cfg(target_os = "windows")] { @@ -1795,33 +1833,26 @@ impl App { ); } let profile = self.active_profile.as_deref(); - let feature_key = Feature::WindowsSandbox.key(); - let elevated_key = Feature::WindowsSandboxElevated.key(); let elevated_enabled = matches!(mode, WindowsSandboxEnableMode::Elevated); - let mut builder = - ConfigEditsBuilder::new(&self.config.codex_home).with_profile(profile); - if elevated_enabled { - builder = builder.set_feature_enabled(elevated_key, true); - } else { - builder = builder - .set_feature_enabled(feature_key, true) - .set_feature_enabled(elevated_key, false); - } + let builder = ConfigEditsBuilder::new(&self.config.codex_home) + .with_profile(profile) + .set_windows_sandbox_mode(if elevated_enabled { + "elevated" + } else { + "unelevated" + }) + .clear_legacy_windows_sandbox_keys(); match builder.apply().await { Ok(()) => { if elevated_enabled { + self.config.set_windows_sandbox_enabled(false); self.config.set_windows_elevated_sandbox_enabled(true); - self.chat_widget - .set_feature_enabled(Feature::WindowsSandboxElevated, true); } else { self.config.set_windows_sandbox_enabled(true); self.config.set_windows_elevated_sandbox_enabled(false); - self.chat_widget - .set_feature_enabled(Feature::WindowsSandbox, true); - self.chat_widget - .set_feature_enabled(Feature::WindowsSandboxElevated, false); } - self.chat_widget.clear_forced_auto_mode_downgrade(); + self.chat_widget + .set_windows_sandbox_mode(self.config.windows_sandbox_mode); let windows_sandbox_level = WindowsSandboxLevel::from_config(&self.config); if let Some((sample_paths, extra_count, failed_scan)) = @@ -1866,17 +1897,15 @@ impl App { .send(AppEvent::UpdateAskForApprovalPolicy(preset.approval)); self.app_event_tx .send(AppEvent::UpdateSandboxPolicy(preset.sandbox.clone())); - self.chat_widget.add_info_message( - match mode { - WindowsSandboxEnableMode::Elevated => { - "Enabled elevated agent sandbox.".to_string() - } - WindowsSandboxEnableMode::Legacy => { - "Enabled non-elevated agent sandbox.".to_string() - } - }, - None, - ); + let _ = mode; + self.chat_widget.add_plain_history_lines(vec![ + Line::from(vec!["• ".dim(), "Sandbox ready".into()]), + Line::from(vec![ + " ".into(), + "Codex can now safely edit files and execute commands in your computer" + .dark_gray(), + ]), + ]); } } Err(err) => { @@ -1984,21 +2013,17 @@ impl App { codex_core::protocol::SandboxPolicy::WorkspaceWrite { .. } | codex_core::protocol::SandboxPolicy::ReadOnly ); + #[cfg(target_os = "windows")] + let policy_for_chat = policy.clone(); - if let Err(err) = self.config.sandbox_policy.set(policy.clone()) { + if let Err(err) = self.config.sandbox_policy.set(policy) { tracing::warn!(%err, "failed to set sandbox policy on app config"); self.chat_widget .add_error_message(format!("Failed to set sandbox policy: {err}")); return Ok(AppRunControl::Continue); } #[cfg(target_os = "windows")] - if !matches!(&policy, codex_core::protocol::SandboxPolicy::ReadOnly) - || WindowsSandboxLevel::from_config(&self.config) - != WindowsSandboxLevel::Disabled - { - self.config.forced_auto_mode_downgraded_on_windows = false; - } - if let Err(err) = self.chat_widget.set_sandbox_policy(policy) { + if let Err(err) = self.chat_widget.set_sandbox_policy(policy_for_chat) { tracing::warn!(%err, "failed to set sandbox policy on chat config"); self.chat_widget .add_error_message(format!("Failed to set sandbox policy: {err}")); diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 0f456f45712f..d2faca49c28a 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -203,6 +203,12 @@ pub(crate) enum AppEvent { preset: ApprovalPreset, }, + /// Begin the non-elevated Windows sandbox setup flow. + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + BeginWindowsSandboxLegacySetup { + preset: ApprovalPreset, + }, + /// Enable the Windows sandbox feature and switch to Agent mode. #[cfg_attr(not(target_os = "windows"), allow(dead_code))] EnableWindowsSandboxForAgentMode { diff --git a/codex-rs/tui/src/bottom_pane/list_selection_view.rs b/codex-rs/tui/src/bottom_pane/list_selection_view.rs index 00baf9c56cbe..14f94ca6f495 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -821,7 +821,7 @@ mod tests { }]; let footer_note = Line::from(vec![ "Note: ".dim(), - "Use /setup-elevated-sandbox".cyan(), + "Use /setup-default-sandbox".cyan(), " to allow network access.".dim(), ]); let view = ListSelectionView::new( diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_footer_note_wraps.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_footer_note_wraps.snap index ca571ae0ea42..800fcf75c437 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_footer_note_wraps.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_footer_note_wraps.snap @@ -1,6 +1,6 @@ --- source: tui/src/bottom_pane/list_selection_view.rs -assertion_line: 683 +assertion_line: 828 expression: "render_lines_with_width(&view, 40)" --- @@ -9,6 +9,6 @@ expression: "render_lines_with_width(&view, 40)" › 1. Read Only (current) Codex can read files - Note: Use /setup-elevated-sandbox to + Note: Use /setup-default-sandbox to allow network access. Press enter to confirm or esc to go ba diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index e507a39a4dd7..295d9ebdd765 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -49,6 +49,7 @@ use codex_chatgpt::connectors; use codex_core::config::Config; use codex_core::config::ConstraintResult; use codex_core::config::types::Notifications; +use codex_core::config::types::WindowsSandboxModeToml; use codex_core::config_loader::ConfigLayerStackOrdering; use codex_core::features::FEATURES; use codex_core::features::Feature; @@ -5256,7 +5257,7 @@ impl ChatWidget { let is_current = Self::preset_matches_current(current_approval, current_sandbox, &preset); let name = if preset.id == "auto" && windows_degraded_sandbox_enabled { - "Default (non-elevated sandbox)".to_string() + "Default (non-admin sandbox)".to_string() } else { preset.label.to_string() }; @@ -5340,8 +5341,8 @@ impl ChatWidget { let footer_note = show_elevate_sandbox_hint.then(|| { vec![ - "The non-elevated sandbox protects your files and prevents network access under most circumstances. However, it carries greater risk if prompt injected. To upgrade to the elevated sandbox, run ".dim(), - "/setup-elevated-sandbox".cyan(), + "The non-admin sandbox protects your files and prevents network access under most circumstances. However, it carries greater risk if prompt injected. To upgrade to the default sandbox, run ".dim(), + "/setup-default-sandbox".cyan(), ".".dim(), ] .into() @@ -5684,59 +5685,24 @@ impl ChatWidget { return; } - let current_approval = self.config.approval_policy.value(); - let current_sandbox = self.config.sandbox_policy.get(); - let presets = builtin_approval_presets(); - let stay_full_access = presets - .iter() - .find(|preset| preset.id == "full-access") - .is_some_and(|preset| { - Self::preset_matches_current(current_approval, current_sandbox, preset) - }); self.otel_manager .counter("codex.windows_sandbox.elevated_prompt_shown", 1, &[]); let mut header = ColumnRenderable::new(); header.push(*Box::new( Paragraph::new(vec![ - line!["Set Up Agent Sandbox".bold()], - line![""], - line!["Agent mode uses an experimental Windows sandbox that protects your files and prevents network access by default."], - line!["Learn more: https://developers.openai.com/codex/windows"], + line!["Set up the Codex agent sandbox to protect your files and control network access. Learn more "], ]) .wrap(Wrap { trim: false }), )); - let stay_label = if stay_full_access { - "Stay in Agent Full Access".to_string() - } else { - "Stay in Read-Only".to_string() - }; - let mut stay_actions = if stay_full_access { - Vec::new() - } else { - presets - .iter() - .find(|preset| preset.id == "read-only") - .map(|preset| { - Self::approval_preset_actions(preset.approval, preset.sandbox.clone()) - }) - .unwrap_or_default() - }; - stay_actions.insert( - 0, - Box::new({ - let otel = self.otel_manager.clone(); - move |_tx| { - otel.counter("codex.windows_sandbox.elevated_prompt_decline", 1, &[]); - } - }), - ); - let accept_otel = self.otel_manager.clone(); + let legacy_otel = self.otel_manager.clone(); + let legacy_preset = preset.clone(); + let quit_otel = self.otel_manager.clone(); let items = vec![ SelectionItem { - name: "Set up agent sandbox (requires elevation)".to_string(), + name: "Set up default sandbox (requires Administrator permissions)".to_string(), description: None, actions: vec![Box::new(move |tx| { accept_otel.counter("codex.windows_sandbox.elevated_prompt_accept", 1, &[]); @@ -5748,9 +5714,24 @@ impl ChatWidget { ..Default::default() }, SelectionItem { - name: stay_label, + name: "Use non-admin sandbox (higher risk if prompt injected)".to_string(), + description: None, + actions: vec![Box::new(move |tx| { + legacy_otel.counter("codex.windows_sandbox.fallback_use_legacy", 1, &[]); + tx.send(AppEvent::BeginWindowsSandboxLegacySetup { + preset: legacy_preset.clone(), + }); + })], + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Quit".to_string(), description: None, - actions: stay_actions, + actions: vec![Box::new(move |tx| { + quit_otel.counter("codex.windows_sandbox.elevated_prompt_decline", 1, &[]); + tx.send(AppEvent::Exit(ExitMode::ShutdownFirst)); + })], dismiss_on_select: true, ..Default::default() }, @@ -5778,23 +5759,16 @@ impl ChatWidget { let _ = reason; - let current_approval = self.config.approval_policy.value(); - let current_sandbox = self.config.sandbox_policy.get(); - let presets = builtin_approval_presets(); - let stay_full_access = presets - .iter() - .find(|preset| preset.id == "full-access") - .is_some_and(|preset| { - Self::preset_matches_current(current_approval, current_sandbox, preset) - }); let mut lines = Vec::new(); - lines.push(line!["Use Non-Elevated Sandbox?".bold()]); + lines.push(line![ + "Couldn't set up your sandbox with Administrator permissions".bold() + ]); lines.push(line![""]); lines.push(line![ - "Elevation failed. You can also use a non-elevated sandbox, which protects your files and prevents network access under most circumstances. However, it carries greater risk if prompt injected." + "You can still use Codex in a non-admin sandbox. It carries greater risk if prompt injected." ]); lines.push(line![ - "Learn more: https://developers.openai.com/codex/windows" + "Learn more " ]); let mut header = ColumnRenderable::new(); @@ -5802,34 +5776,10 @@ impl ChatWidget { let elevated_preset = preset.clone(); let legacy_preset = preset; - let stay_label = if stay_full_access { - "Stay in Agent Full Access".to_string() - } else { - "Stay in Read-Only".to_string() - }; - let mut stay_actions = if stay_full_access { - Vec::new() - } else { - presets - .iter() - .find(|preset| preset.id == "read-only") - .map(|preset| { - Self::approval_preset_actions(preset.approval, preset.sandbox.clone()) - }) - .unwrap_or_default() - }; - stay_actions.insert( - 0, - Box::new({ - let otel = self.otel_manager.clone(); - move |_tx| { - otel.counter("codex.windows_sandbox.fallback_stay_current", 1, &[]); - } - }), - ); + let quit_otel = self.otel_manager.clone(); let items = vec![ SelectionItem { - name: "Try elevated agent sandbox setup again".to_string(), + name: "Try setting up admin sandbox again".to_string(), description: None, actions: vec![Box::new({ let otel = self.otel_manager.clone(); @@ -5845,16 +5795,15 @@ impl ChatWidget { ..Default::default() }, SelectionItem { - name: "Use non-elevated agent sandbox".to_string(), + name: "Use Codex with non-admin sandbox".to_string(), description: None, actions: vec![Box::new({ let otel = self.otel_manager.clone(); let preset = legacy_preset; move |tx| { otel.counter("codex.windows_sandbox.fallback_use_legacy", 1, &[]); - tx.send(AppEvent::EnableWindowsSandboxForAgentMode { + tx.send(AppEvent::BeginWindowsSandboxLegacySetup { preset: preset.clone(), - mode: WindowsSandboxEnableMode::Legacy, }); } })], @@ -5862,9 +5811,12 @@ impl ChatWidget { ..Default::default() }, SelectionItem { - name: stay_label, + name: "Quit".to_string(), description: None, - actions: stay_actions, + actions: vec![Box::new(move |tx| { + quit_otel.counter("codex.windows_sandbox.fallback_stay_current", 1, &[]); + tx.send(AppEvent::Exit(ExitMode::ShutdownFirst)); + })], dismiss_on_select: true, ..Default::default() }, @@ -5888,8 +5840,8 @@ impl ChatWidget { } #[cfg(target_os = "windows")] - pub(crate) fn maybe_prompt_windows_sandbox_enable(&mut self) { - if self.config.forced_auto_mode_downgraded_on_windows + pub(crate) fn maybe_prompt_windows_sandbox_enable(&mut self, show_now: bool) { + if show_now && WindowsSandboxLevel::from_config(&self.config) == WindowsSandboxLevel::Disabled && let Some(preset) = builtin_approval_presets() .into_iter() @@ -5900,7 +5852,7 @@ impl ChatWidget { } #[cfg(not(target_os = "windows"))] - pub(crate) fn maybe_prompt_windows_sandbox_enable(&mut self) {} + pub(crate) fn maybe_prompt_windows_sandbox_enable(&mut self, _show_now: bool) {} #[cfg(target_os = "windows")] pub(crate) fn show_windows_sandbox_setup_status(&mut self) { @@ -5912,7 +5864,10 @@ impl ChatWidget { ); self.bottom_pane.ensure_status_indicator(); self.bottom_pane.set_interrupt_hint_visible(false); - self.set_status_header("Setting up agent sandbox. This can take a minute.".to_string()); + self.set_status( + "Setting up sandbox...".to_string(), + Some("Hang tight, this may take a few minutes".to_string()), + ); self.request_redraw(); } @@ -5930,15 +5885,6 @@ impl ChatWidget { #[cfg(not(target_os = "windows"))] pub(crate) fn clear_windows_sandbox_setup_status(&mut self) {} - #[cfg(target_os = "windows")] - pub(crate) fn clear_forced_auto_mode_downgrade(&mut self) { - self.config.forced_auto_mode_downgraded_on_windows = false; - } - - #[cfg(not(target_os = "windows"))] - #[allow(dead_code)] - pub(crate) fn clear_forced_auto_mode_downgrade(&mut self) {} - /// Set the approval policy in the widget's config copy. pub(crate) fn set_approval_policy(&mut self, policy: AskForApproval) { if let Err(err) = self.config.approval_policy.set(policy) { @@ -5947,19 +5893,23 @@ impl ChatWidget { } /// Set the sandbox policy in the widget's config copy. + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] pub(crate) fn set_sandbox_policy(&mut self, policy: SandboxPolicy) -> ConstraintResult<()> { - #[cfg(target_os = "windows")] - let should_clear_downgrade = !matches!(&policy, SandboxPolicy::ReadOnly) - || WindowsSandboxLevel::from_config(&self.config) != WindowsSandboxLevel::Disabled; - self.config.sandbox_policy.set(policy)?; + Ok(()) + } + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + pub(crate) fn set_windows_sandbox_mode(&mut self, mode: Option) { + self.config.windows_sandbox_mode = mode; #[cfg(target_os = "windows")] - if should_clear_downgrade { - self.config.forced_auto_mode_downgraded_on_windows = false; - } - - Ok(()) + self.bottom_pane.set_windows_degraded_sandbox_active( + codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED + && matches!( + WindowsSandboxLevel::from_config(&self.config), + WindowsSandboxLevel::RestrictedToken + ), + ); } #[cfg_attr(not(target_os = "windows"), allow(dead_code))] diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup@windows_degraded.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup@windows_degraded.snap index 7556f84aae42..ecbe5de1579b 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup@windows_degraded.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup@windows_degraded.snap @@ -8,7 +8,7 @@ expression: popup › 1. Read Only (current) Codex can read files in the current workspace. Approval is required to edit files or access the internet. - 2. Default (non-elevated sandbox) Codex can read and edit files in the + 2. Default (non-admin sandbox) Codex can read and edit files in the current workspace, and run commands. Approval is required to access the internet or edit other files. @@ -17,7 +17,7 @@ expression: popup asking for approval. Exercise caution when using. - The non-elevated sandbox protects your files and prevents network access under + The non-admin sandbox protects your files and prevents network access under most circumstances. However, it carries greater risk if prompt injected. To - upgrade to the elevated sandbox, run /setup-elevated-sandbox. + upgrade to the default sandbox, run /setup-default-sandbox. Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 1f1af74a1159..90e0ee95f836 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -4019,9 +4019,18 @@ async fn approvals_selection_popup_snapshot_windows_degraded_sandbox() { chat.open_approvals_popup(); let popup = render_bottom_popup(&chat, 80); - insta::with_settings!({ snapshot_suffix => "windows_degraded" }, { - assert_snapshot!("approvals_selection_popup", popup); - }); + assert!( + popup.contains("Default (non-admin sandbox)"), + "expected degraded sandbox label in approvals popup: {popup}" + ); + assert!( + popup.contains("/setup-default-sandbox"), + "expected setup hint in approvals popup: {popup}" + ); + assert!( + popup.contains("non-admin sandbox"), + "expected degraded sandbox note in approvals popup: {popup}" + ); } #[tokio::test] @@ -4074,8 +4083,12 @@ async fn windows_auto_mode_prompt_requests_enabling_sandbox_feature() { let popup = render_bottom_popup(&chat, 120); assert!( - popup.contains("requires elevation"), - "expected auto mode prompt to mention elevation, popup: {popup}" + popup.contains("requires Administrator permissions"), + "expected auto mode prompt to mention Administrator permissions, popup: {popup}" + ); + assert!( + popup.contains("Use non-admin sandbox"), + "expected auto mode prompt to include non-admin fallback option, popup: {popup}" ); } @@ -4086,22 +4099,40 @@ async fn startup_prompts_for_windows_sandbox_when_agent_requested() { chat.set_feature_enabled(Feature::WindowsSandbox, false); chat.set_feature_enabled(Feature::WindowsSandboxElevated, false); - chat.config.forced_auto_mode_downgraded_on_windows = true; - chat.maybe_prompt_windows_sandbox_enable(); + chat.maybe_prompt_windows_sandbox_enable(true); let popup = render_bottom_popup(&chat, 120); assert!( - popup.contains("requires elevation"), - "expected startup prompt to explain elevation: {popup}" + popup.contains("requires Administrator permissions"), + "expected startup prompt to mention Administrator permissions: {popup}" + ); + assert!( + popup.contains("Set up default sandbox"), + "expected startup prompt to offer default sandbox setup: {popup}" ); assert!( - popup.contains("Set up agent sandbox"), - "expected startup prompt to offer agent sandbox setup: {popup}" + popup.contains("Use non-admin sandbox"), + "expected startup prompt to offer non-admin fallback: {popup}" ); assert!( - popup.contains("Stay in"), - "expected startup prompt to offer staying in current mode: {popup}" + popup.contains("Quit"), + "expected startup prompt to offer quit action: {popup}" + ); +} + +#[cfg(target_os = "windows")] +#[tokio::test] +async fn startup_does_not_prompt_for_windows_sandbox_when_not_requested() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + + chat.set_feature_enabled(Feature::WindowsSandbox, false); + chat.set_feature_enabled(Feature::WindowsSandboxElevated, false); + chat.maybe_prompt_windows_sandbox_enable(false); + + assert!( + chat.bottom_pane.no_modal_or_popup_active(), + "expected no startup sandbox NUX popup when startup trigger is false" ); } diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index f457881ed515..735642905bd3 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -469,6 +469,7 @@ async fn run_ratatui_app( let should_show_trust_screen_flag = should_show_trust_screen(&initial_config); let should_show_onboarding = should_show_onboarding(login_status, &initial_config, should_show_trust_screen_flag); + let mut trust_decision_was_made = false; let config = if should_show_onboarding { let show_login_screen = should_show_login_screen(login_status, &initial_config); @@ -495,6 +496,7 @@ async fn run_ratatui_app( exit_reason: ExitReason::UserRequested, }); } + trust_decision_was_made = onboarding_result.directory_trust_decision.is_some(); // If this onboarding run included the login step, always refresh cloud requirements and // rebuild config. This avoids missing newly available cloud requirements due to login // status detection edge cases. @@ -669,6 +671,9 @@ async fn run_ratatui_app( set_default_client_residency_requirement(config.enforce_residency.value()); let active_profile = config.active_profile.clone(); let should_show_trust_screen = should_show_trust_screen(&config); + let should_prompt_windows_sandbox_nux_at_startup = cfg!(target_os = "windows") + && trust_decision_was_made + && WindowsSandboxLevel::from_config(&config) == WindowsSandboxLevel::Disabled; let Cli { prompt, @@ -692,6 +697,7 @@ async fn run_ratatui_app( session_selection, feedback, should_show_trust_screen, // Proxy to: is it a first run in this directory? + should_prompt_windows_sandbox_nux_at_startup, ) .await; @@ -876,12 +882,6 @@ async fn load_config_or_exit_with_fallback_cwd( /// or if the current cwd project is already trusted. If not, we need to /// show the trust screen. fn should_show_trust_screen(config: &Config) -> bool { - if cfg!(target_os = "windows") - && WindowsSandboxLevel::from_config(config) == WindowsSandboxLevel::Disabled - { - // If the experimental sandbox is not enabled, Native Windows cannot enforce sandboxed write access; skip the trust prompt entirely. - return false; - } if config.did_user_set_custom_approval_policy_or_sandbox_mode { // Respect explicit approval/sandbox overrides made by the user. return false; @@ -936,7 +936,7 @@ mod tests { #[tokio::test] #[serial] - async fn windows_skips_trust_prompt_without_sandbox() -> std::io::Result<()> { + async fn windows_shows_trust_prompt_without_sandbox() -> std::io::Result<()> { let temp_dir = TempDir::new()?; let mut config = build_config(&temp_dir).await?; config.did_user_set_custom_approval_policy_or_sandbox_mode = false; @@ -944,17 +944,10 @@ mod tests { config.set_windows_sandbox_enabled(false); let should_show = should_show_trust_screen(&config); - if cfg!(target_os = "windows") { - assert!( - !should_show, - "Windows trust prompt should always be skipped on native Windows" - ); - } else { - assert!( - should_show, - "Non-Windows should still show trust prompt when project is untrusted" - ); - } + assert!( + should_show, + "Trust prompt should be shown when project trust is undecided" + ); Ok(()) } #[tokio::test] diff --git a/codex-rs/tui/src/onboarding/onboarding_screen.rs b/codex-rs/tui/src/onboarding/onboarding_screen.rs index 09be507fff6e..127ac9f6677e 100644 --- a/codex-rs/tui/src/onboarding/onboarding_screen.rs +++ b/codex-rs/tui/src/onboarding/onboarding_screen.rs @@ -1,6 +1,9 @@ use codex_core::AuthManager; use codex_core::config::Config; -use codex_core::git_info::get_git_repo_root; +#[cfg(target_os = "windows")] +use codex_core::windows_sandbox::WindowsSandboxLevelExt; +#[cfg(target_os = "windows")] +use codex_protocol::config_types::WindowsSandboxLevel; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; @@ -82,7 +85,7 @@ impl OnboardingScreen { let cwd = config.cwd.clone(); let forced_chatgpt_workspace_id = config.forced_chatgpt_workspace_id.clone(); let forced_login_method = config.forced_login_method; - let codex_home = config.codex_home; + let codex_home = config.codex_home.clone(); let cli_auth_credentials_store_mode = config.cli_auth_credentials_store_mode; let mut steps: Vec = Vec::new(); steps.push(Step::Welcome(WelcomeWidget::new( @@ -109,18 +112,18 @@ impl OnboardingScreen { animations_enabled: config.animations, })) } - let is_git_repo = get_git_repo_root(&cwd).is_some(); - let highlighted = if is_git_repo { - TrustDirectorySelection::Trust - } else { - // Default to not trusting the directory if it's not a git repo. - TrustDirectorySelection::DontTrust - }; + #[cfg(target_os = "windows")] + let show_windows_create_sandbox_hint = + WindowsSandboxLevel::from_config(&config) == WindowsSandboxLevel::Disabled; + #[cfg(not(target_os = "windows"))] + let show_windows_create_sandbox_hint = false; + let highlighted = TrustDirectorySelection::Trust; if show_trust_screen { steps.push(Step::TrustDirectory(TrustDirectoryWidget { cwd, codex_home, - is_git_repo, + show_windows_create_sandbox_hint, + should_quit: false, selection: None, highlighted, error: None, @@ -253,6 +256,16 @@ impl KeyboardHandler for OnboardingScreen { if let Some(active_step) = self.current_steps_mut().into_iter().last() { active_step.handle_key_event(key_event); } + if self.steps.iter().any(|step| { + if let Step::TrustDirectory(widget) = step { + widget.should_quit() + } else { + false + } + }) { + self.should_exit = true; + self.is_done = true; + } } self.request_frame.schedule_frame(); } diff --git a/codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__trust_directory__tests__renders_snapshot_for_git_repo.snap b/codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__trust_directory__tests__renders_snapshot_for_git_repo.snap index 71cbc3d6ea44..12c1816db806 100644 --- a/codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__trust_directory__tests__renders_snapshot_for_git_repo.snap +++ b/codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__trust_directory__tests__renders_snapshot_for_git_repo.snap @@ -1,14 +1,14 @@ --- source: tui/src/onboarding/trust_directory.rs +assertion_line: 218 expression: terminal.backend() --- -> You are running Codex in /workspace/project +> You are in /workspace/project - Since this folder is version controlled, you may wish to allow Codex - to work in this folder without asking for approval. + Do you trust the contents of this directory? Working with untrusted + contents comes with higher risk of prompt injection. -› 1. Yes, allow Codex to work in this folder without asking for - approval - 2. No, ask me to approve edits and commands +› 1. Yes, continue + 2. No, quit Press enter to continue diff --git a/codex-rs/tui/src/onboarding/trust_directory.rs b/codex-rs/tui/src/onboarding/trust_directory.rs index cf5d6e9a1ae7..87cdac5236eb 100644 --- a/codex-rs/tui/src/onboarding/trust_directory.rs +++ b/codex-rs/tui/src/onboarding/trust_directory.rs @@ -27,7 +27,8 @@ use super::onboarding_screen::StepState; pub(crate) struct TrustDirectoryWidget { pub codex_home: PathBuf, pub cwd: PathBuf, - pub is_git_repo: bool, + pub show_windows_create_sandbox_hint: bool, + pub should_quit: bool, pub selection: Option, pub highlighted: TrustDirectorySelection, pub error: Option, @@ -36,7 +37,7 @@ pub(crate) struct TrustDirectoryWidget { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum TrustDirectorySelection { Trust, - DontTrust, + Quit, } impl WidgetRef for &TrustDirectoryWidget { @@ -45,44 +46,24 @@ impl WidgetRef for &TrustDirectoryWidget { column.push(Line::from(vec![ "> ".into(), - "You are running Codex in ".bold(), + "You are in ".bold(), self.cwd.to_string_lossy().to_string().into(), ])); column.push(""); - let guidance = if self.is_git_repo { - "Since this folder is version controlled, you may wish to allow Codex to work in this folder without asking for approval." - } else { - "Since this folder is not version controlled, we recommend requiring approval of all edits and commands." - }; - column.push( - Paragraph::new(guidance.to_string()) + Paragraph::new( + "Do you trust the contents of this directory? Working with untrusted contents comes with higher risk of prompt injection.".to_string(), + ) .wrap(Wrap { trim: true }) .inset(Insets::tlbr(0, 2, 0, 0)), ); column.push(""); - let mut options: Vec<(&str, TrustDirectorySelection)> = Vec::new(); - if self.is_git_repo { - options.push(( - "Yes, allow Codex to work in this folder without asking for approval", - TrustDirectorySelection::Trust, - )); - options.push(( - "No, ask me to approve edits and commands", - TrustDirectorySelection::DontTrust, - )); - } else { - options.push(( - "Allow Codex to work in this folder without asking for approval", - TrustDirectorySelection::Trust, - )); - options.push(( - "Require approval of edits and commands", - TrustDirectorySelection::DontTrust, - )); - } + let options: Vec<(&str, TrustDirectorySelection)> = vec![ + ("Yes, continue", TrustDirectorySelection::Trust), + ("No, quit", TrustDirectorySelection::Quit), + ]; for (idx, (text, selection)) in options.iter().enumerate() { column.push(selection_option_row( @@ -108,7 +89,11 @@ impl WidgetRef for &TrustDirectoryWidget { Line::from(vec![ "Press ".dim(), key_hint::plain(KeyCode::Enter).into(), - " to continue".dim(), + if self.show_windows_create_sandbox_hint { + " to continue and create a sandbox...".dim() + } else { + " to continue".dim() + }, ]) .inset(Insets::tlbr(0, 2, 0, 0)), ); @@ -128,13 +113,13 @@ impl KeyboardHandler for TrustDirectoryWidget { self.highlighted = TrustDirectorySelection::Trust; } KeyCode::Down | KeyCode::Char('j') => { - self.highlighted = TrustDirectorySelection::DontTrust; + self.highlighted = TrustDirectorySelection::Quit; } KeyCode::Char('1') | KeyCode::Char('y') => self.handle_trust(), - KeyCode::Char('2') | KeyCode::Char('n') => self.handle_dont_trust(), + KeyCode::Char('2') | KeyCode::Char('n') => self.handle_quit(), KeyCode::Enter => match self.highlighted { TrustDirectorySelection::Trust => self.handle_trust(), - TrustDirectorySelection::DontTrust => self.handle_dont_trust(), + TrustDirectorySelection::Quit => self.handle_quit(), }, _ => {} } @@ -143,9 +128,10 @@ impl KeyboardHandler for TrustDirectoryWidget { impl StepStateProvider for TrustDirectoryWidget { fn get_step_state(&self) -> StepState { - match self.selection { - Some(_) => StepState::Complete, - None => StepState::InProgress, + if self.selection.is_some() || self.should_quit { + StepState::Complete + } else { + StepState::InProgress } } } @@ -162,19 +148,13 @@ impl TrustDirectoryWidget { self.selection = Some(TrustDirectorySelection::Trust); } - fn handle_dont_trust(&mut self) { - self.highlighted = TrustDirectorySelection::DontTrust; - let target = - resolve_root_git_project_for_trust(&self.cwd).unwrap_or_else(|| self.cwd.clone()); - if let Err(e) = set_project_trust_level(&self.codex_home, &target, TrustLevel::Untrusted) { - tracing::error!("Failed to set project untrusted: {e:?}"); - self.error = Some(format!( - "Failed to set untrusted for {}: {e}", - target.display() - )); - } + fn handle_quit(&mut self) { + self.highlighted = TrustDirectorySelection::Quit; + self.should_quit = true; + } - self.selection = Some(TrustDirectorySelection::DontTrust); + pub fn should_quit(&self) -> bool { + self.should_quit } } @@ -198,9 +178,10 @@ mod tests { let mut widget = TrustDirectoryWidget { codex_home: codex_home.path().to_path_buf(), cwd: PathBuf::from("."), - is_git_repo: false, + show_windows_create_sandbox_hint: false, + should_quit: false, selection: None, - highlighted: TrustDirectorySelection::DontTrust, + highlighted: TrustDirectorySelection::Quit, error: None, }; @@ -213,7 +194,7 @@ mod tests { let press = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE); widget.handle_key_event(press); - assert_eq!(widget.selection, Some(TrustDirectorySelection::DontTrust)); + assert!(widget.should_quit); } #[test] @@ -222,7 +203,8 @@ mod tests { let widget = TrustDirectoryWidget { codex_home: codex_home.path().to_path_buf(), cwd: PathBuf::from("/workspace/project"), - is_git_repo: true, + show_windows_create_sandbox_hint: false, + should_quit: false, selection: None, highlighted: TrustDirectorySelection::Trust, error: None, diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index e4e1c9d89103..e48ac74e7e04 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -15,7 +15,7 @@ pub enum SlashCommand { Model, Approvals, Permissions, - #[strum(serialize = "setup-elevated-sandbox")] + #[strum(serialize = "setup-default-sandbox")] ElevateSandbox, Experimental, Skills, @@ -77,7 +77,7 @@ impl SlashCommand { SlashCommand::Agent => "switch the active agent thread", SlashCommand::Approvals => "choose what Codex is allowed to do", SlashCommand::Permissions => "choose what Codex is allowed to do", - SlashCommand::ElevateSandbox => "set up elevated agent sandbox", + SlashCommand::ElevateSandbox => "set up default agent sandbox", SlashCommand::Experimental => "toggle experimental features", SlashCommand::Mcp => "list configured MCP tools", SlashCommand::Apps => "manage apps", diff --git a/codex-rs/windows-sandbox-rs/src/lib.rs b/codex-rs/windows-sandbox-rs/src/lib.rs index 4a97efaaf858..525f216ae46b 100644 --- a/codex-rs/windows-sandbox-rs/src/lib.rs +++ b/codex-rs/windows-sandbox-rs/src/lib.rs @@ -115,6 +115,8 @@ pub use token::get_current_token_for_restriction; #[cfg(target_os = "windows")] pub use windows_impl::run_windows_sandbox_capture; #[cfg(target_os = "windows")] +pub use windows_impl::run_windows_sandbox_legacy_preflight; +#[cfg(target_os = "windows")] pub use windows_impl::CaptureResult; #[cfg(target_os = "windows")] pub use winutil::string_from_sid_bytes; @@ -130,6 +132,8 @@ pub use stub::apply_world_writable_scan_and_denies; #[cfg(not(target_os = "windows"))] pub use stub::run_windows_sandbox_capture; #[cfg(not(target_os = "windows"))] +pub use stub::run_windows_sandbox_legacy_preflight; +#[cfg(not(target_os = "windows"))] pub use stub::CaptureResult; #[cfg(target_os = "windows")] @@ -502,6 +506,50 @@ mod windows_impl { }) } + pub fn run_windows_sandbox_legacy_preflight( + sandbox_policy: &SandboxPolicy, + sandbox_policy_cwd: &Path, + codex_home: &Path, + cwd: &Path, + env_map: &HashMap, + ) -> Result<()> { + let is_workspace_write = matches!(sandbox_policy, SandboxPolicy::WorkspaceWrite { .. }); + if !is_workspace_write { + return Ok(()); + } + + ensure_codex_home_exists(codex_home)?; + let caps = load_or_create_cap_sids(codex_home)?; + let psid_generic = + unsafe { convert_string_sid_to_sid(&caps.workspace) }.expect("valid workspace SID"); + let ws_sid = workspace_cap_sid_for_cwd(codex_home, cwd)?; + let psid_workspace = + unsafe { convert_string_sid_to_sid(&ws_sid) }.expect("valid workspace SID"); + let current_dir = cwd.to_path_buf(); + let AllowDenyPaths { allow, deny } = + compute_allow_paths(sandbox_policy, sandbox_policy_cwd, ¤t_dir, env_map); + let canonical_cwd = canonicalize_path(¤t_dir); + + unsafe { + for p in &allow { + let psid = if is_command_cwd_root(p, &canonical_cwd) { + psid_workspace + } else { + psid_generic + }; + let _ = add_allow_ace(p, psid); + } + for p in &deny { + let _ = add_deny_write_ace(p, psid_generic); + } + allow_null_device(psid_generic); + allow_null_device(psid_workspace); + let _ = protect_workspace_codex_dir(¤t_dir, psid_workspace); + } + + Ok(()) + } + #[cfg(test)] mod tests { use super::should_apply_network_block; @@ -570,4 +618,14 @@ mod stub { ) -> Result<()> { bail!("Windows sandbox is only available on Windows") } + + pub fn run_windows_sandbox_legacy_preflight( + _sandbox_policy: &SandboxPolicy, + _sandbox_policy_cwd: &Path, + _codex_home: &Path, + _cwd: &Path, + _env_map: &HashMap, + ) -> Result<()> { + bail!("Windows sandbox is only available on Windows") + } }