Elevated sandbox NUX (#8789)

Elevated Sandbox NUX:

* prompt for elevated sandbox setup when agent mode is selected (via
/approvals or at startup)
* prompt for degraded sandbox if elevated setup is declined or fails
* introduce /elevate-sandbox command to upgrade from degraded
experience.
This commit is contained in:
iceweasel-oai
2026-01-08 16:23:06 -08:00
committed by GitHub
Unverified
parent bdfdebcfa1
commit 6372ba9d5f
22 changed files with 1110 additions and 184 deletions
+9
View File
@@ -1504,6 +1504,15 @@ impl Config {
}
self.forced_auto_mode_downgraded_on_windows = !value;
}
pub fn set_windows_elevated_sandbox_globally(&mut self, value: bool) {
crate::safety::set_windows_elevated_sandbox_enabled(value);
if value {
self.features.enable(Feature::WindowsSandboxElevated);
} else {
self.features.disable(Feature::WindowsSandboxElevated);
}
}
}
fn default_review_model() -> String {
+3
View File
@@ -51,6 +51,7 @@ pub mod token_data;
mod truncate;
mod unified_exec;
mod user_instructions;
pub mod windows_sandbox;
pub use model_provider_info::CHAT_WIRE_API_DEPRECATION_SUMMARY;
pub use model_provider_info::DEFAULT_LMSTUDIO_PORT;
pub use model_provider_info::DEFAULT_OLLAMA_PORT;
@@ -114,6 +115,8 @@ pub use command_safety::is_safe_command;
pub use exec_policy::ExecPolicyError;
pub use exec_policy::load_exec_policy;
pub use safety::get_platform_sandbox;
pub use safety::is_windows_elevated_sandbox_enabled;
pub use safety::set_windows_elevated_sandbox_enabled;
pub use safety::set_windows_sandbox_enabled;
// Re-export the protocol types from the standalone `codex-protocol` crate so existing
// `codex_core::protocol::...` references continue to work across the workspace.
+49
View File
@@ -0,0 +1,49 @@
use crate::protocol::SandboxPolicy;
use std::collections::HashMap;
use std::path::Path;
/// Kill switch for the elevated sandbox NUX on Windows.
///
/// When false, revert to the previous sandbox NUX, which only
/// prompts users to enable the legacy sandbox feature.
pub const ELEVATED_SANDBOX_NUX_ENABLED: bool = true;
#[cfg(target_os = "windows")]
pub fn sandbox_setup_is_complete(codex_home: &Path) -> bool {
codex_windows_sandbox::sandbox_setup_is_complete(codex_home)
}
#[cfg(not(target_os = "windows"))]
pub fn sandbox_setup_is_complete(_codex_home: &Path) -> bool {
false
}
#[cfg(target_os = "windows")]
pub fn run_elevated_setup(
policy: &SandboxPolicy,
policy_cwd: &Path,
command_cwd: &Path,
env_map: &HashMap<String, String>,
codex_home: &Path,
) -> anyhow::Result<()> {
codex_windows_sandbox::run_elevated_setup(
policy,
policy_cwd,
command_cwd,
env_map,
codex_home,
None,
None,
)
}
#[cfg(not(target_os = "windows"))]
pub fn run_elevated_setup(
_policy: &SandboxPolicy,
_policy_cwd: &Path,
_command_cwd: &Path,
_env_map: &HashMap<String, String>,
_codex_home: &Path,
) -> anyhow::Result<()> {
anyhow::bail!("elevated Windows sandbox setup is only supported on Windows")
}
+86 -3
View File
@@ -1,5 +1,9 @@
use crate::app_backtrack::BacktrackState;
use crate::app_event::AppEvent;
#[cfg(target_os = "windows")]
use crate::app_event::WindowsSandboxEnableMode;
#[cfg(target_os = "windows")]
use crate::app_event::WindowsSandboxFallbackReason;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::ApprovalRequest;
use crate::chatwidget::ChatWidget;
@@ -792,19 +796,91 @@ impl App {
AppEvent::OpenWindowsSandboxEnablePrompt { preset } => {
self.chat_widget.open_windows_sandbox_enable_prompt(preset);
}
AppEvent::EnableWindowsSandboxForAgentMode { preset } => {
AppEvent::OpenWindowsSandboxFallbackPrompt { preset, reason } => {
self.chat_widget.clear_windows_sandbox_setup_status();
self.chat_widget
.open_windows_sandbox_fallback_prompt(preset, reason);
}
AppEvent::BeginWindowsSandboxElevatedSetup { 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();
// If the elevated setup already ran on this machine, don't prompt for
// elevation again - just flip the config to use the elevated path.
if codex_core::windows_sandbox::sandbox_setup_is_complete(codex_home.as_path())
{
tx.send(AppEvent::EnableWindowsSandboxForAgentMode {
preset,
mode: WindowsSandboxEnableMode::Elevated,
});
return Ok(true);
}
self.chat_widget.show_windows_sandbox_setup_status();
tokio::task::spawn_blocking(move || {
let result = codex_core::windows_sandbox::run_elevated_setup(
&policy,
policy_cwd.as_path(),
command_cwd.as_path(),
&env_map,
codex_home.as_path(),
);
let event = match result {
Ok(()) => AppEvent::EnableWindowsSandboxForAgentMode {
preset: preset.clone(),
mode: WindowsSandboxEnableMode::Elevated,
},
Err(err) => {
tracing::error!(
error = %err,
"failed to run elevated Windows sandbox setup"
);
AppEvent::OpenWindowsSandboxFallbackPrompt {
preset,
reason: WindowsSandboxFallbackReason::ElevationFailed,
}
}
};
tx.send(event);
});
}
#[cfg(not(target_os = "windows"))]
{
let _ = preset;
}
}
AppEvent::EnableWindowsSandboxForAgentMode { preset, mode } => {
#[cfg(target_os = "windows")]
{
self.chat_widget.clear_windows_sandbox_setup_status();
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);
match ConfigEditsBuilder::new(&self.config.codex_home)
.with_profile(profile)
.set_feature_enabled(feature_key, true)
.set_feature_enabled(elevated_key, elevated_enabled)
.apply()
.await
{
Ok(()) => {
self.config.set_windows_sandbox_globally(true);
self.config
.set_windows_elevated_sandbox_globally(elevated_enabled);
self.chat_widget
.set_feature_enabled(Feature::WindowsSandbox, true);
self.chat_widget.set_feature_enabled(
Feature::WindowsSandboxElevated,
elevated_enabled,
);
self.chat_widget.clear_forced_auto_mode_downgrade();
if let Some((sample_paths, extra_count, failed_scan)) =
self.chat_widget.world_writable_warning_details()
@@ -833,7 +909,14 @@ impl App {
self.app_event_tx
.send(AppEvent::UpdateSandboxPolicy(preset.sandbox.clone()));
self.chat_widget.add_info_message(
"Enabled experimental Windows sandbox.".to_string(),
match mode {
WindowsSandboxEnableMode::Elevated => {
"Enabled elevated agent sandbox.".to_string()
}
WindowsSandboxEnableMode::Legacy => {
"Enabled non-elevated agent sandbox.".to_string()
}
},
None,
);
}
@@ -851,7 +934,7 @@ impl App {
}
#[cfg(not(target_os = "windows"))]
{
let _ = preset;
let _ = (preset, mode);
}
}
AppEvent::PersistModelSelection { model, effort } => {
+27
View File
@@ -15,6 +15,19 @@ use codex_core::protocol::AskForApproval;
use codex_core::protocol::SandboxPolicy;
use codex_protocol::openai_models::ReasoningEffort;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
pub(crate) enum WindowsSandboxEnableMode {
Elevated,
Legacy,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
pub(crate) enum WindowsSandboxFallbackReason {
ElevationFailed,
}
#[allow(clippy::large_enum_variant)]
#[derive(Debug)]
pub(crate) enum AppEvent {
@@ -106,10 +119,24 @@ pub(crate) enum AppEvent {
preset: ApprovalPreset,
},
/// Open the Windows sandbox fallback prompt after declining or failing elevation.
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
OpenWindowsSandboxFallbackPrompt {
preset: ApprovalPreset,
reason: WindowsSandboxFallbackReason,
},
/// Begin the elevated Windows sandbox setup flow.
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
BeginWindowsSandboxElevatedSetup {
preset: ApprovalPreset,
},
/// Enable the Windows sandbox feature and switch to Agent mode.
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
EnableWindowsSandboxForAgentMode {
preset: ApprovalPreset,
mode: WindowsSandboxEnableMode,
},
/// Update the current approval policy in the running app and widget.
+19 -92
View File
@@ -64,6 +64,13 @@ use std::path::PathBuf;
use std::time::Duration;
use std::time::Instant;
fn windows_degraded_sandbox_active() -> bool {
cfg!(target_os = "windows")
&& codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED
&& codex_core::get_platform_sandbox().is_some()
&& !codex_core::is_windows_elevated_sandbox_enabled()
}
/// If the pasted content exceeds this number of characters, replace it with a
/// placeholder in the UI.
const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000;
@@ -1229,6 +1236,10 @@ impl ChatComposer {
&& rest.is_empty()
&& let Some((_n, cmd)) = built_in_slash_commands()
.into_iter()
.filter(|(_, cmd)| {
windows_degraded_sandbox_active()
|| *cmd != SlashCommand::ElevateSandbox
})
.find(|(n, _)| *n == name)
{
self.textarea.set_text("");
@@ -1296,6 +1307,10 @@ impl ChatComposer {
if !treat_as_plain_text {
let is_builtin = built_in_slash_commands()
.into_iter()
.filter(|(_, cmd)| {
windows_degraded_sandbox_active()
|| *cmd != SlashCommand::ElevateSandbox
})
.any(|(command_name, _)| command_name == name);
let prompt_prefix = format!("{PROMPTS_CMD_PREFIX}:");
let is_known_prompt = name
@@ -1715,7 +1730,6 @@ impl ChatComposer {
self.active_popup = ActivePopup::None;
return;
}
let skill_token = self.current_skill_token();
let allow_command_popup = file_token.is_none() && skill_token.is_none();
@@ -1785,6 +1799,9 @@ impl ChatComposer {
let builtin_match = built_in_slash_commands()
.into_iter()
.filter(|(_, cmd)| {
windows_degraded_sandbox_active() || *cmd != SlashCommand::ElevateSandbox
})
.any(|(cmd_name, _)| fuzzy_match(cmd_name, name).is_some());
if builtin_match {
@@ -3054,44 +3071,6 @@ mod tests {
assert!(composer.textarea.is_empty(), "composer should be cleared");
}
#[test]
fn slash_review_with_args_dispatches_command_with_args() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
type_chars_humanlike(&mut composer, &['/', 'r', 'e', 'v', 'i', 'e', 'w', ' ']);
type_chars_humanlike(&mut composer, &['f', 'i', 'x', ' ', 't', 'h', 'i', 's']);
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match result {
InputResult::CommandWithArgs(cmd, args) => {
assert_eq!(cmd, SlashCommand::Review);
assert_eq!(args, "fix this");
}
InputResult::Command(cmd) => {
panic!("expected args for '/review', got bare command: {cmd:?}")
}
InputResult::Submitted(text) => {
panic!("expected command dispatch, got literal submit: {text}")
}
InputResult::None => panic!("expected CommandWithArgs result for '/review'"),
}
assert!(composer.textarea.is_empty(), "composer should be cleared");
}
#[test]
fn extract_args_supports_quoted_paths_single_arg() {
let args = extract_positional_args_for_prompt_line(
@@ -4393,59 +4372,6 @@ mod tests {
assert_eq!(result, InputResult::None);
}
#[test]
fn history_navigation_takes_priority_over_popups() {
use codex_protocol::protocol::SkillScope;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use tokio::sync::mpsc::unbounded_channel;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_skill_mentions(Some(vec![SkillMetadata {
name: "codex-cli-release-notes".to_string(),
description: "example".to_string(),
short_description: None,
path: PathBuf::from("skills/codex-cli-release-notes/SKILL.md"),
scope: SkillScope::Repo,
}]));
// Seed local history; the newest entry triggers the skills popup.
composer.history.record_local_submission("older");
composer
.history
.record_local_submission("$codex-cli-release-notes");
// First Up recalls "$...", but we should not open the skills popup while browsing history.
let (result, _redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
assert_eq!(result, InputResult::None);
assert_eq!(composer.textarea.text(), "$codex-cli-release-notes");
assert!(
matches!(composer.active_popup, ActivePopup::None),
"expected no skills popup while browsing history"
);
// Second Up should navigate history again (no popup should interfere).
let (result, _redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
assert_eq!(result, InputResult::None);
assert_eq!(composer.textarea.text(), "older");
assert!(
matches!(composer.active_popup, ActivePopup::None),
"expected popup to be dismissed after history navigation"
);
}
#[test]
fn slash_popup_activated_for_bare_slash_and_valid_prefixes() {
// use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
@@ -4603,6 +4529,7 @@ mod tests {
);
assert_eq!(composer.attached_images.len(), 1);
}
#[test]
fn input_disabled_ignores_keypresses_and_hides_cursor() {
use crossterm::event::KeyCode;
@@ -15,6 +15,13 @@ use codex_protocol::custom_prompts::CustomPrompt;
use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX;
use std::collections::HashSet;
fn windows_degraded_sandbox_active() -> bool {
cfg!(target_os = "windows")
&& codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED
&& codex_core::get_platform_sandbox().is_some()
&& !codex_core::is_windows_elevated_sandbox_enabled()
}
/// A selectable item in the popup: either a built-in command or a user prompt.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum CommandItem {
@@ -32,9 +39,11 @@ pub(crate) struct CommandPopup {
impl CommandPopup {
pub(crate) fn new(mut prompts: Vec<CustomPrompt>, skills_enabled: bool) -> Self {
let allow_elevate_sandbox = windows_degraded_sandbox_active();
let builtins: Vec<(&'static str, SlashCommand)> = built_in_slash_commands()
.into_iter()
.filter(|(_, cmd)| skills_enabled || *cmd != SlashCommand::Skills)
.filter(|(_, cmd)| allow_elevate_sandbox || *cmd != SlashCommand::ElevateSandbox)
.collect();
// Exclude prompts that collide with builtin command names and sort by name.
let exclude: HashSet<String> = builtins.iter().map(|(n, _)| (*n).to_string()).collect();
+298 -18
View File
@@ -85,6 +85,9 @@ use tokio::task::JoinHandle;
use tracing::debug;
use crate::app_event::AppEvent;
#[cfg(target_os = "windows")]
use crate::app_event::WindowsSandboxEnableMode;
use crate::app_event::WindowsSandboxFallbackReason;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::ApprovalRequest;
use crate::bottom_pane::BetaFeatureItem;
@@ -1737,6 +1740,45 @@ impl ChatWidget {
SlashCommand::Approvals => {
self.open_approvals_popup();
}
SlashCommand::ElevateSandbox => {
#[cfg(target_os = "windows")]
{
let windows_degraded_sandbox_enabled = codex_core::get_platform_sandbox()
.is_some()
&& !codex_core::is_windows_elevated_sandbox_enabled();
if !windows_degraded_sandbox_enabled
|| !codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED
{
// This command should not be visible/recognized outside degraded mode,
// but guard anyway in case something dispatches it directly.
return;
}
let Some(preset) = builtin_approval_presets()
.into_iter()
.find(|preset| preset.id == "auto")
else {
// Avoid panicking in interactive UI; treat this as a recoverable
// internal error.
self.add_error_message(
"Internal error: missing the 'auto' approval preset.".to_string(),
);
return;
};
if let Err(err) = self.config.approval_policy.can_set(&preset.approval) {
self.add_error_message(err.to_string());
return;
}
self.app_event_tx
.send(AppEvent::BeginWindowsSandboxElevatedSetup { preset });
}
#[cfg(not(target_os = "windows"))]
{
// Not supported; on non-Windows this command should never be reachable.
};
}
SlashCommand::Experimental => {
self.open_experimental_popup();
}
@@ -2841,10 +2883,25 @@ impl ChatWidget {
let current_sandbox = self.config.sandbox_policy.get();
let mut items: Vec<SelectionItem> = Vec::new();
let presets: Vec<ApprovalPreset> = builtin_approval_presets();
#[cfg(target_os = "windows")]
let windows_degraded_sandbox_enabled = codex_core::get_platform_sandbox().is_some()
&& !codex_core::is_windows_elevated_sandbox_enabled();
#[cfg(not(target_os = "windows"))]
let windows_degraded_sandbox_enabled = false;
let show_elevate_sandbox_hint = codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED
&& windows_degraded_sandbox_enabled
&& presets.iter().any(|preset| preset.id == "auto");
for preset in presets.into_iter() {
let is_current =
Self::preset_matches_current(current_approval, current_sandbox, &preset);
let name = preset.label.to_string();
let name = if preset.id == "auto" && windows_degraded_sandbox_enabled {
"Agent (non-elevated sandbox)".to_string()
} else {
preset.label.to_string()
};
let description = Some(preset.description.to_string());
let disabled_reason = match self.config.approval_policy.can_set(&preset.approval) {
Ok(()) => None,
@@ -2868,11 +2925,24 @@ impl ChatWidget {
{
if codex_core::get_platform_sandbox().is_none() {
let preset_clone = preset.clone();
vec![Box::new(move |tx| {
tx.send(AppEvent::OpenWindowsSandboxEnablePrompt {
preset: preset_clone.clone(),
});
})]
if codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED
&& codex_core::windows_sandbox::sandbox_setup_is_complete(
self.config.codex_home.as_path(),
)
{
vec![Box::new(move |tx| {
tx.send(AppEvent::EnableWindowsSandboxForAgentMode {
preset: preset_clone.clone(),
mode: WindowsSandboxEnableMode::Elevated,
});
})]
} else {
vec![Box::new(move |tx| {
tx.send(AppEvent::OpenWindowsSandboxEnablePrompt {
preset: preset_clone.clone(),
});
})]
}
} else if let Some((sample_paths, extra_count, failed_scan)) =
self.world_writable_warning_details()
{
@@ -2907,8 +2977,18 @@ 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(),
".".dim(),
]
.into()
});
self.bottom_pane.show_selection_view(SelectionViewParams {
title: Some("Select Approval Mode".to_string()),
footer_note,
footer_hint: Some(standard_popup_hint_line()),
items,
header: Box::new(()),
@@ -3185,36 +3265,106 @@ impl ChatWidget {
pub(crate) fn open_windows_sandbox_enable_prompt(&mut self, preset: ApprovalPreset) {
use ratatui_macros::line;
if !codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED {
// Legacy flow (pre-NUX): explain the experimental sandbox and let the user enable it
// directly (no elevation prompts).
let mut header = ColumnRenderable::new();
header.push(*Box::new(
Paragraph::new(vec![
line!["Agent mode on Windows uses an experimental sandbox to limit network and filesystem access.".bold()],
line!["Learn more: https://developers.openai.com/codex/windows"],
])
.wrap(Wrap { trim: false }),
));
let preset_clone = preset;
let items = vec![
SelectionItem {
name: "Enable experimental sandbox".to_string(),
description: None,
actions: vec![Box::new(move |tx| {
tx.send(AppEvent::EnableWindowsSandboxForAgentMode {
preset: preset_clone.clone(),
mode: WindowsSandboxEnableMode::Legacy,
});
})],
dismiss_on_select: true,
..Default::default()
},
SelectionItem {
name: "Go back".to_string(),
description: None,
actions: vec![Box::new(|tx| {
tx.send(AppEvent::OpenApprovalsPopup);
})],
dismiss_on_select: true,
..Default::default()
},
];
self.bottom_pane.show_selection_view(SelectionViewParams {
title: None,
footer_hint: Some(standard_popup_hint_line()),
items,
header: Box::new(header),
..Default::default()
});
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)
});
let 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()
};
let stay_label = if stay_full_access {
"Stay in Agent Full Access".to_string()
} else {
"Stay in Read-Only".to_string()
};
let mut header = ColumnRenderable::new();
header.push(*Box::new(
Paragraph::new(vec![
line!["Agent mode on Windows uses an experimental sandbox to limit network and filesystem access.".bold()],
line![
"Learn more: https://developers.openai.com/codex/windows"
],
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"],
])
.wrap(Wrap { trim: false }),
));
let preset_clone = preset;
let items = vec![
SelectionItem {
name: "Enable experimental sandbox".to_string(),
name: "Set up agent sandbox (requires elevation)".to_string(),
description: None,
actions: vec![Box::new(move |tx| {
tx.send(AppEvent::EnableWindowsSandboxForAgentMode {
preset: preset_clone.clone(),
tx.send(AppEvent::BeginWindowsSandboxElevatedSetup {
preset: preset.clone(),
});
})],
dismiss_on_select: true,
..Default::default()
},
SelectionItem {
name: "Go back".to_string(),
name: stay_label,
description: None,
actions: vec![Box::new(|tx| {
tx.send(AppEvent::OpenApprovalsPopup);
})],
actions: stay_actions,
dismiss_on_select: true,
..Default::default()
},
@@ -3232,6 +3382,107 @@ impl ChatWidget {
#[cfg(not(target_os = "windows"))]
pub(crate) fn open_windows_sandbox_enable_prompt(&mut self, _preset: ApprovalPreset) {}
#[cfg(target_os = "windows")]
pub(crate) fn open_windows_sandbox_fallback_prompt(
&mut self,
preset: ApprovalPreset,
reason: WindowsSandboxFallbackReason,
) {
use ratatui_macros::line;
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 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()
};
let stay_label = if stay_full_access {
"Stay in Agent Full Access".to_string()
} else {
"Stay in Read-Only".to_string()
};
let mut lines = Vec::new();
lines.push(line!["Use Non-Elevated Sandbox?".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."
]);
lines.push(line![
"Learn more: https://developers.openai.com/codex/windows"
]);
let mut header = ColumnRenderable::new();
header.push(*Box::new(Paragraph::new(lines).wrap(Wrap { trim: false })));
let elevated_preset = preset.clone();
let legacy_preset = preset;
let items = vec![
SelectionItem {
name: "Try elevated agent sandbox setup again".to_string(),
description: None,
actions: vec![Box::new(move |tx| {
tx.send(AppEvent::BeginWindowsSandboxElevatedSetup {
preset: elevated_preset.clone(),
});
})],
dismiss_on_select: true,
..Default::default()
},
SelectionItem {
name: "Use non-elevated agent sandbox".to_string(),
description: None,
actions: vec![Box::new(move |tx| {
tx.send(AppEvent::EnableWindowsSandboxForAgentMode {
preset: legacy_preset.clone(),
mode: WindowsSandboxEnableMode::Legacy,
});
})],
dismiss_on_select: true,
..Default::default()
},
SelectionItem {
name: stay_label,
description: None,
actions: stay_actions,
dismiss_on_select: true,
..Default::default()
},
];
self.bottom_pane.show_selection_view(SelectionViewParams {
title: None,
footer_hint: Some(standard_popup_hint_line()),
items,
header: Box::new(header),
..Default::default()
});
}
#[cfg(not(target_os = "windows"))]
pub(crate) fn open_windows_sandbox_fallback_prompt(
&mut self,
_preset: ApprovalPreset,
_reason: WindowsSandboxFallbackReason,
) {
}
#[cfg(target_os = "windows")]
pub(crate) fn maybe_prompt_windows_sandbox_enable(&mut self) {
if self.config.forced_auto_mode_downgraded_on_windows
@@ -3247,6 +3498,34 @@ impl ChatWidget {
#[cfg(not(target_os = "windows"))]
pub(crate) fn maybe_prompt_windows_sandbox_enable(&mut self) {}
#[cfg(target_os = "windows")]
pub(crate) fn show_windows_sandbox_setup_status(&mut self) {
// While elevated sandbox setup runs, prevent typing so the user doesn't
// accidentally queue messages that will run under an unexpected mode.
self.bottom_pane.set_composer_input_enabled(
false,
Some("Input disabled until setup completes.".to_string()),
);
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.request_redraw();
}
#[cfg(not(target_os = "windows"))]
#[allow(dead_code)]
pub(crate) fn show_windows_sandbox_setup_status(&mut self) {}
#[cfg(target_os = "windows")]
pub(crate) fn clear_windows_sandbox_setup_status(&mut self) {
self.bottom_pane.set_composer_input_enabled(true, None);
self.bottom_pane.hide_status_indicator();
self.request_redraw();
}
#[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;
@@ -3279,6 +3558,7 @@ impl ChatWidget {
Ok(())
}
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
pub(crate) fn set_feature_enabled(&mut self, feature: Feature, enabled: bool) {
if enabled {
self.config.features.enable(feature);
@@ -1,5 +1,6 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 1980
expression: popup
---
Select Approval Mode
@@ -0,0 +1,18 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 2003
expression: popup
---
Select Approval Mode
1. Read Only (current) Requires approval to edit files and run
commands.
2. Agent (non-elevated sandbox) Read and edit files, and run commands.
3. Agent (full access) Codex can edit files outside this workspace
and run commands with network access.
Exercise caution when using.
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 /setup-elevated-sandbox.
Press enter to confirm or esc to go back
+46 -6
View File
@@ -64,6 +64,8 @@ use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use insta::assert_snapshot;
use pretty_assertions::assert_eq;
#[cfg(target_os = "windows")]
use serial_test::serial;
use std::collections::HashSet;
use std::path::PathBuf;
use tempfile::NamedTempFile;
@@ -76,6 +78,11 @@ fn set_windows_sandbox_enabled(enabled: bool) {
codex_core::set_windows_sandbox_enabled(enabled);
}
#[cfg(target_os = "windows")]
fn set_windows_elevated_sandbox_enabled(enabled: bool) {
codex_core::set_windows_elevated_sandbox_enabled(enabled);
}
async fn test_config() -> Config {
// Use base defaults to avoid depending on host state.
let codex_home = std::env::temp_dir();
@@ -2027,6 +2034,35 @@ async fn approvals_selection_popup_snapshot() {
assert_snapshot!("approvals_selection_popup", popup);
}
#[cfg(target_os = "windows")]
#[tokio::test]
#[serial]
async fn approvals_selection_popup_snapshot_windows_degraded_sandbox() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
let was_sandbox_enabled = codex_core::get_platform_sandbox().is_some();
let was_elevated_enabled = codex_core::is_windows_elevated_sandbox_enabled();
chat.config.notices.hide_full_access_warning = None;
chat.config.features.enable(Feature::WindowsSandbox);
chat.config
.features
.disable(Feature::WindowsSandboxElevated);
set_windows_sandbox_enabled(true);
set_windows_elevated_sandbox_enabled(false);
chat.open_approvals_popup();
let popup = render_bottom_popup(&chat, 80);
insta::with_settings!({ snapshot_suffix => "windows_degraded" }, {
assert_snapshot!("approvals_selection_popup", popup);
});
// Avoid leaking sandbox global state into other tests.
set_windows_sandbox_enabled(was_sandbox_enabled);
set_windows_elevated_sandbox_enabled(was_elevated_enabled);
}
#[tokio::test]
async fn preset_matching_ignores_extra_writable_roots() {
let preset = builtin_approval_presets()
@@ -2077,8 +2113,8 @@ async fn windows_auto_mode_prompt_requests_enabling_sandbox_feature() {
let popup = render_bottom_popup(&chat, 120);
assert!(
popup.contains("Agent mode on Windows uses an experimental sandbox"),
"expected auto mode prompt to mention enabling the sandbox feature, popup: {popup}"
popup.contains("requires elevation"),
"expected auto mode prompt to mention elevation, popup: {popup}"
);
}
@@ -2094,12 +2130,16 @@ async fn startup_prompts_for_windows_sandbox_when_agent_requested() {
let popup = render_bottom_popup(&chat, 120);
assert!(
popup.contains("Agent mode on Windows uses an experimental sandbox"),
"expected startup prompt to explain sandbox: {popup}"
popup.contains("requires elevation"),
"expected startup prompt to explain elevation: {popup}"
);
assert!(
popup.contains("Enable experimental sandbox"),
"expected startup prompt to offer enabling the sandbox: {popup}"
popup.contains("Set up agent sandbox"),
"expected startup prompt to offer agent sandbox setup: {popup}"
);
assert!(
popup.contains("Stay in"),
"expected startup prompt to offer staying in current mode: {popup}"
);
set_windows_sandbox_enabled(true);
+4
View File
@@ -14,6 +14,8 @@ pub enum SlashCommand {
// more frequently used commands should be listed first.
Model,
Approvals,
#[strum(serialize = "setup-elevated-sandbox")]
ElevateSandbox,
Experimental,
Skills,
Review,
@@ -54,6 +56,7 @@ impl SlashCommand {
SlashCommand::Ps => "list background terminals",
SlashCommand::Model => "choose what model and reasoning effort to use",
SlashCommand::Approvals => "choose what Codex can do without approval",
SlashCommand::ElevateSandbox => "set up elevated agent sandbox",
SlashCommand::Experimental => "toggle beta features",
SlashCommand::Mcp => "list configured MCP tools",
SlashCommand::Logout => "log out of Codex",
@@ -78,6 +81,7 @@ impl SlashCommand {
// | SlashCommand::Undo
| SlashCommand::Model
| SlashCommand::Approvals
| SlashCommand::ElevateSandbox
| SlashCommand::Experimental
| SlashCommand::Review
| SlashCommand::Logout => false,
+86 -3
View File
@@ -1,5 +1,9 @@
use crate::app_backtrack::BacktrackState;
use crate::app_event::AppEvent;
#[cfg(target_os = "windows")]
use crate::app_event::WindowsSandboxEnableMode;
#[cfg(target_os = "windows")]
use crate::app_event::WindowsSandboxFallbackReason;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::ApprovalRequest;
use crate::chatwidget::ChatWidget;
@@ -1569,19 +1573,91 @@ impl App {
AppEvent::OpenWindowsSandboxEnablePrompt { preset } => {
self.chat_widget.open_windows_sandbox_enable_prompt(preset);
}
AppEvent::EnableWindowsSandboxForAgentMode { preset } => {
AppEvent::OpenWindowsSandboxFallbackPrompt { preset, reason } => {
self.chat_widget.clear_windows_sandbox_setup_status();
self.chat_widget
.open_windows_sandbox_fallback_prompt(preset, reason);
}
AppEvent::BeginWindowsSandboxElevatedSetup { 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();
// If the elevated setup already ran on this machine, don't prompt for
// elevation again - just flip the config to use the elevated path.
if codex_core::windows_sandbox::sandbox_setup_is_complete(codex_home.as_path())
{
tx.send(AppEvent::EnableWindowsSandboxForAgentMode {
preset,
mode: WindowsSandboxEnableMode::Elevated,
});
return Ok(true);
}
self.chat_widget.show_windows_sandbox_setup_status();
tokio::task::spawn_blocking(move || {
let result = codex_core::windows_sandbox::run_elevated_setup(
&policy,
policy_cwd.as_path(),
command_cwd.as_path(),
&env_map,
codex_home.as_path(),
);
let event = match result {
Ok(()) => AppEvent::EnableWindowsSandboxForAgentMode {
preset: preset.clone(),
mode: WindowsSandboxEnableMode::Elevated,
},
Err(err) => {
tracing::error!(
error = %err,
"failed to run elevated Windows sandbox setup"
);
AppEvent::OpenWindowsSandboxFallbackPrompt {
preset,
reason: WindowsSandboxFallbackReason::ElevationFailed,
}
}
};
tx.send(event);
});
}
#[cfg(not(target_os = "windows"))]
{
let _ = preset;
}
}
AppEvent::EnableWindowsSandboxForAgentMode { preset, mode } => {
#[cfg(target_os = "windows")]
{
self.chat_widget.clear_windows_sandbox_setup_status();
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);
match ConfigEditsBuilder::new(&self.config.codex_home)
.with_profile(profile)
.set_feature_enabled(feature_key, true)
.set_feature_enabled(elevated_key, elevated_enabled)
.apply()
.await
{
Ok(()) => {
self.config.set_windows_sandbox_globally(true);
self.config
.set_windows_elevated_sandbox_globally(elevated_enabled);
self.chat_widget
.set_feature_enabled(Feature::WindowsSandbox, true);
self.chat_widget.set_feature_enabled(
Feature::WindowsSandboxElevated,
elevated_enabled,
);
self.chat_widget.clear_forced_auto_mode_downgrade();
if let Some((sample_paths, extra_count, failed_scan)) =
self.chat_widget.world_writable_warning_details()
@@ -1610,7 +1686,14 @@ impl App {
self.app_event_tx
.send(AppEvent::UpdateSandboxPolicy(preset.sandbox.clone()));
self.chat_widget.add_info_message(
"Enabled experimental Windows sandbox.".to_string(),
match mode {
WindowsSandboxEnableMode::Elevated => {
"Enabled elevated agent sandbox.".to_string()
}
WindowsSandboxEnableMode::Legacy => {
"Enabled non-elevated agent sandbox.".to_string()
}
},
None,
);
}
@@ -1628,7 +1711,7 @@ impl App {
}
#[cfg(not(target_os = "windows"))]
{
let _ = preset;
let _ = (preset, mode);
}
}
AppEvent::PersistModelSelection { model, effort } => {
+27
View File
@@ -14,6 +14,19 @@ use codex_core::protocol::AskForApproval;
use codex_core::protocol::SandboxPolicy;
use codex_protocol::openai_models::ReasoningEffort;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
pub(crate) enum WindowsSandboxEnableMode {
Elevated,
Legacy,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
pub(crate) enum WindowsSandboxFallbackReason {
ElevationFailed,
}
#[allow(clippy::large_enum_variant)]
#[derive(Debug)]
pub(crate) enum AppEvent {
@@ -105,10 +118,24 @@ pub(crate) enum AppEvent {
preset: ApprovalPreset,
},
/// Open the Windows sandbox fallback prompt after declining or failing elevation.
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
OpenWindowsSandboxFallbackPrompt {
preset: ApprovalPreset,
reason: WindowsSandboxFallbackReason,
},
/// Begin the elevated Windows sandbox setup flow.
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
BeginWindowsSandboxElevatedSetup {
preset: ApprovalPreset,
},
/// Enable the Windows sandbox feature and switch to Agent mode.
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
EnableWindowsSandboxForAgentMode {
preset: ApprovalPreset,
mode: WindowsSandboxEnableMode,
},
/// Update the current approval policy in the running app and widget.
+28 -38
View File
@@ -67,6 +67,13 @@ use std::path::PathBuf;
use std::time::Duration;
use std::time::Instant;
fn windows_degraded_sandbox_active() -> bool {
cfg!(target_os = "windows")
&& codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED
&& codex_core::get_platform_sandbox().is_some()
&& !codex_core::is_windows_elevated_sandbox_enabled()
}
/// If the pasted content exceeds this number of characters, replace it with a
/// placeholder in the UI.
const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000;
@@ -1146,6 +1153,10 @@ impl ChatComposer {
&& rest.is_empty()
&& let Some((_n, cmd)) = built_in_slash_commands()
.into_iter()
.filter(|(_, cmd)| {
windows_degraded_sandbox_active()
|| *cmd != SlashCommand::ElevateSandbox
})
.find(|(n, _)| *n == name)
{
self.textarea.set_text("");
@@ -1213,6 +1224,10 @@ impl ChatComposer {
if !treat_as_plain_text {
let is_builtin = built_in_slash_commands()
.into_iter()
.filter(|(_, cmd)| {
windows_degraded_sandbox_active()
|| *cmd != SlashCommand::ElevateSandbox
})
.any(|(command_name, _)| command_name == name);
let prompt_prefix = format!("{PROMPTS_CMD_PREFIX}:");
let is_known_prompt = name
@@ -1659,6 +1674,15 @@ impl ChatComposer {
fn sync_popups(&mut self) {
let file_token = Self::current_at_token(&self.textarea);
let browsing_history = self
.history
.should_handle_navigation(self.textarea.text(), self.textarea.cursor());
// When browsing input history (shell-style Up/Down recall), skip all popup
// synchronization so nothing steals focus from continued history navigation.
if browsing_history {
self.active_popup = ActivePopup::None;
return;
}
let skill_token = self.current_skill_token();
let allow_command_popup = file_token.is_none() && skill_token.is_none();
@@ -1728,6 +1752,9 @@ impl ChatComposer {
let builtin_match = built_in_slash_commands()
.into_iter()
.filter(|(_, cmd)| {
windows_degraded_sandbox_active() || *cmd != SlashCommand::ElevateSandbox
})
.any(|(cmd_name, _)| fuzzy_match(cmd_name, name).is_some());
if builtin_match {
@@ -2975,44 +3002,6 @@ mod tests {
assert!(composer.textarea.is_empty(), "composer should be cleared");
}
#[test]
fn slash_review_with_args_dispatches_command_with_args() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
type_chars_humanlike(&mut composer, &['/', 'r', 'e', 'v', 'i', 'e', 'w', ' ']);
type_chars_humanlike(&mut composer, &['f', 'i', 'x', ' ', 't', 'h', 'i', 's']);
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match result {
InputResult::CommandWithArgs(cmd, args) => {
assert_eq!(cmd, SlashCommand::Review);
assert_eq!(args, "fix this");
}
InputResult::Command(cmd) => {
panic!("expected args for '/review', got bare command: {cmd:?}")
}
InputResult::Submitted(text) => {
panic!("expected command dispatch, got literal submit: {text}")
}
InputResult::None => panic!("expected CommandWithArgs result for '/review'"),
}
assert!(composer.textarea.is_empty(), "composer should be cleared");
}
#[test]
fn extract_args_supports_quoted_paths_single_arg() {
let args = extract_positional_args_for_prompt_line(
@@ -4303,6 +4292,7 @@ mod tests {
"'/zzz' should not activate slash popup because it is not a prefix of any built-in command"
);
}
#[test]
fn input_disabled_ignores_keypresses_and_hides_cursor() {
use crossterm::event::KeyCode;
@@ -15,6 +15,13 @@ use codex_protocol::custom_prompts::CustomPrompt;
use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX;
use std::collections::HashSet;
fn windows_degraded_sandbox_active() -> bool {
cfg!(target_os = "windows")
&& codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED
&& codex_core::get_platform_sandbox().is_some()
&& !codex_core::is_windows_elevated_sandbox_enabled()
}
/// A selectable item in the popup: either a built-in command or a user prompt.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum CommandItem {
@@ -32,9 +39,11 @@ pub(crate) struct CommandPopup {
impl CommandPopup {
pub(crate) fn new(mut prompts: Vec<CustomPrompt>, skills_enabled: bool) -> Self {
let allow_elevate_sandbox = windows_degraded_sandbox_active();
let builtins: Vec<(&'static str, SlashCommand)> = built_in_slash_commands()
.into_iter()
.filter(|(_, cmd)| skills_enabled || *cmd != SlashCommand::Skills)
.filter(|(_, cmd)| allow_elevate_sandbox || *cmd != SlashCommand::ElevateSandbox)
.collect();
// Exclude prompts that collide with builtin command names and sort by name.
let exclude: HashSet<String> = builtins.iter().map(|(n, _)| (*n).to_string()).collect();
+307 -18
View File
@@ -10,6 +10,7 @@ use codex_backend_client::Client as BackendClient;
use codex_core::config::Config;
use codex_core::config::ConstraintResult;
use codex_core::config::types::Notifications;
use codex_core::features::Feature;
use codex_core::git_info::current_branch_name;
use codex_core::git_info::local_git_branches;
use codex_core::models_manager::manager::ModelsManager;
@@ -83,6 +84,9 @@ use tokio::task::JoinHandle;
use tracing::debug;
use crate::app_event::AppEvent;
#[cfg(target_os = "windows")]
use crate::app_event::WindowsSandboxEnableMode;
use crate::app_event::WindowsSandboxFallbackReason;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::ApprovalRequest;
use crate::bottom_pane::BottomPane;
@@ -1570,6 +1574,45 @@ impl ChatWidget {
SlashCommand::Approvals => {
self.open_approvals_popup();
}
SlashCommand::ElevateSandbox => {
#[cfg(target_os = "windows")]
{
let windows_degraded_sandbox_enabled = codex_core::get_platform_sandbox()
.is_some()
&& !codex_core::is_windows_elevated_sandbox_enabled();
if !windows_degraded_sandbox_enabled
|| !codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED
{
// This command should not be visible/recognized outside degraded mode,
// but guard anyway in case something dispatches it directly.
return;
}
let Some(preset) = builtin_approval_presets()
.into_iter()
.find(|preset| preset.id == "auto")
else {
// Avoid panicking in interactive UI; treat this as a recoverable
// internal error.
self.add_error_message(
"Internal error: missing the 'auto' approval preset.".to_string(),
);
return;
};
if let Err(err) = self.config.approval_policy.can_set(&preset.approval) {
self.add_error_message(err.to_string());
return;
}
self.app_event_tx
.send(AppEvent::BeginWindowsSandboxElevatedSetup { preset });
}
#[cfg(not(target_os = "windows"))]
{
// Not supported; on non-Windows this command should never be reachable.
};
}
SlashCommand::Quit | SlashCommand::Exit => {
self.request_exit();
}
@@ -2594,10 +2637,25 @@ impl ChatWidget {
let current_sandbox = self.config.sandbox_policy.get();
let mut items: Vec<SelectionItem> = Vec::new();
let presets: Vec<ApprovalPreset> = builtin_approval_presets();
#[cfg(target_os = "windows")]
let windows_degraded_sandbox_enabled = codex_core::get_platform_sandbox().is_some()
&& !codex_core::is_windows_elevated_sandbox_enabled();
#[cfg(not(target_os = "windows"))]
let windows_degraded_sandbox_enabled = false;
let show_elevate_sandbox_hint = codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED
&& windows_degraded_sandbox_enabled
&& presets.iter().any(|preset| preset.id == "auto");
for preset in presets.into_iter() {
let is_current =
Self::preset_matches_current(current_approval, current_sandbox, &preset);
let name = preset.label.to_string();
let name = if preset.id == "auto" && windows_degraded_sandbox_enabled {
"Agent (non-elevated sandbox)".to_string()
} else {
preset.label.to_string()
};
let description_text = preset.description;
let description = Some(description_text.to_string());
let requires_confirmation = preset.id == "full-access"
@@ -2618,11 +2676,24 @@ impl ChatWidget {
{
if codex_core::get_platform_sandbox().is_none() {
let preset_clone = preset.clone();
vec![Box::new(move |tx| {
tx.send(AppEvent::OpenWindowsSandboxEnablePrompt {
preset: preset_clone.clone(),
});
})]
if codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED
&& codex_core::windows_sandbox::sandbox_setup_is_complete(
self.config.codex_home.as_path(),
)
{
vec![Box::new(move |tx| {
tx.send(AppEvent::EnableWindowsSandboxForAgentMode {
preset: preset_clone.clone(),
mode: WindowsSandboxEnableMode::Elevated,
});
})]
} else {
vec![Box::new(move |tx| {
tx.send(AppEvent::OpenWindowsSandboxEnablePrompt {
preset: preset_clone.clone(),
});
})]
}
} else if let Some((sample_paths, extra_count, failed_scan)) =
self.world_writable_warning_details()
{
@@ -2656,8 +2727,18 @@ 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(),
".".dim(),
]
.into()
});
self.bottom_pane.show_selection_view(SelectionViewParams {
title: Some("Select Approval Mode".to_string()),
footer_note,
footer_hint: Some(standard_popup_hint_line()),
items,
header: Box::new(()),
@@ -2915,36 +2996,106 @@ impl ChatWidget {
pub(crate) fn open_windows_sandbox_enable_prompt(&mut self, preset: ApprovalPreset) {
use ratatui_macros::line;
if !codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED {
// Legacy flow (pre-NUX): explain the experimental sandbox and let the user enable it
// directly (no elevation prompts).
let mut header = ColumnRenderable::new();
header.push(*Box::new(
Paragraph::new(vec![
line!["Agent mode on Windows uses an experimental sandbox to limit network and filesystem access.".bold()],
line!["Learn more: https://developers.openai.com/codex/windows"],
])
.wrap(Wrap { trim: false }),
));
let preset_clone = preset;
let items = vec![
SelectionItem {
name: "Enable experimental sandbox".to_string(),
description: None,
actions: vec![Box::new(move |tx| {
tx.send(AppEvent::EnableWindowsSandboxForAgentMode {
preset: preset_clone.clone(),
mode: WindowsSandboxEnableMode::Legacy,
});
})],
dismiss_on_select: true,
..Default::default()
},
SelectionItem {
name: "Go back".to_string(),
description: None,
actions: vec![Box::new(|tx| {
tx.send(AppEvent::OpenApprovalsPopup);
})],
dismiss_on_select: true,
..Default::default()
},
];
self.bottom_pane.show_selection_view(SelectionViewParams {
title: None,
footer_hint: Some(standard_popup_hint_line()),
items,
header: Box::new(header),
..Default::default()
});
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)
});
let 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()
};
let stay_label = if stay_full_access {
"Stay in Agent Full Access".to_string()
} else {
"Stay in Read-Only".to_string()
};
let mut header = ColumnRenderable::new();
header.push(*Box::new(
Paragraph::new(vec![
line!["Agent mode on Windows uses an experimental sandbox to limit network and filesystem access.".bold()],
line![
"Learn more: https://developers.openai.com/codex/windows"
],
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"],
])
.wrap(Wrap { trim: false }),
));
let preset_clone = preset;
let items = vec![
SelectionItem {
name: "Enable experimental sandbox".to_string(),
name: "Set up agent sandbox (requires elevation)".to_string(),
description: None,
actions: vec![Box::new(move |tx| {
tx.send(AppEvent::EnableWindowsSandboxForAgentMode {
preset: preset_clone.clone(),
tx.send(AppEvent::BeginWindowsSandboxElevatedSetup {
preset: preset.clone(),
});
})],
dismiss_on_select: true,
..Default::default()
},
SelectionItem {
name: "Go back".to_string(),
name: stay_label,
description: None,
actions: vec![Box::new(|tx| {
tx.send(AppEvent::OpenApprovalsPopup);
})],
actions: stay_actions,
dismiss_on_select: true,
..Default::default()
},
@@ -2962,6 +3113,107 @@ impl ChatWidget {
#[cfg(not(target_os = "windows"))]
pub(crate) fn open_windows_sandbox_enable_prompt(&mut self, _preset: ApprovalPreset) {}
#[cfg(target_os = "windows")]
pub(crate) fn open_windows_sandbox_fallback_prompt(
&mut self,
preset: ApprovalPreset,
reason: WindowsSandboxFallbackReason,
) {
use ratatui_macros::line;
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 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()
};
let stay_label = if stay_full_access {
"Stay in Agent Full Access".to_string()
} else {
"Stay in Read-Only".to_string()
};
let mut lines = Vec::new();
lines.push(line!["Use Non-Elevated Sandbox?".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."
]);
lines.push(line![
"Learn more: https://developers.openai.com/codex/windows"
]);
let mut header = ColumnRenderable::new();
header.push(*Box::new(Paragraph::new(lines).wrap(Wrap { trim: false })));
let elevated_preset = preset.clone();
let legacy_preset = preset;
let items = vec![
SelectionItem {
name: "Try elevated agent sandbox setup again".to_string(),
description: None,
actions: vec![Box::new(move |tx| {
tx.send(AppEvent::BeginWindowsSandboxElevatedSetup {
preset: elevated_preset.clone(),
});
})],
dismiss_on_select: true,
..Default::default()
},
SelectionItem {
name: "Use non-elevated agent sandbox".to_string(),
description: None,
actions: vec![Box::new(move |tx| {
tx.send(AppEvent::EnableWindowsSandboxForAgentMode {
preset: legacy_preset.clone(),
mode: WindowsSandboxEnableMode::Legacy,
});
})],
dismiss_on_select: true,
..Default::default()
},
SelectionItem {
name: stay_label,
description: None,
actions: stay_actions,
dismiss_on_select: true,
..Default::default()
},
];
self.bottom_pane.show_selection_view(SelectionViewParams {
title: None,
footer_hint: Some(standard_popup_hint_line()),
items,
header: Box::new(header),
..Default::default()
});
}
#[cfg(not(target_os = "windows"))]
pub(crate) fn open_windows_sandbox_fallback_prompt(
&mut self,
_preset: ApprovalPreset,
_reason: WindowsSandboxFallbackReason,
) {
}
#[cfg(target_os = "windows")]
pub(crate) fn maybe_prompt_windows_sandbox_enable(&mut self) {
if self.config.forced_auto_mode_downgraded_on_windows
@@ -2977,6 +3229,34 @@ impl ChatWidget {
#[cfg(not(target_os = "windows"))]
pub(crate) fn maybe_prompt_windows_sandbox_enable(&mut self) {}
#[cfg(target_os = "windows")]
pub(crate) fn show_windows_sandbox_setup_status(&mut self) {
// While elevated sandbox setup runs, prevent typing so the user doesn't
// accidentally queue messages that will run under an unexpected mode.
self.bottom_pane.set_composer_input_enabled(
false,
Some("Input disabled until setup completes.".to_string()),
);
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.request_redraw();
}
#[cfg(not(target_os = "windows"))]
#[allow(dead_code)]
pub(crate) fn show_windows_sandbox_setup_status(&mut self) {}
#[cfg(target_os = "windows")]
pub(crate) fn clear_windows_sandbox_setup_status(&mut self) {
self.bottom_pane.set_composer_input_enabled(true, None);
self.bottom_pane.hide_status_indicator();
self.request_redraw();
}
#[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;
@@ -3009,6 +3289,15 @@ impl ChatWidget {
Ok(())
}
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
pub(crate) fn set_feature_enabled(&mut self, feature: Feature, enabled: bool) {
if enabled {
self.config.features.enable(feature);
} else {
self.config.features.disable(feature);
}
}
pub(crate) fn set_full_access_warning_acknowledged(&mut self, acknowledged: bool) {
self.config.notices.hide_full_access_warning = Some(acknowledged);
}
@@ -0,0 +1,18 @@
---
source: tui2/src/chatwidget/tests.rs
assertion_line: 1773
expression: popup
---
Select Approval Mode
1. Read Only (current) Requires approval to edit files and run
commands.
2. Agent (non-elevated sandbox) Read and edit files, and run commands.
3. Agent (full access) Codex can edit files outside this workspace
and run commands with network access.
Exercise caution when using.
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 /setup-elevated-sandbox.
Press enter to confirm or esc to go back
+48 -6
View File
@@ -10,6 +10,8 @@ use codex_core::CodexAuth;
use codex_core::config::Config;
use codex_core::config::ConfigBuilder;
use codex_core::config::Constrained;
#[cfg(target_os = "windows")]
use codex_core::features::Feature;
use codex_core::models_manager::manager::ModelsManager;
use codex_core::protocol::AgentMessageDeltaEvent;
use codex_core::protocol::AgentMessageEvent;
@@ -60,6 +62,8 @@ use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use insta::assert_snapshot;
use pretty_assertions::assert_eq;
#[cfg(target_os = "windows")]
use serial_test::serial;
use std::collections::HashSet;
use std::path::PathBuf;
use tempfile::NamedTempFile;
@@ -72,6 +76,11 @@ fn set_windows_sandbox_enabled(enabled: bool) {
codex_core::set_windows_sandbox_enabled(enabled);
}
#[cfg(target_os = "windows")]
fn set_windows_elevated_sandbox_enabled(enabled: bool) {
codex_core::set_windows_elevated_sandbox_enabled(enabled);
}
async fn test_config() -> Config {
// Use base defaults to avoid depending on host state.
let codex_home = std::env::temp_dir();
@@ -1786,6 +1795,35 @@ async fn approvals_selection_popup_snapshot() {
assert_snapshot!("approvals_selection_popup", popup);
}
#[cfg(target_os = "windows")]
#[tokio::test]
#[serial]
async fn approvals_selection_popup_snapshot_windows_degraded_sandbox() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
let was_sandbox_enabled = codex_core::get_platform_sandbox().is_some();
let was_elevated_enabled = codex_core::is_windows_elevated_sandbox_enabled();
chat.config.notices.hide_full_access_warning = None;
chat.config.features.enable(Feature::WindowsSandbox);
chat.config
.features
.disable(Feature::WindowsSandboxElevated);
set_windows_sandbox_enabled(true);
set_windows_elevated_sandbox_enabled(false);
chat.open_approvals_popup();
let popup = render_bottom_popup(&chat, 80);
insta::with_settings!({ snapshot_suffix => "windows_degraded" }, {
assert_snapshot!("approvals_selection_popup", popup);
});
// Avoid leaking sandbox global state into other tests.
set_windows_sandbox_enabled(was_sandbox_enabled);
set_windows_elevated_sandbox_enabled(was_elevated_enabled);
}
#[tokio::test]
async fn preset_matching_ignores_extra_writable_roots() {
let preset = builtin_approval_presets()
@@ -1836,8 +1874,8 @@ async fn windows_auto_mode_prompt_requests_enabling_sandbox_feature() {
let popup = render_bottom_popup(&chat, 120);
assert!(
popup.contains("Agent mode on Windows uses an experimental sandbox"),
"expected auto mode prompt to mention enabling the sandbox feature, popup: {popup}"
popup.contains("requires elevation"),
"expected auto mode prompt to mention elevation, popup: {popup}"
);
}
@@ -1853,12 +1891,16 @@ async fn startup_prompts_for_windows_sandbox_when_agent_requested() {
let popup = render_bottom_popup(&chat, 120);
assert!(
popup.contains("Agent mode on Windows uses an experimental sandbox"),
"expected startup prompt to explain sandbox: {popup}"
popup.contains("requires elevation"),
"expected startup prompt to explain elevation: {popup}"
);
assert!(
popup.contains("Enable experimental sandbox"),
"expected startup prompt to offer enabling the sandbox: {popup}"
popup.contains("Set up agent sandbox"),
"expected startup prompt to offer agent sandbox setup: {popup}"
);
assert!(
popup.contains("Stay in"),
"expected startup prompt to offer staying in current mode: {popup}"
);
set_windows_sandbox_enabled(true);
+4
View File
@@ -14,6 +14,8 @@ pub enum SlashCommand {
// more frequently used commands should be listed first.
Model,
Approvals,
#[strum(serialize = "setup-elevated-sandbox")]
ElevateSandbox,
Skills,
Review,
New,
@@ -51,6 +53,7 @@ impl SlashCommand {
SlashCommand::Status => "show current session configuration and token usage",
SlashCommand::Model => "choose what model and reasoning effort to use",
SlashCommand::Approvals => "choose what Codex can do without approval",
SlashCommand::ElevateSandbox => "set up elevated agent sandbox",
SlashCommand::Mcp => "list configured MCP tools",
SlashCommand::Logout => "log out of Codex",
SlashCommand::Rollout => "print the rollout file path",
@@ -74,6 +77,7 @@ impl SlashCommand {
// | SlashCommand::Undo
| SlashCommand::Model
| SlashCommand::Approvals
| SlashCommand::ElevateSandbox
| SlashCommand::Review
| SlashCommand::Logout => false,
SlashCommand::Diff
@@ -30,6 +30,18 @@ pub struct SandboxCreds {
pub password: String,
}
/// Returns true when the on-disk setup artifacts exist and match the current
/// setup version.
///
/// This reuses the same marker/users validation used by `require_logon_sandbox_creds`.
pub fn sandbox_setup_is_complete(codex_home: &Path) -> bool {
let marker_ok = matches!(load_marker(codex_home), Ok(Some(marker)) if marker.version_matches());
if !marker_ok {
return false;
}
matches!(load_users(codex_home), Ok(Some(users)) if users.version_matches())
}
fn load_marker(codex_home: &Path) -> Result<Option<SetupMarker>> {
let path = setup_marker_path(codex_home);
let marker = match fs::read_to_string(&path) {
+2
View File
@@ -45,6 +45,8 @@ pub use hide_users::hide_newly_created_users;
#[cfg(target_os = "windows")]
pub use identity::require_logon_sandbox_creds;
#[cfg(target_os = "windows")]
pub use identity::sandbox_setup_is_complete;
#[cfg(target_os = "windows")]
pub use logging::log_note;
#[cfg(target_os = "windows")]
pub use logging::LOG_FILE_NAME;