Constrain Windows sandbox requirements (#23766)

# Why

Managed requirements can already constrain sandbox policy choices, but
Windows sandbox implementation selection was still resolved
independently from those requirements. That left the TUI able to
continue through the unelevated fallback even when an organization wants
to require the elevated Windows sandbox implementation.

# What

- Add `[windows].allowed_sandbox_implementations` requirements support
for the Windows `elevated` and `unelevated` implementations.
- Apply that allowlist during core config resolution so disallowed
configured or feature-selected Windows sandbox implementations fall back
to an allowed implementation with the existing requirements warning
path.
- Reuse the existing TUI Windows setup prompts to block disallowed
unelevated continuation, keep required elevated setup in front of the
user, and refuse to persist a TUI-selected Windows sandbox mode that
requirements disallow.

# Semantics

| Allowed | Selected | Effective |
| --- | --- | --- |
| `["elevated"]` | `unelevated` / unset | `elevated` |
| `["unelevated"]` | `elevated` / unset | `unelevated` |
| `["elevated", "unelevated"]` | `elevated` | `elevated` |
| `["elevated", "unelevated"]` | `unelevated` | `unelevated` |
| `["elevated", "unelevated"]` | unset | `elevated` |

Availability is handled by interactive setup surfaces after allowlist
resolution. If the effective elevated implementation is not ready,
elevated-only requirements block on setup. When unelevated is also
allowed, the UI may offer the existing unelevated fallback.

## TUI Screens

If elevated setup is not already complete:
```
  Your organization requires the default Codex agent sandbox to continue. Set it up to protect your files and control
  network access.
  Learn more <https://developers.openai.com/codex/windows>

› 1. Set up default sandbox (requires Administrator permissions)
  2. Quit
```

If admin setup fails under `["elevated"]`:
```
  Couldn't set up your sandbox with Administrator permissions

  Your organization requires the default sandbox before Codex can continue.
  Learn more <https://developers.openai.com/codex/windows>

› 1. Try setting up admin sandbox again
  2. Quit
```

# Next Steps


- extend the requirements/readout surface, such as
`configRequirements/read`, so clients can inspect the loaded
`[windows].allowed_sandbox_implementations` requirement instead of
inferring it from Windows setup state
- consider extending `windowsSandbox/readiness` as well
- update the App startup guide, setup flow, and banner surfaces so an
elevated-only requirement omits any continue-unelevated escape hatch and
blocks startup until a permitted implementation is ready;
- preserve the existing unelevated fallback path when requirements allow
it, including the `["unelevated"]` case where elevated is disallowed
This commit is contained in:
Abhinav authored and GitHub committed 2026-05-29 16:31:33 -07:00
1 parent 8e5f561697
commit a5a94ee5a7
25 files changed
+821 -143

No files matched your search

@@ -8,6 +8,13 @@ impl ChatWidget {
}
pub(crate) fn submit_initial_user_message_if_pending(&mut self) {
if self.suppress_initial_user_message_submit {
return;
}
#[cfg(any(target_os = "windows", test))]
if self.elevated_windows_sandbox_setup_required() {
return;
}
if let Some(user_message) = self.initial_user_message.take() {
self.submit_user_message(user_message);
}
+1 -7
View File
@@ -135,13 +135,7 @@ impl ChatWidget {
if self.connectors_enabled() {
self.prefetch_connectors();
}
if let Some(user_message) = self.initial_user_message.take() {
if self.suppress_initial_user_message_submit {
self.initial_user_message = Some(user_message);
} else {
self.submit_user_message(user_message);
}
}
self.submit_initial_user_message_if_pending();
if display == SessionConfiguredDisplay::Normal
&& let Some(forked_from_id) = forked_from_id
{
@@ -0,0 +1,12 @@
---
source: tui/src/chatwidget/tests/permissions.rs
expression: "render_bottom_popup(&chat, 120)"
---
Your organization requires the default Codex agent sandbox to continue. Set it up to protect your files and control
network access.
Learn more <https://developers.openai.com/codex/windows>
› 1. Set up default sandbox (requires Administrator permissions)
2. Quit
Press enter to confirm or esc to go back
@@ -0,0 +1,13 @@
---
source: tui/src/chatwidget/tests/permissions.rs
expression: popup
---
Couldn't set up your sandbox with Administrator permissions
Your organization requires the default sandbox before Codex can continue.
Learn more <https://developers.openai.com/codex/windows>
› 1. Try setting up admin sandbox again
2. Quit
Press enter to confirm or esc to go back
-1
View File
@@ -123,7 +123,6 @@ pub(super) use codex_config::ConfigLayerStack;
pub(super) use codex_config::RequirementSource;
pub(super) use codex_config::types::ApprovalsReviewer;
pub(super) use codex_config::types::Notifications;
#[cfg(target_os = "windows")]
pub(super) use codex_config::types::WindowsSandboxModeToml;
pub(super) use codex_core_plugins::OPENAI_CURATED_MARKETPLACE_NAME;
pub(super) use codex_core_skills::model::SkillMetadata;
@@ -48,6 +48,25 @@ fn app_server_workspace_write_profile(extra_root: AbsolutePathBuf) -> Permission
}
}
fn windows_sandbox_requirements_stack(
allowed_sandbox_implementations: Vec<WindowsSandboxModeToml>,
) -> ConfigLayerStack {
let requirements_toml = codex_config::ConfigRequirementsToml {
windows: Some(codex_config::WindowsRequirementsToml {
allowed_sandbox_implementations: Some(allowed_sandbox_implementations),
}),
..Default::default()
};
let mut requirements_with_sources = codex_config::ConfigRequirementsWithSources::default();
requirements_with_sources
.merge_unset_fields(RequirementSource::Unknown, requirements_toml.clone());
let requirements = codex_config::ConfigRequirements::try_from(requirements_with_sources)
.expect("windows sandbox requirements");
ConfigLayerStack::new(Vec::new(), requirements, requirements_toml)
.expect("test config layer stack")
}
#[tokio::test]
async fn approvals_selection_popup_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
@@ -404,6 +423,148 @@ async fn startup_prompts_for_windows_sandbox_when_agent_requested() {
);
}
#[cfg(target_os = "windows")]
#[tokio::test]
async fn startup_windows_sandbox_prompt_blocks_disallowed_unelevated_fallback() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.set_feature_enabled(Feature::WindowsSandbox, /*enabled*/ false);
chat.set_feature_enabled(Feature::WindowsSandboxElevated, /*enabled*/ false);
chat.config.config_layer_stack =
windows_sandbox_requirements_stack(vec![WindowsSandboxModeToml::Elevated]);
chat.maybe_prompt_windows_sandbox_enable(/*show_now*/ true);
let popup = render_bottom_popup(&chat, /*width*/ 120);
assert!(
popup.contains("Your organization requires the default Codex agent sandbox"),
"expected required sandbox prompt copy: {popup}"
);
assert!(
!popup.contains("Use non-admin sandbox"),
"expected required sandbox prompt to hide non-admin fallback: {popup}"
);
}
#[tokio::test]
async fn windows_sandbox_required_enable_prompt_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.config.config_layer_stack =
windows_sandbox_requirements_stack(vec![WindowsSandboxModeToml::Elevated]);
let preset = builtin_approval_presets()
.into_iter()
.find(|preset| preset.id == "auto")
.expect("auto preset");
chat.open_windows_sandbox_enable_prompt(preset, /*profile_selection*/ None);
assert_chatwidget_snapshot!(
"windows_sandbox_required_enable_prompt",
render_bottom_popup(&chat, /*width*/ 120)
);
}
#[tokio::test]
async fn windows_sandbox_required_enable_prompt_reopens_on_cancel_when_unelevated_allowed() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.config.permissions.windows_sandbox_mode = Some(WindowsSandboxModeToml::Elevated);
chat.config.config_layer_stack = windows_sandbox_requirements_stack(vec![
WindowsSandboxModeToml::Elevated,
WindowsSandboxModeToml::Unelevated,
]);
let preset = builtin_approval_presets()
.into_iter()
.find(|preset| preset.id == "auto")
.expect("auto preset");
chat.open_windows_sandbox_enable_prompt(preset, /*profile_selection*/ None);
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(matches!(
rx.try_recv(),
Ok(AppEvent::OpenWindowsSandboxEnablePrompt { .. })
));
}
#[tokio::test]
async fn required_windows_sandbox_setup_defers_configured_initial_prompt() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let initial_prompt = "fix required sandbox startup".to_string();
chat.config.permissions.windows_sandbox_mode = Some(WindowsSandboxModeToml::Elevated);
chat.config.config_layer_stack = windows_sandbox_requirements_stack(vec![
WindowsSandboxModeToml::Elevated,
WindowsSandboxModeToml::Unelevated,
]);
chat.initial_user_message =
create_initial_user_message(Some(initial_prompt.clone()), Vec::new(), Vec::new());
chat.handle_thread_session(crate::session_state::ThreadSessionState {
thread_id: ThreadId::new(),
forked_from_id: None,
fork_parent_title: None,
thread_name: None,
model: "gpt-test".to_string(),
model_provider_id: "test-provider".to_string(),
service_tier: None,
approval_policy: AskForApproval::OnRequest,
approvals_reviewer: ApprovalsReviewer::User,
permission_profile: PermissionProfile::workspace_write(),
active_permission_profile: None,
cwd: test_project_path().abs(),
runtime_workspace_roots: Vec::new(),
instruction_source_paths: Vec::new(),
reasoning_effort: None,
collaboration_mode: None,
personality: None,
message_history: None,
network_proxy: None,
rollout_path: Some(PathBuf::new()),
});
drain_insert_history(&mut rx);
assert!(chat.initial_user_message.is_some());
while let Ok(op) = op_rx.try_recv() {
assert!(
!matches!(op, Op::UserTurn { .. }),
"required sandbox setup should hold the configured initial prompt"
);
}
chat.set_windows_sandbox_mode(Some(WindowsSandboxModeToml::Unelevated));
chat.submit_initial_user_message_if_pending();
let Op::UserTurn { items, .. } = next_submit_op(&mut op_rx) else {
panic!("expected initial prompt submission after setup is no longer required");
};
assert_eq!(
items,
vec![UserInput::Text {
text: initial_prompt,
text_elements: Vec::new(),
}]
);
}
#[tokio::test]
async fn windows_sandbox_required_fallback_prompt_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.config.config_layer_stack =
windows_sandbox_requirements_stack(vec![WindowsSandboxModeToml::Elevated]);
let preset = builtin_approval_presets()
.into_iter()
.find(|preset| preset.id == "auto")
.expect("auto preset");
chat.open_windows_sandbox_fallback_prompt(preset, /*profile_selection*/ None);
let popup = render_bottom_popup(&chat, /*width*/ 120);
assert_chatwidget_snapshot!("windows_sandbox_required_fallback_prompt", popup);
}
#[cfg(target_os = "windows")]
#[tokio::test]
async fn startup_does_not_prompt_for_windows_sandbox_when_not_requested() {
@@ -3,6 +3,31 @@
use super::*;
impl ChatWidget {
#[cfg(any(target_os = "windows", test))]
pub(crate) fn windows_sandbox_mode_allowed(&self, mode: WindowsSandboxModeToml) -> bool {
self.config
.config_layer_stack
.requirements()
.windows_sandbox_mode
.can_set(&Some(mode))
.is_ok()
}
#[cfg(any(target_os = "windows", test))]
pub(super) fn elevated_windows_sandbox_setup_required(&self) -> bool {
WindowsSandboxLevel::from_config(&self.config) == WindowsSandboxLevel::Elevated
&& self
.config
.config_layer_stack
.requirements()
.windows_sandbox_mode
.source
.is_some()
&& !crate::legacy_core::windows_sandbox::sandbox_setup_is_complete(
self.config.codex_home.as_path(),
)
}
#[cfg(target_os = "windows")]
pub(crate) fn world_writable_warning_details(&self) -> Option<(Vec<String>, usize, bool)> {
if self
@@ -193,7 +218,7 @@ impl ChatWidget {
) {
}
#[cfg(target_os = "windows")]
#[cfg(any(target_os = "windows", test))]
pub(crate) fn open_windows_sandbox_enable_prompt(
&mut self,
preset: ApprovalPreset,
@@ -255,11 +280,22 @@ impl ChatWidget {
&[],
);
let allow_unelevated =
self.windows_sandbox_mode_allowed(WindowsSandboxModeToml::Unelevated);
let setup_choice_is_required =
!allow_unelevated || self.elevated_windows_sandbox_setup_required();
let mut header = ColumnRenderable::new();
header.push(*Box::new(
Paragraph::new(vec![
line!["Set up the Codex agent sandbox to protect your files and control network access. Learn more <https://developers.openai.com/codex/windows>"],
])
Paragraph::new(if allow_unelevated {
vec![
line!["Set up the Codex agent sandbox to protect your files and control network access. Learn more <https://developers.openai.com/codex/windows>"],
]
} else {
vec![
line!["Your organization requires the default Codex agent sandbox to continue. Set it up to protect your files and control network access."],
line!["Learn more <https://developers.openai.com/codex/windows>"],
]
})
.wrap(Wrap { trim: false }),
));
@@ -268,25 +304,27 @@ impl ChatWidget {
let legacy_preset = preset.clone();
let legacy_profile_selection = profile_selection.clone();
let quit_otel = self.session_telemetry.clone();
let items = vec![
SelectionItem {
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",
/*inc*/ 1,
&[],
);
tx.send(AppEvent::BeginWindowsSandboxElevatedSetup {
preset: preset.clone(),
profile_selection: profile_selection.clone(),
});
})],
dismiss_on_select: true,
..Default::default()
},
SelectionItem {
let retry_preset = preset.clone();
let retry_profile_selection = profile_selection.clone();
let mut items = vec![SelectionItem {
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",
/*inc*/ 1,
&[],
);
tx.send(AppEvent::BeginWindowsSandboxElevatedSetup {
preset: preset.clone(),
profile_selection: profile_selection.clone(),
});
})],
dismiss_on_select: true,
..Default::default()
}];
if allow_unelevated {
items.push(SelectionItem {
name: "Use non-admin sandbox (higher risk if prompt injected)".to_string(),
description: None,
actions: vec![Box::new(move |tx| {
@@ -302,33 +340,41 @@ impl ChatWidget {
})],
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_quit",
/*inc*/ 1,
&[],
);
tx.send(AppEvent::Exit(ExitMode::ShutdownFirst));
})],
dismiss_on_select: true,
..Default::default()
},
];
});
}
items.push(SelectionItem {
name: "Quit".to_string(),
description: None,
actions: vec![Box::new(move |tx| {
quit_otel.counter(
"codex.windows_sandbox.elevated_prompt_quit",
/*inc*/ 1,
&[],
);
tx.send(AppEvent::Exit(ExitMode::ShutdownFirst));
})],
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),
on_cancel: setup_choice_is_required.then(|| {
Box::new(move |tx: &AppEventSender| {
tx.send(AppEvent::OpenWindowsSandboxEnablePrompt {
preset: retry_preset.clone(),
profile_selection: retry_profile_selection.clone(),
});
}) as _
}),
..Default::default()
});
}
#[cfg(not(target_os = "windows"))]
#[cfg(all(not(target_os = "windows"), not(test)))]
pub(crate) fn open_windows_sandbox_enable_prompt(
&mut self,
_preset: ApprovalPreset,
@@ -336,7 +382,7 @@ impl ChatWidget {
) {
}
#[cfg(target_os = "windows")]
#[cfg(any(target_os = "windows", test))]
pub(crate) fn open_windows_sandbox_fallback_prompt(
&mut self,
preset: ApprovalPreset,
@@ -344,14 +390,24 @@ impl ChatWidget {
) {
use ratatui_macros::line;
let allow_unelevated =
self.windows_sandbox_mode_allowed(WindowsSandboxModeToml::Unelevated);
let setup_choice_is_required =
!allow_unelevated || self.elevated_windows_sandbox_setup_required();
let mut lines = Vec::new();
lines.push(line![
"Couldn't set up your sandbox with Administrator permissions".bold()
]);
lines.push(line![""]);
lines.push(line![
"You can still use Codex in a non-admin sandbox. It carries greater risk if prompt injected."
]);
if allow_unelevated {
lines.push(line![
"You can still use Codex in a non-admin sandbox. It carries greater risk if prompt injected."
]);
} else {
lines.push(line![
"Your organization requires the default sandbox before Codex can continue."
]);
}
lines.push(line![
"Learn more <https://developers.openai.com/codex/windows>"
]);
@@ -361,32 +417,34 @@ impl ChatWidget {
let elevated_preset = preset.clone();
let legacy_preset = preset;
let retry_preset = elevated_preset.clone();
let retry_profile_selection = profile_selection.clone();
let elevated_profile_selection = profile_selection.clone();
let legacy_profile_selection = profile_selection;
let quit_otel = self.session_telemetry.clone();
let items = vec![
SelectionItem {
name: "Try setting up admin sandbox again".to_string(),
description: None,
actions: vec![Box::new({
let otel = self.session_telemetry.clone();
let preset = elevated_preset;
move |tx| {
otel.counter(
"codex.windows_sandbox.fallback_retry_elevated",
/*inc*/ 1,
&[],
);
tx.send(AppEvent::BeginWindowsSandboxElevatedSetup {
preset: preset.clone(),
profile_selection: elevated_profile_selection.clone(),
});
}
})],
dismiss_on_select: true,
..Default::default()
},
SelectionItem {
let mut items = vec![SelectionItem {
name: "Try setting up admin sandbox again".to_string(),
description: None,
actions: vec![Box::new({
let otel = self.session_telemetry.clone();
let preset = elevated_preset;
move |tx| {
otel.counter(
"codex.windows_sandbox.fallback_retry_elevated",
/*inc*/ 1,
&[],
);
tx.send(AppEvent::BeginWindowsSandboxElevatedSetup {
preset: preset.clone(),
profile_selection: elevated_profile_selection.clone(),
});
}
})],
dismiss_on_select: true,
..Default::default()
}];
if allow_unelevated {
items.push(SelectionItem {
name: "Use Codex with non-admin sandbox".to_string(),
description: None,
actions: vec![Box::new({
@@ -406,33 +464,41 @@ impl ChatWidget {
})],
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.fallback_prompt_quit",
/*inc*/ 1,
&[],
);
tx.send(AppEvent::Exit(ExitMode::ShutdownFirst));
})],
dismiss_on_select: true,
..Default::default()
},
];
});
}
items.push(SelectionItem {
name: "Quit".to_string(),
description: None,
actions: vec![Box::new(move |tx| {
quit_otel.counter(
"codex.windows_sandbox.fallback_prompt_quit",
/*inc*/ 1,
&[],
);
tx.send(AppEvent::Exit(ExitMode::ShutdownFirst));
})],
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),
on_cancel: setup_choice_is_required.then(|| {
Box::new(move |tx: &AppEventSender| {
tx.send(AppEvent::OpenWindowsSandboxFallbackPrompt {
preset: retry_preset.clone(),
profile_selection: retry_profile_selection.clone(),
});
}) as _
}),
..Default::default()
});
}
#[cfg(not(target_os = "windows"))]
#[cfg(all(not(target_os = "windows"), not(test)))]
pub(crate) fn open_windows_sandbox_fallback_prompt(
&mut self,
_preset: ApprovalPreset,
@@ -442,8 +508,11 @@ impl ChatWidget {
#[cfg(target_os = "windows")]
pub(crate) fn maybe_prompt_windows_sandbox_enable(&mut self, show_now: bool) {
let windows_sandbox_level = WindowsSandboxLevel::from_config(&self.config);
let setup_is_required = windows_sandbox_level == WindowsSandboxLevel::Disabled
|| self.elevated_windows_sandbox_setup_required();
if show_now
&& WindowsSandboxLevel::from_config(&self.config) == WindowsSandboxLevel::Disabled
&& setup_is_required
&& let Some(preset) = builtin_approval_presets()
.into_iter()
.find(|preset| preset.id == "auto")