Promote Windows Sandbox (#11341)

1. Move Windows Sandbox NUX to right after trust directory screen
2. Don't offer read-only as an option in Sandbox NUX.
Elevated/Legacy/Quit
3. Don't allow new untrusted directories. It's trust or quit
4. move experimental sandbox features to `[windows]
sandbox="elevated|unelevatd"`
5. Copy tweaks = elevated -> default, non-elevated -> non-admin
This commit is contained in:
iceweasel-oai
2026-02-11 11:48:33 -08:00
committed by GitHub
parent 24e6adbda5
commit 87279de434
21 changed files with 727 additions and 395 deletions

View File

@@ -1800,14 +1800,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();
@@ -2933,13 +2942,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
@@ -3855,13 +3873,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 {
@@ -4034,13 +4063,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

View File

@@ -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"

View File

@@ -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<I>(mut self, edits: I) -> Self
where
I: IntoIterator<Item = ConfigEdit>,

View File

@@ -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;
@@ -161,10 +164,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
@@ -342,6 +341,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<WindowsSandboxModeToml>,
/// Centralized feature flags; source of truth for feature gating.
pub features: Features,
@@ -1037,6 +1040,10 @@ pub struct ConfigToml {
/// OTEL configuration.
pub otel: Option<crate::config::types::OtelConfigToml>,
/// Windows-specific configuration.
#[serde(default)]
pub windows: Option<WindowsToml>,
/// Tracks whether the Windows onboarding screen has been acknowledged.
pub windows_wsl_setup_acknowledged: Option<bool>,
@@ -1139,12 +1146,6 @@ pub struct GhostSnapshotToml {
pub disable_warnings: Option<bool>,
}
#[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(
@@ -1154,7 +1155,7 @@ impl ConfigToml {
windows_sandbox_level: WindowsSandboxLevel,
resolved_cwd: &Path,
sandbox_policy_constraint: Option<&Constrained<SandboxPolicy>>,
) -> SandboxPolicyResolution {
) -> SandboxPolicy {
let sandbox_mode_was_explicit = sandbox_mode_override.is_some()
|| profile_sandbox_mode.is_some()
|| self.sandbox_mode.is_some();
@@ -1162,10 +1163,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
}
@@ -1190,8 +1200,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
@@ -1199,7 +1208,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) {
@@ -1216,10 +1224,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
@@ -1438,6 +1443,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;
@@ -1467,11 +1473,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,
@@ -1711,7 +1718,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,
@@ -1776,6 +1782,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
@@ -1881,21 +1888,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
};
}
}
@@ -2076,13 +2091,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"
@@ -2101,13 +2110,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!(
@@ -2135,24 +2138,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,
}
);
}
@@ -2185,24 +2179,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,
}
);
}
@@ -2365,10 +2350,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:?}"),
@@ -2701,13 +2682,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(())
@@ -4026,7 +4005,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,
@@ -4069,6 +4047,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,
@@ -4132,7 +4111,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,
@@ -4175,6 +4153,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,
@@ -4236,7 +4215,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,
@@ -4279,6 +4257,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,
@@ -4326,7 +4305,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,
@@ -4369,6 +4347,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,
@@ -4671,15 +4650,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:?}"
);
}
@@ -4722,7 +4699,7 @@ trust_level = "untrusted"
Some(&constrained),
);
assert_eq!(resolution.policy, SandboxPolicy::DangerFullAccess);
assert_eq!(resolution, SandboxPolicy::DangerFullAccess);
Ok(())
}
@@ -4764,21 +4741,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(())
}

View File

@@ -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<bool>,
pub web_search: Option<WebSearchMode>,
pub analytics: Option<crate::config::types::AnalyticsConfigToml>,
#[serde(default)]
pub windows: Option<WindowsToml>,
/// Optional feature toggles scoped to this profile.
#[serde(default)]
// Injects known feature keys into the schema and forbids unknown keys.

View File

@@ -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<WindowsSandboxModeToml>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum McpServerDisabledReason {
Unknown,

View File

@@ -493,13 +493,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 {

View File

@@ -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<WindowsSandboxModeToml> {
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<WindowsSandboxModeToml> {
let entries = features.map(|features| &features.entries)?;
legacy_windows_sandbox_mode_from_entries(entries)
}
pub fn legacy_windows_sandbox_mode_from_entries(
entries: &BTreeMap<String, bool>,
) -> Option<WindowsSandboxModeToml> {
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<String, String>,
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<String, String>,
_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);
}
}

View File

@@ -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<AppExitInfo> {
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))]
@@ -1788,6 +1790,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<String, String> =
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")]
{
@@ -1800,33 +1838,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)) =
@@ -1871,17 +1902,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) => {
@@ -1989,21 +2018,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}"));

View File

@@ -212,6 +212,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 {

View File

@@ -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(

View File

@@ -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

View File

@@ -51,6 +51,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;
@@ -5286,7 +5287,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()
};
@@ -5370,8 +5371,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()
@@ -5714,59 +5715,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 <https://developers.openai.com/codex/windows>"],
])
.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, &[]);
@@ -5778,9 +5744,24 @@ impl ChatWidget {
..Default::default()
},
SelectionItem {
name: stay_label,
name: "Use non-admin sandbox (higher risk if prompt injected)".to_string(),
description: None,
actions: stay_actions,
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: 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()
},
@@ -5808,23 +5789,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 <https://developers.openai.com/codex/windows>"
]);
let mut header = ColumnRenderable::new();
@@ -5832,34 +5806,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();
@@ -5875,16 +5825,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,
});
}
})],
@@ -5892,9 +5841,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()
},
@@ -5918,8 +5870,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()
@@ -5930,7 +5882,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) {
@@ -5942,7 +5894,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();
}
@@ -5960,15 +5915,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) {
@@ -5977,21 +5923,25 @@ 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)?;
#[cfg(target_os = "windows")]
if should_clear_downgrade {
self.config.forced_auto_mode_downgraded_on_windows = false;
}
Ok(())
}
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
pub(crate) fn set_windows_sandbox_mode(&mut self, mode: Option<WindowsSandboxModeToml>) {
self.config.windows_sandbox_mode = mode;
#[cfg(target_os = "windows")]
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))]
pub(crate) fn set_feature_enabled(&mut self, feature: Feature, enabled: bool) {
if enabled {

View File

@@ -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

View File

@@ -4161,9 +4161,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]
@@ -4216,8 +4225,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}"
);
}
@@ -4228,22 +4241,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 agent sandbox"),
"expected startup prompt to offer agent sandbox setup: {popup}"
popup.contains("Set up default sandbox"),
"expected startup prompt to offer default sandbox setup: {popup}"
);
assert!(
popup.contains("Stay in"),
"expected startup prompt to offer staying in current mode: {popup}"
popup.contains("Use non-admin sandbox"),
"expected startup prompt to offer non-admin fallback: {popup}"
);
assert!(
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"
);
}

View File

@@ -473,6 +473,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);
@@ -499,6 +500,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.
@@ -674,6 +676,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,
@@ -697,6 +702,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;
@@ -881,12 +887,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;
@@ -941,7 +941,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;
@@ -949,17 +949,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]

View File

@@ -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<Step> = 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();
}

View File

@@ -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

View File

@@ -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<TrustDirectorySelection>,
pub highlighted: TrustDirectorySelection,
pub error: Option<String>,
@@ -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,

View File

@@ -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",

View File

@@ -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<String, String>,
) -> 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, &current_dir, env_map);
let canonical_cwd = canonicalize_path(&current_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(&current_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<String, String>,
) -> Result<()> {
bail!("Windows sandbox is only available on Windows")
}
}