tui: add named permission profile picker (#21559)

## Why

Users who opt into named permission profiles through
`default_permissions` or `[permissions.*]` should stay in named-profile
semantics when they open `/permissions`. The legacy picker rewrites
those users into anonymous preset state, which loses the active profile
identity and hides custom configured profiles.

## What changed

- Switch `/permissions` to a profile-aware picker when profile mode is
active.
- Show friendly built-in labels instead of raw `:` profile syntax.
- Include configured custom profiles and their descriptions in the
picker.
- Route selections through the split TUI profile-selection flow below
this PR.
- Add TUI snapshots and regression coverage for built-ins, custom
profiles, and conflicting legacy runtime overrides.

## Stack

1. [#22931](https://github.com/openai/codex/pull/22931):
runtime/session/network propagation for active permission profiles.
2. [#23708](https://github.com/openai/codex/pull/23708): TUI selection
plumbing and guardrail flow.
3. **This PR**: profile-aware `/permissions` menu and custom profile
display.

## UX impact

In profile mode, `/permissions` shows the same human-facing built-ins
users already know:

```text
Default
Auto-review
Full Access
Read Only
locked-down
web-enabled
```

Selecting `locked-down` keeps `active_permission_profile =
Some("locked-down")`; selecting a built-in keeps the friendly label
while switching to its named built-in profile.

## Screenshots

Live `$test-tui` smoke screenshots uploaded through GitHub attachments:

**Profile mode with built-ins and custom profiles**

<img width="832" alt="Profile mode permissions picker with custom
profiles"
src="https://github.com/user-attachments/assets/58b72431-418c-4839-9e39-575076db4c8f"
/>

**Legacy mode remains anonymous preset picker**

<img width="1232" alt="Legacy permissions picker"
src="https://github.com/user-attachments/assets/95f413ab-4cee-411c-9afb-92580a885c97"
/>

<img width="1296" height="906" alt="image"
src="https://github.com/user-attachments/assets/ea381a78-9904-4aa2-828f-b7f2e43f60f2"
/>

<img width="705" height="207" alt="Screenshot 2026-05-18 at 2 58 00 PM"
src="https://github.com/user-attachments/assets/2fa6dd71-0296-449e-a6de-a72d78a1cb70"
/>

## Validation

- `git diff --cached --check` before commit.
- Full test run skipped at the user request while pushing the split
stack.
This commit is contained in:
viyatb-oai
2026-05-26 09:39:55 -07:00
committed by GitHub
Unverified
parent ef6528c6c7
commit f6fd753039
9 changed files with 424 additions and 10 deletions
+7 -4
View File
@@ -2078,7 +2078,7 @@ async fn default_permissions_can_select_builtin_profile_without_permissions_tabl
.await?;
assert!(config.explicit_permission_profile_mode);
assert!(config.custom_permission_profile_ids.is_empty());
assert!(config.custom_permission_profiles.is_empty());
let policy = config.permissions.file_system_sandbox_policy();
assert_eq!(
config
@@ -2726,7 +2726,7 @@ async fn permissions_profiles_allow_direct_write_roots_outside_workspace_root()
entries: BTreeMap::from([(
"dev".to_string(),
PermissionProfileToml {
description: None,
description: Some("Workspace access.".to_string()),
extends: None,
workspace_roots: None,
filesystem: Some(FilesystemPermissionsToml {
@@ -2751,8 +2751,11 @@ async fn permissions_profiles_allow_direct_write_roots_outside_workspace_root()
.await?;
assert_eq!(
config.custom_permission_profile_ids,
vec!["dev".to_string()]
config.custom_permission_profiles,
vec![CustomPermissionProfileSummary {
id: "dev".to_string(),
description: Some("Workspace access.".to_string()),
}]
);
let memories_root = AbsolutePathBuf::from_absolute_path(std::fs::canonicalize(
codex_home.path().join("memories"),
+18 -5
View File
@@ -586,8 +586,8 @@ pub struct Config {
/// of the legacy `sandbox_mode` syntax.
pub explicit_permission_profile_mode: bool,
/// User-defined permission profile IDs available from effective config.
pub custom_permission_profile_ids: Vec<String>,
/// User-defined permission profiles available from effective config.
pub custom_permission_profiles: Vec<CustomPermissionProfileSummary>,
/// Configures who approval requests are routed to for review once they have
/// been escalated. This does not disable separate safety checks such as
@@ -1902,6 +1902,12 @@ pub struct AgentRoleConfig {
pub nickname_candidates: Option<Vec<String>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CustomPermissionProfileSummary {
pub id: String,
pub description: Option<String>,
}
fn resolve_tool_suggest_config(
config_toml: &ConfigToml,
config_layer_stack: &ConfigLayerStack,
@@ -2638,11 +2644,18 @@ impl Config {
permission_config_syntax,
Some(PermissionConfigSyntax::Profiles)
);
let custom_permission_profile_ids = cfg
let custom_permission_profiles = cfg
.permissions
.as_ref()
.map_or_else(Vec::new, |permissions| {
permissions.entries.keys().cloned().collect()
permissions
.entries
.iter()
.map(|(id, profile)| CustomPermissionProfileSummary {
id: id.clone(),
description: profile.description.clone(),
})
.collect()
});
let using_implicit_builtin_profile = permission_config_syntax.is_none()
&& effective_permission_selection.selected_profile_id.is_none();
@@ -3377,7 +3390,7 @@ impl Config {
windows_sandbox_private_desktop,
},
explicit_permission_profile_mode,
custom_permission_profile_ids,
custom_permission_profiles,
approvals_reviewer: constrained_approvals_reviewer.value(),
enforce_residency: enforce_residency.value,
notify: cfg.notify,
+1 -1
View File
@@ -179,7 +179,7 @@ fn new_config(model: Option<String>, arg0_paths: Arg0DispatchPaths) -> anyhow::R
Constrained::allow_any(PermissionProfile::read_only()),
)?,
explicit_permission_profile_mode: false,
custom_permission_profile_ids: Vec::new(),
custom_permission_profiles: Vec::new(),
approvals_reviewer: ApprovalsReviewer::User,
enforce_residency: Constrained::allow_any(/*initial_value*/ None),
hide_agent_reasoning: false,
+1
View File
@@ -372,6 +372,7 @@ mod model_popups;
mod notifications;
use self::notifications::Notification;
mod permission_popups;
mod permissions_menu;
mod protocol;
mod protocol_requests;
mod rate_limits;
@@ -14,6 +14,11 @@ impl ChatWidget {
/// Open a popup to choose the permissions mode.
pub(crate) fn open_permissions_popup(&mut self) {
if self.config.explicit_permission_profile_mode {
self.open_permission_profiles_popup();
return;
}
let include_read_only = cfg!(target_os = "windows");
let current_approval =
AskForApproval::from(self.config.permissions.approval_policy.value());
@@ -0,0 +1,171 @@
use super::*;
impl ChatWidget {
pub(super) fn open_permission_profiles_popup(&mut self) {
let active_profile_id = self
.config
.permissions
.active_permission_profile()
.map(|profile| profile.id);
let presets = builtin_approval_presets();
let Some(read_only) = presets.iter().find(|preset| preset.id == "read-only") else {
self.add_error_message(
"Internal error: missing the 'read-only' approval preset.".to_string(),
);
return;
};
let Some(default) = presets.iter().find(|preset| preset.id == "auto") else {
self.add_error_message(
"Internal error: missing the 'auto' approval preset.".to_string(),
);
return;
};
let Some(full_access) = presets.iter().find(|preset| preset.id == "full-access") else {
self.add_error_message(
"Internal error: missing the 'full-access' approval preset.".to_string(),
);
return;
};
let mut items = vec![
self.builtin_permission_mode_selection_item(
default,
":workspace",
default
.description
.replace(" (Identical to Agent mode)", ""),
AskForApproval::from(default.approval),
ApprovalsReviewer::User,
),
];
if self.config.features.enabled(Feature::GuardianApproval) {
items.push(self.builtin_permission_mode_selection_item(
default,
":workspace",
AUTO_REVIEW_DESCRIPTION.to_string(),
AskForApproval::OnRequest,
ApprovalsReviewer::AutoReview,
));
}
items.push(self.builtin_permission_mode_selection_item(
full_access,
":danger-no-sandbox",
full_access.description.to_string(),
AskForApproval::from(full_access.approval),
ApprovalsReviewer::User,
));
items.push(self.builtin_permission_mode_selection_item(
read_only,
":read-only",
read_only.description.to_string(),
AskForApproval::from(read_only.approval),
ApprovalsReviewer::User,
));
items.extend(
self.config
.custom_permission_profiles
.iter()
.map(|profile| {
Self::permission_profile_selection_item(
&profile.id,
&profile.id,
profile
.description
.as_deref()
.unwrap_or("Configured permission profile."),
active_profile_id.as_deref(),
)
}),
);
self.bottom_pane.show_selection_view(SelectionViewParams {
title: Some("Update Model Permissions".to_string()),
footer_hint: Some(standard_popup_hint_line()),
items,
header: Box::new(()),
..Default::default()
});
}
fn builtin_permission_mode_selection_item(
&self,
preset: &ApprovalPreset,
id: &str,
description: String,
approval_policy: AskForApproval,
approvals_reviewer: ApprovalsReviewer,
) -> SelectionItem {
let label = if approvals_reviewer == ApprovalsReviewer::AutoReview {
"Auto-review"
} else {
preset.label
};
let active_profile_id = self
.config
.permissions
.active_permission_profile()
.map(|profile| profile.id);
let current_approval =
AskForApproval::from(self.config.permissions.approval_policy.value());
let current_reviewer = self.config.approvals_reviewer;
let profile_id = id.to_string();
let selection = PermissionProfileSelection {
profile_id,
approval_policy: Some(approval_policy),
approvals_reviewer: Some(approvals_reviewer),
display_label: label.to_string(),
};
SelectionItem {
name: label.to_string(),
description: Some(description),
is_current: active_profile_id.as_deref() == Some(id)
&& current_approval == approval_policy
&& current_reviewer == approvals_reviewer,
actions: self.permission_mode_actions(
preset,
label.to_string(),
approvals_reviewer,
Some(selection),
/*return_to_permissions*/ true,
),
dismiss_on_select: true,
disabled_reason: self
.config
.permissions
.approval_policy
.can_set(&approval_policy.to_core())
.err()
.map(|err| err.to_string())
.or_else(|| {
self.config
.permissions
.can_set_permission_profile(&preset.permission_profile)
.err()
.map(|err| err.to_string())
}),
..Default::default()
}
}
fn permission_profile_selection_item(
label: &str,
id: &str,
description: &str,
active_profile_id: Option<&str>,
) -> SelectionItem {
let id_for_action = id.to_string();
let selection = PermissionProfileSelection {
profile_id: id_for_action.clone(),
approval_policy: None,
approvals_reviewer: None,
display_label: id_for_action,
};
SelectionItem {
name: label.to_string(),
description: Some(description.to_string()),
is_current: active_profile_id == Some(id),
actions: Self::permission_profile_selection_actions(selection),
dismiss_on_select: true,
..Default::default()
}
}
}
@@ -0,0 +1,20 @@
---
source: tui/src/chatwidget/tests/permissions.rs
expression: "render_bottom_popup(&chat, 80)"
---
Update Model Permissions
1. Default (current) Codex can read and edit files in the current
workspace, and run commands. Approval is required to
access the internet or edit other files.
2. Auto-review Same workspace-write permissions as Default, but
eligible `on-request` approvals are routed through the
auto-reviewer subagent.
3. Full Access Codex can edit files outside this workspace and access
the internet without asking for approval. Exercise
caution when using.
4. Read Only Codex can read files in the current workspace.
Approval is required to edit files or access the
internet.
Press enter to confirm or esc to go back
@@ -0,0 +1,22 @@
---
source: tui/src/chatwidget/tests/permissions.rs
expression: "render_bottom_popup(&chat, 80)"
---
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.
2. Auto-review Same workspace-write permissions as Default, but
eligible `on-request` approvals are routed through
the auto-reviewer subagent.
3. Full Access Codex can edit files outside this workspace and
access the internet without asking for approval.
Exercise caution when using.
4. Read Only Codex can read files in the current workspace.
Approval is required to edit files or access the
internet.
5. locked-down (current) Inspect and patch only approved workspace files.
6. web-enabled Workspace profile with network access.
Press enter to confirm or esc to go back
@@ -1,4 +1,6 @@
use super::*;
use crate::legacy_core::config::CustomPermissionProfileSummary;
use codex_protocol::models::ActivePermissionProfile;
use codex_protocol::models::ManagedFileSystemPermissions;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
@@ -63,6 +65,183 @@ async fn approvals_selection_popup_snapshot() {
assert_chatwidget_snapshot!("approvals_selection_popup", popup);
}
#[tokio::test]
async fn profile_permissions_selection_popup_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.config.explicit_permission_profile_mode = true;
chat.config
.permissions
.set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::active(
PermissionProfile::workspace_write(),
ActivePermissionProfile::new(":workspace"),
))
.expect("set active profile");
chat.open_permissions_popup();
assert_chatwidget_snapshot!(
"profile_permissions_selection_popup",
render_bottom_popup(&chat, /*width*/ 80)
);
}
#[tokio::test]
async fn profile_permissions_selection_popup_with_custom_profiles_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.config.explicit_permission_profile_mode = true;
chat.config.custom_permission_profiles = vec![
CustomPermissionProfileSummary {
id: "locked-down".to_string(),
description: Some("Inspect and patch only approved workspace files.".to_string()),
},
CustomPermissionProfileSummary {
id: "web-enabled".to_string(),
description: Some("Workspace profile with network access.".to_string()),
},
];
chat.config
.permissions
.set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::active(
PermissionProfile::workspace_write(),
ActivePermissionProfile::new("locked-down"),
))
.expect("set active profile");
chat.open_permissions_popup();
assert_chatwidget_snapshot!(
"profile_permissions_selection_popup_with_custom_profiles",
render_bottom_popup(&chat, /*width*/ 80)
);
}
#[tokio::test]
async fn profile_permissions_selection_emits_named_profile_event_only() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
#[cfg(target_os = "windows")]
{
chat.set_windows_sandbox_mode(Some(WindowsSandboxModeToml::Unelevated));
}
chat.config.explicit_permission_profile_mode = true;
chat.config
.permissions
.set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::active(
PermissionProfile::workspace_write(),
ActivePermissionProfile::new(":workspace"),
))
.expect("set active profile");
chat.open_permissions_popup();
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
assert_eq!(events.len(), 1);
assert!(matches!(
&events[0],
AppEvent::SelectPermissionProfile(PermissionProfileSelection {
profile_id,
approval_policy: Some(AskForApproval::OnRequest),
approvals_reviewer: Some(ApprovalsReviewer::User),
display_label,
}) if profile_id == ":workspace" && display_label == "Default"
));
}
#[tokio::test]
async fn profile_permissions_selection_emits_active_custom_profile() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.config.explicit_permission_profile_mode = true;
chat.config.custom_permission_profiles = vec![CustomPermissionProfileSummary {
id: "locked-down".to_string(),
description: None,
}];
chat.config
.permissions
.set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::active(
PermissionProfile::workspace_write(),
ActivePermissionProfile::new("locked-down"),
))
.expect("set active profile");
chat.open_permissions_popup();
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
assert_eq!(events.len(), 1);
assert!(matches!(
&events[0],
AppEvent::SelectPermissionProfile(PermissionProfileSelection {
profile_id,
approval_policy: None,
approvals_reviewer: None,
display_label,
}) if profile_id == "locked-down" && display_label == "locked-down"
));
}
#[tokio::test]
async fn profile_permissions_selection_emits_auto_review_mode_event() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
#[cfg(target_os = "windows")]
{
chat.set_windows_sandbox_mode(Some(WindowsSandboxModeToml::Unelevated));
}
chat.config.explicit_permission_profile_mode = true;
chat.config
.permissions
.set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::active(
PermissionProfile::workspace_write(),
ActivePermissionProfile::new(":workspace"),
))
.expect("set active profile");
chat.open_permissions_popup();
chat.handle_key_event(KeyEvent::from(KeyCode::Down));
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
assert_eq!(events.len(), 1);
assert!(matches!(
&events[0],
AppEvent::SelectPermissionProfile(PermissionProfileSelection {
profile_id,
approval_policy: Some(AskForApproval::OnRequest),
approvals_reviewer: Some(ApprovalsReviewer::AutoReview),
display_label,
}) if profile_id == ":workspace" && display_label == "Auto-review"
));
}
#[tokio::test]
async fn profile_permissions_full_access_opens_confirmation() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.config.explicit_permission_profile_mode = true;
chat.set_feature_enabled(Feature::GuardianApproval, /*enabled*/ false);
chat.config.notices.hide_full_access_warning = None;
chat.open_permissions_popup();
chat.handle_key_event(KeyEvent::from(KeyCode::Up));
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
assert_eq!(events.len(), 1);
assert!(matches!(
&events[0],
AppEvent::OpenFullAccessConfirmation {
preset,
return_to_permissions: true,
profile_selection: Some(PermissionProfileSelection {
profile_id,
approval_policy: Some(AskForApproval::Never),
approvals_reviewer: Some(ApprovalsReviewer::User),
display_label,
}),
} if preset.id == "full-access"
&& profile_id == ":danger-no-sandbox"
&& display_label == "Full Access"
));
}
#[cfg(target_os = "windows")]
#[tokio::test]
#[serial]