Compare commits

...

4 Commits

Author SHA1 Message Date
Dylan Hurd
3f95b0fccb fix(core) hide disallowed full access 2026-02-16 22:18:27 -08:00
Dylan Hurd
75193c0082 chore(core) permissions apis 2026-02-16 22:18:27 -08:00
Dylan Hurd
d2b74dab7a fix(tui) Consolidate Permissions logic 2026-02-16 22:17:04 -08:00
Dylan Hurd
d6e3b6f74d fix(tui) remove config check for trusted setting 2026-02-16 22:16:42 -08:00
8 changed files with 548 additions and 256 deletions

View File

@@ -181,10 +181,6 @@ pub struct Config {
/// using backend-specific headers or URLs to enforce this.
pub enforce_residency: Constrained<Option<ResidencyRequirement>>,
/// True if the user passed in an override or set a value in config.toml
/// for either of approval_policy or sandbox_mode.
pub did_user_set_custom_approval_policy_or_sandbox_mode: bool,
/// When `true`, `AgentReasoning` events emitted by the backend will be
/// suppressed from the frontend output. This can reduce visual noise when
/// users are only interested in the final agent responses.
@@ -1499,9 +1495,6 @@ impl Config {
let active_project = cfg
.get_active_project(&resolved_cwd)
.unwrap_or(ProjectConfig { trust_level: None });
let sandbox_mode_was_explicit = sandbox_mode.is_some()
|| config_profile.sandbox_mode.is_some()
|| cfg.sandbox_mode.is_some();
let windows_sandbox_level = match windows_sandbox_mode {
Some(WindowsSandboxModeToml::Elevated) => WindowsSandboxLevel::Elevated,
@@ -1522,9 +1515,6 @@ impl Config {
}
}
}
let approval_policy_was_explicit = approval_policy_override.is_some()
|| config_profile.approval_policy.is_some()
|| cfg.approval_policy.is_some();
let mut approval_policy = approval_policy_override
.or(config_profile.approval_policy)
.or(cfg.approval_policy)
@@ -1537,9 +1527,7 @@ impl Config {
AskForApproval::default()
}
});
if !approval_policy_was_explicit
&& let Err(err) = requirements.approval_policy.can_set(&approval_policy)
{
if let Err(err) = requirements.approval_policy.can_set(&approval_policy) {
tracing::warn!(
error = %err,
"default approval policy is disallowed by requirements; falling back to required default"
@@ -1548,10 +1536,6 @@ impl Config {
}
let web_search_mode = resolve_web_search_mode(&cfg, &config_profile, &features)
.unwrap_or(WebSearchMode::Cached);
// TODO(dylan): We should be able to leverage ConfigLayerStack so that
// we can reliably check this at every config level.
let did_user_set_custom_approval_policy_or_sandbox_mode =
approval_policy_was_explicit || sandbox_mode_was_explicit;
let mut model_providers = built_in_model_providers();
// Merge user-defined providers into the built-in list.
@@ -1755,7 +1739,6 @@ impl Config {
macos_seatbelt_profile_extensions: None,
},
enforce_residency: enforce_residency.value,
did_user_set_custom_approval_policy_or_sandbox_mode,
notify: cfg.notify,
user_instructions,
base_instructions,
@@ -1951,6 +1934,32 @@ impl Config {
};
}
pub fn permissions_satisfy_requirements(
&self,
candidate: &Permissions,
) -> ConstraintResult<()> {
let candidate_approval_policy = candidate.approval_policy.value();
self.permissions
.approval_policy
.can_set(&candidate_approval_policy)?;
self.permissions
.sandbox_policy
.can_set(candidate.sandbox_policy.get())?;
if let Some(network_requirement) = self.config_layer_stack.requirements().network.as_ref()
&& candidate.network != self.permissions.network
{
return Err(ConstraintError::InvalidValue {
field_name: "experimental_network",
candidate: format!("{:?}", candidate.network),
allowed: format!("{:?}", self.permissions.network),
requirement_source: network_requirement.source.clone(),
});
}
Ok(())
}
pub fn managed_network_requirements_enabled(&self) -> bool {
self.config_layer_stack
.requirements_toml()
@@ -2064,6 +2073,20 @@ mod tests {
}
}
fn unconstrained_permissions_from(config: &Config) -> Permissions {
Permissions {
approval_policy: Constrained::allow_any(config.permissions.approval_policy.value()),
sandbox_policy: Constrained::allow_any(config.permissions.sandbox_policy.get().clone()),
network: config.permissions.network.clone(),
shell_environment_policy: config.permissions.shell_environment_policy.clone(),
windows_sandbox_mode: config.permissions.windows_sandbox_mode,
macos_seatbelt_profile_extensions: config
.permissions
.macos_seatbelt_profile_extensions
.clone(),
}
}
#[test]
fn test_toml_parsing() {
let history_with_persistence = r#"
@@ -2737,7 +2760,6 @@ profile = "project"
config.permissions.sandbox_policy.get(),
&SandboxPolicy::DangerFullAccess
));
assert!(config.did_user_set_custom_approval_policy_or_sandbox_mode);
Ok(())
}
@@ -4102,7 +4124,6 @@ model_verbosity = "high"
macos_seatbelt_profile_extensions: None,
},
enforce_residency: Constrained::allow_any(None),
did_user_set_custom_approval_policy_or_sandbox_mode: true,
user_instructions: None,
notify: None,
cwd: fixture.cwd(),
@@ -4213,7 +4234,6 @@ model_verbosity = "high"
macos_seatbelt_profile_extensions: None,
},
enforce_residency: Constrained::allow_any(None),
did_user_set_custom_approval_policy_or_sandbox_mode: true,
user_instructions: None,
notify: None,
cwd: fixture.cwd(),
@@ -4322,7 +4342,6 @@ model_verbosity = "high"
macos_seatbelt_profile_extensions: None,
},
enforce_residency: Constrained::allow_any(None),
did_user_set_custom_approval_policy_or_sandbox_mode: true,
user_instructions: None,
notify: None,
cwd: fixture.cwd(),
@@ -4417,7 +4436,6 @@ model_verbosity = "high"
macos_seatbelt_profile_extensions: None,
},
enforce_residency: Constrained::allow_any(None),
did_user_set_custom_approval_policy_or_sandbox_mode: true,
user_instructions: None,
notify: None,
cwd: fixture.cwd(),
@@ -4482,24 +4500,6 @@ model_verbosity = "high"
Ok(())
}
#[test]
fn test_did_user_set_custom_approval_policy_or_sandbox_mode_defaults_no() -> anyhow::Result<()>
{
let fixture = create_test_fixture()?;
let config = Config::load_from_base_config_with_overrides(
fixture.cfg.clone(),
ConfigOverrides {
..Default::default()
},
fixture.codex_home(),
)?;
assert!(config.did_user_set_custom_approval_policy_or_sandbox_mode);
Ok(())
}
#[test]
fn test_requirements_web_search_mode_allowlist_does_not_warn_when_unset() -> anyhow::Result<()>
{
@@ -5081,6 +5081,189 @@ mcp_oauth_callback_port = 5678
Ok(())
}
#[tokio::test]
async fn permissions_satisfy_requirements_accepts_valid_candidate() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.cloud_requirements(CloudRequirementsLoader::new(async {
Some(crate::config_loader::ConfigRequirementsToml {
allowed_approval_policies: Some(vec![AskForApproval::OnRequest]),
allowed_sandbox_modes: Some(vec![
crate::config_loader::SandboxModeRequirement::ReadOnly,
]),
..Default::default()
})
}))
.build()
.await?;
let candidate = unconstrained_permissions_from(&config);
config.permissions_satisfy_requirements(&candidate)?;
Ok(())
}
#[tokio::test]
async fn permissions_satisfy_requirements_rejects_disallowed_approval() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.cloud_requirements(CloudRequirementsLoader::new(async {
Some(crate::config_loader::ConfigRequirementsToml {
allowed_approval_policies: Some(vec![AskForApproval::OnRequest]),
..Default::default()
})
}))
.build()
.await?;
let mut candidate = unconstrained_permissions_from(&config);
candidate.approval_policy = Constrained::allow_any(AskForApproval::Never);
let err = config
.permissions_satisfy_requirements(&candidate)
.expect_err("Never should be rejected");
assert!(matches!(
err,
ConstraintError::InvalidValue {
field_name: "approval_policy",
..
}
));
Ok(())
}
#[tokio::test]
async fn permissions_satisfy_requirements_rejects_disallowed_sandbox() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.cloud_requirements(CloudRequirementsLoader::new(async {
Some(crate::config_loader::ConfigRequirementsToml {
allowed_sandbox_modes: Some(vec![
crate::config_loader::SandboxModeRequirement::ReadOnly,
]),
..Default::default()
})
}))
.build()
.await?;
let mut candidate = unconstrained_permissions_from(&config);
candidate.sandbox_policy = Constrained::allow_any(SandboxPolicy::DangerFullAccess);
let err = config
.permissions_satisfy_requirements(&candidate)
.expect_err("danger-full-access should be rejected");
assert!(matches!(
err,
ConstraintError::InvalidValue {
field_name: "sandbox_mode",
..
}
));
Ok(())
}
#[tokio::test]
async fn permissions_satisfy_requirements_enforces_network_exact_match_when_managed()
-> std::io::Result<()> {
let codex_home = TempDir::new()?;
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.cloud_requirements(CloudRequirementsLoader::new(async {
Some(crate::config_loader::ConfigRequirementsToml {
network: Some(crate::config_loader::NetworkRequirementsToml {
enabled: Some(true),
..Default::default()
}),
..Default::default()
})
}))
.build()
.await?;
assert!(config.permissions.network.is_some());
let mut candidate = unconstrained_permissions_from(&config);
candidate.network = None;
let err = config
.permissions_satisfy_requirements(&candidate)
.expect_err("network mismatch should be rejected when managed");
assert!(matches!(
err,
ConstraintError::InvalidValue {
field_name: "experimental_network",
..
}
));
Ok(())
}
#[tokio::test]
async fn permissions_satisfy_requirements_ignores_network_when_unmanaged() -> std::io::Result<()>
{
let codex_home_managed = TempDir::new()?;
let managed_config = ConfigBuilder::default()
.codex_home(codex_home_managed.path().to_path_buf())
.fallback_cwd(Some(codex_home_managed.path().to_path_buf()))
.cloud_requirements(CloudRequirementsLoader::new(async {
Some(crate::config_loader::ConfigRequirementsToml {
network: Some(crate::config_loader::NetworkRequirementsToml {
enabled: Some(true),
..Default::default()
}),
..Default::default()
})
}))
.build()
.await?;
assert!(managed_config.permissions.network.is_some());
let candidate = unconstrained_permissions_from(&managed_config);
let codex_home_unmanaged = TempDir::new()?;
let unmanaged_config = ConfigBuilder::default()
.codex_home(codex_home_unmanaged.path().to_path_buf())
.fallback_cwd(Some(codex_home_unmanaged.path().to_path_buf()))
.build()
.await?;
assert!(unmanaged_config.permissions.network.is_none());
unmanaged_config.permissions_satisfy_requirements(&candidate)?;
Ok(())
}
#[tokio::test]
async fn permissions_satisfy_requirements_ignores_non_requirement_fields() -> std::io::Result<()>
{
let codex_home = TempDir::new()?;
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.cloud_requirements(CloudRequirementsLoader::new(async {
Some(crate::config_loader::ConfigRequirementsToml {
allowed_approval_policies: Some(vec![AskForApproval::OnRequest]),
allowed_sandbox_modes: Some(vec![
crate::config_loader::SandboxModeRequirement::ReadOnly,
]),
..Default::default()
})
}))
.build()
.await?;
let mut candidate = unconstrained_permissions_from(&config);
candidate.shell_environment_policy = ShellEnvironmentPolicy {
use_profile: true,
..ShellEnvironmentPolicy::default()
};
candidate.windows_sandbox_mode = Some(WindowsSandboxModeToml::Elevated);
config.permissions_satisfy_requirements(&candidate)?;
Ok(())
}
#[tokio::test]
async fn requirements_web_search_mode_overrides_danger_full_access_default()
-> std::io::Result<()> {

View File

@@ -1711,12 +1711,8 @@ impl App {
AppEvent::OpenAllModelsPopup { models } => {
self.chat_widget.open_all_models_popup(models);
}
AppEvent::OpenFullAccessConfirmation {
preset,
return_to_permissions,
} => {
self.chat_widget
.open_full_access_confirmation(preset, return_to_permissions);
AppEvent::OpenFullAccessConfirmation { preset } => {
self.chat_widget.open_full_access_confirmation(preset);
}
AppEvent::OpenWorldWritableWarningConfirmation {
preset,

View File

@@ -16,11 +16,11 @@ use codex_core::protocol::RateLimitSnapshot;
use codex_file_search::FileMatch;
use codex_protocol::ThreadId;
use codex_protocol::openai_models::ModelPreset;
use codex_utils_approval_presets::ApprovalPreset;
use crate::bottom_pane::ApprovalRequest;
use crate::bottom_pane::StatusLineItem;
use crate::history_cell::HistoryCell;
use crate::permissions::PermissionsPreset;
use codex_core::features::Feature;
use codex_core::protocol::AskForApproval;
@@ -171,8 +171,7 @@ pub(crate) enum AppEvent {
/// Open the confirmation prompt before enabling full access mode.
OpenFullAccessConfirmation {
preset: ApprovalPreset,
return_to_permissions: bool,
preset: PermissionsPreset,
},
/// Open the Windows world-writable directories warning.
@@ -181,7 +180,7 @@ pub(crate) enum AppEvent {
/// policy change and only acknowledges/dismisses the warning.
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
OpenWorldWritableWarningConfirmation {
preset: Option<ApprovalPreset>,
preset: Option<PermissionsPreset>,
/// Up to 3 sample world-writable directories to display in the warning.
sample_paths: Vec<String>,
/// If there are more than `sample_paths`, this carries the remaining count.
@@ -193,25 +192,25 @@ pub(crate) enum AppEvent {
/// Prompt to enable the Windows sandbox feature before using Agent mode.
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
OpenWindowsSandboxEnablePrompt {
preset: ApprovalPreset,
preset: PermissionsPreset,
},
/// Open the Windows sandbox fallback prompt after declining or failing elevation.
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
OpenWindowsSandboxFallbackPrompt {
preset: ApprovalPreset,
preset: PermissionsPreset,
},
/// Begin the elevated Windows sandbox setup flow.
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
BeginWindowsSandboxElevatedSetup {
preset: ApprovalPreset,
preset: PermissionsPreset,
},
/// Begin the non-elevated Windows sandbox setup flow.
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
BeginWindowsSandboxLegacySetup {
preset: ApprovalPreset,
preset: PermissionsPreset,
},
/// Begin a non-elevated grant of read access for an additional directory.
@@ -230,7 +229,7 @@ pub(crate) enum AppEvent {
/// Enable the Windows sandbox feature and switch to Agent mode.
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
EnableWindowsSandboxForAgentMode {
preset: ApprovalPreset,
preset: PermissionsPreset,
mode: WindowsSandboxEnableMode,
},

View File

@@ -39,6 +39,11 @@ use std::time::Instant;
use crate::bottom_pane::StatusLineItem;
use crate::bottom_pane::StatusLineSetupView;
use crate::permissions::PermissionsPreset;
#[cfg(target_os = "windows")]
use crate::permissions::builtin_permissions_presets;
use crate::permissions::visible_permissions_options;
use crate::permissions::windows_degraded_sandbox_enabled;
use crate::status::RateLimitWindowDisplay;
use crate::status::format_directory_display;
use crate::status::format_tokens_compact;
@@ -242,8 +247,6 @@ use codex_protocol::openai_models::InputModality;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
use codex_protocol::plan_tool::UpdatePlanArgs;
use codex_utils_approval_presets::ApprovalPreset;
use codex_utils_approval_presets::builtin_approval_presets;
use strum::IntoEnumIterator;
const USER_SHELL_COMMAND_HELP_TITLE: &str = "Prefix a command with ! to run it locally";
@@ -3338,7 +3341,7 @@ impl ChatWidget {
return;
}
let Some(preset) = builtin_approval_presets()
let Some(preset) = builtin_permissions_presets()
.into_iter()
.find(|preset| preset.id == "auto")
else {
@@ -5334,125 +5337,14 @@ impl ChatWidget {
/// Open a popup to choose the permissions mode (approval policy + sandbox policy).
pub(crate) fn open_permissions_popup(&mut self) {
let include_read_only = cfg!(target_os = "windows");
let current_approval = self.config.permissions.approval_policy.value();
let current_sandbox = self.config.permissions.sandbox_policy.get();
let mut items: Vec<SelectionItem> = Vec::new();
let presets: Vec<ApprovalPreset> = builtin_approval_presets();
#[cfg(target_os = "windows")]
let windows_sandbox_level = WindowsSandboxLevel::from_config(&self.config);
#[cfg(target_os = "windows")]
let windows_degraded_sandbox_enabled =
matches!(windows_sandbox_level, WindowsSandboxLevel::RestrictedToken);
#[cfg(not(target_os = "windows"))]
let windows_degraded_sandbox_enabled = false;
let items: Vec<SelectionItem> = visible_permissions_options(&self.config);
let windows_degraded_sandbox_enabled = windows_degraded_sandbox_enabled(&self.config);
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() {
if !include_read_only && preset.id == "read-only" {
continue;
}
let is_current =
Self::preset_matches_current(current_approval, current_sandbox, &preset);
let name = if preset.id == "auto" && windows_degraded_sandbox_enabled {
"Default (non-admin sandbox)".to_string()
} else {
preset.label.to_string()
};
let description = Some(preset.description.replace(" (Identical to Agent mode)", ""));
let disabled_reason = match self
.config
.permissions
.approval_policy
.can_set(&preset.approval)
{
Ok(()) => None,
Err(err) => Some(err.to_string()),
};
let requires_confirmation = preset.id == "full-access"
&& !self
.config
.notices
.hide_full_access_warning
.unwrap_or(false);
let actions: Vec<SelectionAction> = if requires_confirmation {
let preset_clone = preset.clone();
vec![Box::new(move |tx| {
tx.send(AppEvent::OpenFullAccessConfirmation {
preset: preset_clone.clone(),
return_to_permissions: !include_read_only,
});
})]
} else if preset.id == "auto" {
#[cfg(target_os = "windows")]
{
if WindowsSandboxLevel::from_config(&self.config)
== WindowsSandboxLevel::Disabled
{
let preset_clone = preset.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()
{
let preset_clone = preset.clone();
vec![Box::new(move |tx| {
tx.send(AppEvent::OpenWorldWritableWarningConfirmation {
preset: Some(preset_clone.clone()),
sample_paths: sample_paths.clone(),
extra_count,
failed_scan,
});
})]
} else {
Self::approval_preset_actions(
preset.approval,
preset.sandbox.clone(),
name.clone(),
)
}
}
#[cfg(not(target_os = "windows"))]
{
Self::approval_preset_actions(
preset.approval,
preset.sandbox.clone(),
name.clone(),
)
}
} else {
Self::approval_preset_actions(preset.approval, preset.sandbox.clone(), name.clone())
};
items.push(SelectionItem {
name,
description,
is_current,
actions,
dismiss_on_select: true,
disabled_reason,
..Default::default()
});
}
&& items
.iter()
.any(|item| item.name == "Default (non-admin sandbox)");
let footer_note = show_elevate_sandbox_hint.then(|| {
vec![
@@ -5518,14 +5410,6 @@ impl ChatWidget {
})]
}
fn preset_matches_current(
current_approval: AskForApproval,
current_sandbox: &SandboxPolicy,
preset: &ApprovalPreset,
) -> bool {
current_approval == preset.approval && *current_sandbox == preset.sandbox
}
#[cfg(target_os = "windows")]
pub(crate) fn world_writable_warning_details(&self) -> Option<(Vec<String>, usize, bool)> {
if self
@@ -5556,14 +5440,7 @@ impl ChatWidget {
None
}
pub(crate) fn open_full_access_confirmation(
&mut self,
preset: ApprovalPreset,
return_to_permissions: bool,
) {
let selected_name = preset.label.to_string();
let approval = preset.approval;
let sandbox = preset.sandbox;
pub(crate) fn open_full_access_confirmation(&mut self, preset: PermissionsPreset) {
let mut header_children: Vec<Box<dyn Renderable>> = Vec::new();
let title_line = Line::from("Enable full access?").bold();
let info_line = Line::from(vec![
@@ -5578,25 +5455,27 @@ impl ChatWidget {
));
let header = ColumnRenderable::with(header_children);
let mut accept_actions =
Self::approval_preset_actions(approval, sandbox.clone(), selected_name.clone());
let mut accept_actions = Self::approval_preset_actions(
preset.approval,
preset.sandbox.clone(),
preset.label.to_string(),
);
accept_actions.push(Box::new(|tx| {
tx.send(AppEvent::UpdateFullAccessWarningAcknowledged(true));
}));
let mut accept_and_remember_actions =
Self::approval_preset_actions(approval, sandbox, selected_name);
let mut accept_and_remember_actions = Self::approval_preset_actions(
preset.approval,
preset.sandbox.clone(),
preset.label.to_string(),
);
accept_and_remember_actions.push(Box::new(|tx| {
tx.send(AppEvent::UpdateFullAccessWarningAcknowledged(true));
tx.send(AppEvent::PersistFullAccessWarningAcknowledged);
}));
let deny_actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
if return_to_permissions {
tx.send(AppEvent::OpenPermissionsPopup);
} else {
tx.send(AppEvent::OpenApprovalsPopup);
}
tx.send(AppEvent::OpenPermissionsPopup);
})];
let items = vec![
@@ -5634,7 +5513,7 @@ impl ChatWidget {
#[cfg(target_os = "windows")]
pub(crate) fn open_world_writable_warning_confirmation(
&mut self,
preset: Option<ApprovalPreset>,
preset: Option<PermissionsPreset>,
sample_paths: Vec<String>,
extra_count: usize,
failed_scan: bool,
@@ -5743,7 +5622,7 @@ impl ChatWidget {
#[cfg(not(target_os = "windows"))]
pub(crate) fn open_world_writable_warning_confirmation(
&mut self,
_preset: Option<ApprovalPreset>,
_preset: Option<PermissionsPreset>,
_sample_paths: Vec<String>,
_extra_count: usize,
_failed_scan: bool,
@@ -5751,7 +5630,7 @@ impl ChatWidget {
}
#[cfg(target_os = "windows")]
pub(crate) fn open_windows_sandbox_enable_prompt(&mut self, preset: ApprovalPreset) {
pub(crate) fn open_windows_sandbox_enable_prompt(&mut self, preset: PermissionsPreset) {
use ratatui_macros::line;
if !codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED {
@@ -5863,10 +5742,10 @@ impl ChatWidget {
}
#[cfg(not(target_os = "windows"))]
pub(crate) fn open_windows_sandbox_enable_prompt(&mut self, _preset: ApprovalPreset) {}
pub(crate) fn open_windows_sandbox_enable_prompt(&mut self, _preset: PermissionsPreset) {}
#[cfg(target_os = "windows")]
pub(crate) fn open_windows_sandbox_fallback_prompt(&mut self, preset: ApprovalPreset) {
pub(crate) fn open_windows_sandbox_fallback_prompt(&mut self, preset: PermissionsPreset) {
use ratatui_macros::line;
let mut lines = Vec::new();
@@ -5942,13 +5821,13 @@ impl ChatWidget {
}
#[cfg(not(target_os = "windows"))]
pub(crate) fn open_windows_sandbox_fallback_prompt(&mut self, _preset: ApprovalPreset) {}
pub(crate) fn open_windows_sandbox_fallback_prompt(&mut self, _preset: PermissionsPreset) {}
#[cfg(target_os = "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()
&& let Some(preset) = builtin_permissions_presets()
.into_iter()
.find(|preset| preset.id == "auto")
{

View File

@@ -1,13 +1,12 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 3092
expression: popup
---
Update Model Permissions
1. Default Codex can read and edit files in the current workspace, and
run commands. Approval is required to access the internet or
edit other files.
edit other files. (Identical to Agent mode)
2. Full Access Codex can edit files outside this workspace and access the
internet without asking for approval. Exercise caution when
using.

View File

@@ -12,6 +12,7 @@ use crate::bottom_pane::FeedbackAudience;
use crate::bottom_pane::LocalImageAttachment;
use crate::bottom_pane::MentionBinding;
use crate::history_cell::UserHistoryCell;
use crate::permissions::builtin_permissions_presets;
use crate::test_backend::VT100Backend;
use crate::tui::FrameRequester;
use assert_matches::assert_matches;
@@ -91,7 +92,6 @@ use codex_protocol::protocol::SkillScope;
use codex_protocol::user_input::TextElement;
use codex_protocol::user_input::UserInput;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_approval_presets::builtin_approval_presets;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
@@ -5019,10 +5019,11 @@ async fn approvals_selection_popup_snapshot_windows_degraded_sandbox() {
#[tokio::test]
async fn preset_matching_requires_exact_workspace_write_settings() {
let preset = builtin_approval_presets()
let preset = builtin_permissions_presets()
.into_iter()
.find(|p| p.id == "auto")
.expect("auto preset exists");
let mut config = test_config().await;
let current_sandbox = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![AbsolutePathBuf::try_from("C:\\extra").unwrap()],
read_only_access: Default::default(),
@@ -5030,13 +5031,28 @@ async fn preset_matching_requires_exact_workspace_write_settings() {
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
};
config
.permissions
.approval_policy
.set(AskForApproval::OnRequest)
.unwrap();
config
.permissions
.sandbox_policy
.set(current_sandbox)
.unwrap();
assert!(
!ChatWidget::preset_matches_current(AskForApproval::OnRequest, &current_sandbox, &preset),
!preset.to_selection_item(&config).is_current,
"WorkspaceWrite with extra roots should not match the Default preset"
);
config
.permissions
.approval_policy
.set(AskForApproval::Never)
.unwrap();
assert!(
!ChatWidget::preset_matches_current(AskForApproval::Never, &current_sandbox, &preset),
!preset.to_selection_item(&config).is_current,
"approval mismatch should prevent matching the preset"
);
}
@@ -5045,11 +5061,11 @@ async fn preset_matching_requires_exact_workspace_write_settings() {
async fn full_access_confirmation_popup_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
let preset = builtin_approval_presets()
let preset = builtin_permissions_presets()
.into_iter()
.find(|preset| preset.id == "full-access")
.find(|selection_item| selection_item.id == "full-access")
.expect("full access preset");
chat.open_full_access_confirmation(preset, false);
chat.open_full_access_confirmation(preset);
let popup = render_bottom_popup(&chat, 80);
assert_snapshot!("full_access_confirmation_popup", popup);
@@ -5060,7 +5076,7 @@ async fn full_access_confirmation_popup_snapshot() {
async fn windows_auto_mode_prompt_requests_enabling_sandbox_feature() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
let preset = builtin_approval_presets()
let preset = builtin_permissions_presets()
.into_iter()
.find(|preset| preset.id == "auto")
.expect("auto preset");
@@ -5326,16 +5342,13 @@ async fn disabled_slash_command_while_task_running_snapshot() {
}
#[tokio::test]
async fn approvals_popup_shows_disabled_presets() {
async fn approvals_popup_constrains_disallowed_presets() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
chat.config.permissions.approval_policy =
Constrained::new(AskForApproval::OnRequest, |candidate| match candidate {
AskForApproval::OnRequest => Ok(()),
_ => Err(invalid_value(
candidate.to_string(),
"this message should be printed in the description",
)),
_ => Err(invalid_value(candidate.to_string(), "[on-request]")),
})
.expect("construct constrained approval policy");
chat.open_approvals_popup();
@@ -5352,17 +5365,17 @@ async fn approvals_popup_shows_disabled_presets() {
let screen = terminal.backend().vt100().screen().contents();
let collapsed = screen.split_whitespace().collect::<Vec<_>>().join(" ");
assert!(
collapsed.contains("(disabled)"),
"disabled preset label should be shown"
!collapsed.contains("(disabled)"),
"disallowed presets should be omitted, not rendered disabled"
);
assert!(
collapsed.contains("this message should be printed in the description"),
"disabled preset reason should be shown"
!collapsed.contains("Full Access"),
"full-access preset should be omitted when disallowed"
);
}
#[tokio::test]
async fn approvals_popup_navigation_skips_disabled() {
async fn approvals_popup_selection_ignores_missing_numeric_shortcuts() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await;
chat.config.permissions.approval_policy =
@@ -5373,13 +5386,7 @@ async fn approvals_popup_navigation_skips_disabled() {
.expect("construct constrained approval policy");
chat.open_approvals_popup();
// The approvals popup is the active bottom-pane view; drive navigation via chat handle_key_event.
// Start selected at idx 0 (enabled), move down twice; the disabled option should be skipped
// and selection should wrap back to idx 0 (also enabled).
chat.handle_key_event(KeyEvent::from(KeyCode::Down));
chat.handle_key_event(KeyEvent::from(KeyCode::Down));
// Press numeric shortcut for the disabled row (3 => idx 2); should not close or accept.
// Press numeric shortcut for an item that does not exist after filtering.
chat.handle_key_event(KeyEvent::from(KeyCode::Char('3')));
// Ensure the popup remains open and no selection actions were sent.
@@ -5390,11 +5397,11 @@ async fn approvals_popup_navigation_skips_disabled() {
terminal.set_viewport_area(Rect::new(0, 0, width, height));
terminal
.draw(|f| chat.render(f.area(), f.buffer_mut()))
.expect("render approvals popup after disabled selection");
.expect("render approvals popup after invalid numeric shortcut");
let screen = terminal.backend().vt100().screen().contents();
assert!(
screen.contains("Update Model Permissions"),
"popup should remain open after selecting a disabled entry"
"popup should remain open after selecting a non-existent entry"
);
assert!(
op_rx.try_recv().is_err(),
@@ -5428,7 +5435,7 @@ async fn approvals_popup_navigation_skips_disabled() {
..
})
)),
"disabled preset should not be selected"
"disallowed preset should not be selectable"
);
}
@@ -5579,11 +5586,8 @@ async fn permissions_full_access_history_cell_emitted_only_after_confirmation()
AppEvent::InsertHistoryCell(cell) => {
cells_before_confirmation.push(cell.display_lines(80));
}
AppEvent::OpenFullAccessConfirmation {
preset,
return_to_permissions,
} => {
open_confirmation_event = Some((preset, return_to_permissions));
AppEvent::OpenFullAccessConfirmation { preset } => {
open_confirmation_event = Some(preset);
}
_ => {}
}
@@ -5594,9 +5598,8 @@ async fn permissions_full_access_history_cell_emitted_only_after_confirmation()
"did not expect history cell before confirming full access"
);
}
let (preset, return_to_permissions) =
open_confirmation_event.expect("expected full access confirmation event");
chat.open_full_access_confirmation(preset, return_to_permissions);
let preset = open_confirmation_event.expect("expected full access confirmation event");
chat.open_full_access_confirmation(preset.clone());
let popup = render_bottom_popup(&chat, 80);
assert!(

View File

@@ -90,6 +90,7 @@ mod notifications;
pub mod onboarding;
mod oss_selection;
mod pager_overlay;
mod permissions;
pub mod public_widgets;
mod render;
mod resume_picker;
@@ -900,15 +901,8 @@ async fn load_config_or_exit_with_fallback_cwd(
}
}
/// Determine if user has configured a sandbox / approval policy,
/// or if the current cwd project is already trusted. If not, we need to
/// show the trust screen.
/// Determine if the user has decided whether to trust the current directory.
fn should_show_trust_screen(config: &Config) -> bool {
if config.did_user_set_custom_approval_policy_or_sandbox_mode {
// Respect explicit approval/sandbox overrides made by the user.
return false;
}
// otherwise, show only if no trust decision has been made
config.active_project.trust_level.is_none()
}
@@ -961,7 +955,6 @@ mod tests {
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;
config.active_project = ProjectConfig { trust_level: None };
config.set_windows_sandbox_enabled(false);
@@ -977,7 +970,6 @@ mod tests {
async fn windows_shows_trust_prompt_with_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;
config.active_project = ProjectConfig { trust_level: None };
config.set_windows_sandbox_enabled(true);
@@ -1000,7 +992,6 @@ mod tests {
use codex_protocol::config_types::TrustLevel;
let temp_dir = TempDir::new()?;
let mut config = build_config(&temp_dir).await?;
config.did_user_set_custom_approval_policy_or_sandbox_mode = false;
config.active_project = ProjectConfig {
trust_level: Some(TrustLevel::Untrusted),
};

View File

@@ -0,0 +1,242 @@
use crate::app_event::AppEvent;
#[cfg(target_os = "windows")]
use crate::app_event::WindowsSandboxEnableMode;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::SelectionAction;
use crate::bottom_pane::SelectionItem;
use crate::history_cell;
use codex_core::config::Config;
use codex_core::config::Constrained;
use codex_core::config::Permissions;
use codex_core::protocol::AskForApproval;
use codex_core::protocol::Op;
use codex_core::protocol::SandboxPolicy;
use codex_core::protocol_config_types::WindowsSandboxLevel;
/// A simple preset pairing an approval policy with a sandbox policy.
#[derive(Debug, Clone)]
pub struct PermissionsPreset {
/// Stable identifier for the preset.
pub id: &'static str,
/// Display label shown in UIs.
pub label: &'static str,
/// Short human description shown next to the label in UIs.
pub description: &'static str,
/// Approval policy to apply.
pub approval: AskForApproval,
/// Sandbox policy to apply.
pub sandbox: SandboxPolicy,
}
/// Built-in list of approval presets that pair approval and sandbox policy.
pub fn builtin_permissions_presets() -> Vec<PermissionsPreset> {
vec![
PermissionsPreset {
id: "read-only",
label: "Read Only",
description: "Codex can read files in the current workspace. Approval is required to edit files or access the internet.",
approval: AskForApproval::OnRequest,
sandbox: SandboxPolicy::new_read_only_policy(),
},
PermissionsPreset {
id: "auto",
label: "Default",
description: "Codex can read and edit files in the current workspace, and run commands. Approval is required to access the internet or edit other files. (Identical to Agent mode)",
approval: AskForApproval::OnRequest,
sandbox: SandboxPolicy::new_workspace_write_policy(),
},
PermissionsPreset {
id: "full-access",
label: "Full Access",
description: "Codex can edit files outside this workspace and access the internet without asking for approval. Exercise caution when using.",
approval: AskForApproval::Never,
sandbox: SandboxPolicy::DangerFullAccess,
},
]
}
pub(crate) fn visible_permissions_options(config: &Config) -> Vec<SelectionItem> {
let presets = builtin_permissions_presets()
.into_iter()
.filter(|preset| preset.is_visible(config))
.filter(|preset| preset.satisfies_requirements(config));
presets
.map(|preset| preset.to_selection_item(config))
.collect()
}
impl PermissionsPreset {
pub(crate) fn is_visible(&self, config: &Config) -> bool {
match self.id {
"read-only" => cfg!(target_os = "windows"),
"auto" => {
!cfg!(target_os = "windows")
|| codex_core::windows_sandbox::windows_sandbox_level_from_config(config)
== WindowsSandboxLevel::Disabled
}
"full-access" => true,
_ => false,
}
}
pub(crate) fn to_selection_item(&self, config: &Config) -> SelectionItem {
let name = if self.id == "auto" && windows_degraded_sandbox_enabled(config) {
"Default (non-admin sandbox)".to_string()
} else {
self.label.to_string()
};
SelectionItem {
name,
description: Some(self.description.to_string()),
is_current: self.is_current(config),
actions: self.actions(config),
dismiss_on_select: true,
..Default::default()
}
}
fn is_current(&self, config: &Config) -> bool {
self.approval == config.permissions.approval_policy.value()
&& self.sandbox == *config.permissions.sandbox_policy.get()
}
fn satisfies_requirements(&self, config: &Config) -> bool {
config
.permissions_satisfy_requirements(&Permissions {
approval_policy: Constrained::allow_any(self.approval),
sandbox_policy: Constrained::allow_any(self.sandbox.clone()),
network: config.permissions.network.clone(),
shell_environment_policy: config.permissions.shell_environment_policy.clone(),
windows_sandbox_mode: config.permissions.windows_sandbox_mode,
macos_seatbelt_profile_extensions: config
.permissions
.macos_seatbelt_profile_extensions
.clone(),
})
.is_ok()
}
pub(crate) fn actions(&self, config: &Config) -> Vec<SelectionAction> {
let requires_full_access_confirmation =
matches!(self.sandbox, SandboxPolicy::DangerFullAccess)
&& !config.notices.hide_full_access_warning.unwrap_or(false);
if requires_full_access_confirmation {
let preset = self.clone();
return vec![Box::new(move |tx: &AppEventSender| {
tx.send(AppEvent::OpenFullAccessConfirmation {
preset: preset.clone(),
});
})];
}
#[cfg(target_os = "windows")]
{
if let Some(actions) = windows_permissions_actions(self, config) {
return actions;
}
}
let approval = self.approval;
let sandbox = self.sandbox.clone();
let label = self.label.to_string();
vec![Box::new(move |tx: &AppEventSender| {
let sandbox_clone = sandbox.clone();
tx.send(AppEvent::CodexOp(Op::OverrideTurnContext {
cwd: None,
approval_policy: Some(approval),
sandbox_policy: Some(sandbox_clone.clone()),
windows_sandbox_level: None,
model: None,
effort: None,
summary: None,
collaboration_mode: None,
personality: None,
}));
tx.send(AppEvent::UpdateAskForApprovalPolicy(approval));
tx.send(AppEvent::UpdateSandboxPolicy(sandbox_clone));
tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::new_info_event(format!("Permissions updated to {label}"), None),
)));
})]
}
}
/// Handle windows-specific actions for auto preset. Returns Some when it should take precedence over the approval preset actions.
#[cfg(target_os = "windows")]
fn windows_permissions_actions(
preset: &PermissionsPreset,
config: &Config,
) -> Option<Vec<SelectionAction>> {
if preset.id != "auto" {
return None;
}
if codex_core::windows_sandbox::windows_sandbox_level_from_config(config)
== WindowsSandboxLevel::Disabled
{
let preset_clone = preset.clone();
if codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED
&& codex_core::windows_sandbox::sandbox_setup_is_complete(config.codex_home.as_path())
{
Some(vec![Box::new(move |tx| {
tx.send(AppEvent::EnableWindowsSandboxForAgentMode {
preset: preset_clone.clone(),
mode: WindowsSandboxEnableMode::Elevated,
});
})])
} else {
Some(vec![Box::new(move |tx| {
tx.send(AppEvent::OpenWindowsSandboxEnablePrompt {
preset: preset_clone.clone(),
});
})])
}
} else if let Some((sample_paths, extra_count, failed_scan)) =
world_writable_warning_details(config)
{
let preset_clone = preset.clone();
Some(vec![Box::new(move |tx| {
tx.send(AppEvent::OpenWorldWritableWarningConfirmation {
preset: Some(preset_clone.clone()),
sample_paths: sample_paths.clone(),
extra_count,
failed_scan,
});
})])
} else {
None
}
}
#[cfg(target_os = "windows")]
pub(crate) fn windows_degraded_sandbox_enabled(config: &Config) -> bool {
let windows_sandbox_level =
codex_core::windows_sandbox::windows_sandbox_level_from_config(config);
matches!(windows_sandbox_level, WindowsSandboxLevel::RestrictedToken);
}
#[cfg(target_os = "windows")]
fn world_writable_warning_details(config: &Config) -> Option<(Vec<String>, usize, bool)> {
if config.notices.hide_world_writable_warning.unwrap_or(false) {
return None;
}
let cwd = config.cwd.clone();
let env_map: std::collections::HashMap<String, String> = std::env::vars().collect();
match codex_windows_sandbox::apply_world_writable_scan_and_denies(
config.codex_home.as_path(),
cwd.as_path(),
&env_map,
config.permissions.sandbox_policy.get(),
Some(config.codex_home.as_path()),
) {
Ok(_) => None,
Err(_) => Some((Vec::new(), 0, true)),
}
}
#[cfg(not(target_os = "windows"))]
pub(crate) fn windows_degraded_sandbox_enabled(_config: &Config) -> bool {
false
}