feat(tui): Add confirmation prompt for enabling full access approvals (#4980)

## Summary
Adds a confirmation screen when a user attempts to select Full Access
via the `/approvals` flow in the TUI.

If the user selects the remember option, the preference is persisted to
config.toml as `full_access_warning_acknowledged`, so they will not be
prompted again.


## Testing
- [x] Adds snapshot test coverage for the approvals flow and the
confirmation flow
<img width="865" height="187" alt="Screenshot 2025-10-08 at 6 04 59 PM"
src="https://github.com/user-attachments/assets/fd1dac62-28b0-4835-ba91-5da6dc5ec4c4"
/>



------
https://chatgpt.com/codex/tasks/task_i_68e6c5c458088322a28efa3207058180

---------

Co-authored-by: Fouad Matin <169186268+fouad-openai@users.noreply.github.com>
Co-authored-by: Fouad Matin <fouad@openai.com>
This commit is contained in:
Dylan
2025-10-16 17:31:46 -07:00
committed by GitHub
parent fc1723f131
commit 78f2785595
8 changed files with 276 additions and 14 deletions

View File

@@ -18,6 +18,7 @@ use codex_core::AuthManager;
use codex_core::ConversationManager;
use codex_core::config::Config;
use codex_core::config::persist_model_selection;
use codex_core::config::set_hide_full_access_warning;
use codex_core::model_family::find_family_for_model;
use codex_core::protocol::SessionSource;
use codex_core::protocol::TokenUsage;
@@ -334,6 +335,9 @@ impl App {
AppEvent::OpenReasoningPopup { model, presets } => {
self.chat_widget.open_reasoning_popup(model, presets);
}
AppEvent::OpenFullAccessConfirmation { preset } => {
self.chat_widget.open_full_access_confirmation(preset);
}
AppEvent::PersistModelSelection { model, effort } => {
let profile = self.active_profile.as_deref();
match persist_model_selection(&self.config.codex_home, profile, &model, effort)
@@ -379,6 +383,23 @@ impl App {
AppEvent::UpdateSandboxPolicy(policy) => {
self.chat_widget.set_sandbox_policy(policy);
}
AppEvent::UpdateFullAccessWarningAcknowledged(ack) => {
self.chat_widget.set_full_access_warning_acknowledged(ack);
}
AppEvent::PersistFullAccessWarningAcknowledged => {
if let Err(err) = set_hide_full_access_warning(&self.config.codex_home, true) {
tracing::error!(
error = %err,
"failed to persist full access warning acknowledgement"
);
self.chat_widget.add_error_message(format!(
"Failed to save full access confirmation preference: {err}"
));
}
}
AppEvent::OpenApprovalsPopup => {
self.chat_widget.open_approvals_popup();
}
AppEvent::OpenReviewBranchPicker(cwd) => {
self.chat_widget.show_review_branch_picker(&cwd).await;
}

View File

@@ -1,5 +1,6 @@
use std::path::PathBuf;
use codex_common::approval_presets::ApprovalPreset;
use codex_common::model_presets::ModelPreset;
use codex_core::protocol::ConversationPathResponseEvent;
use codex_core::protocol::Event;
@@ -67,12 +68,26 @@ pub(crate) enum AppEvent {
presets: Vec<ModelPreset>,
},
/// Open the confirmation prompt before enabling full access mode.
OpenFullAccessConfirmation {
preset: ApprovalPreset,
},
/// Update the current approval policy in the running app and widget.
UpdateAskForApprovalPolicy(AskForApproval),
/// Update the current sandbox policy in the running app and widget.
UpdateSandboxPolicy(SandboxPolicy),
/// Update whether the full access warning prompt has been acknowledged.
UpdateFullAccessWarningAcknowledged(bool),
/// Persist the acknowledgement flag for the full access warning prompt.
PersistFullAccessWarningAcknowledged,
/// Re-open the approval presets popup.
OpenApprovalsPopup,
/// Forwarded conversation history snapshot from the current conversation.
ConversationHistory(ConversationPathResponseEvent),

View File

@@ -54,10 +54,13 @@ use ratatui::buffer::Buffer;
use ratatui::layout::Constraint;
use ratatui::layout::Layout;
use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
use tokio::sync::mpsc::UnboundedSender;
use tracing::debug;
@@ -85,6 +88,7 @@ use crate::history_cell::HistoryCell;
use crate::history_cell::McpToolCallCell;
use crate::markdown::append_markdown;
use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::Renderable;
use crate::slash_command::SlashCommand;
use crate::status::RateLimitSnapshotDisplay;
use crate::text_formatting::truncate_text;
@@ -1819,22 +1823,24 @@ impl ChatWidget {
for preset in presets.into_iter() {
let is_current =
current_approval == preset.approval && current_sandbox == preset.sandbox;
let approval = preset.approval;
let sandbox = preset.sandbox.clone();
let name = preset.label.to_string();
let description = Some(preset.description.to_string());
let actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
tx.send(AppEvent::CodexOp(Op::OverrideTurnContext {
cwd: None,
approval_policy: Some(approval),
sandbox_policy: Some(sandbox.clone()),
model: None,
effort: None,
summary: None,
}));
tx.send(AppEvent::UpdateAskForApprovalPolicy(approval));
tx.send(AppEvent::UpdateSandboxPolicy(sandbox.clone()));
})];
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(),
});
})]
} else {
Self::approval_preset_actions(preset.approval, preset.sandbox.clone())
};
items.push(SelectionItem {
name,
description,
@@ -1853,6 +1859,89 @@ impl ChatWidget {
});
}
fn approval_preset_actions(
approval: AskForApproval,
sandbox: SandboxPolicy,
) -> Vec<SelectionAction> {
vec![Box::new(move |tx| {
let sandbox_clone = sandbox.clone();
tx.send(AppEvent::CodexOp(Op::OverrideTurnContext {
cwd: None,
approval_policy: Some(approval),
sandbox_policy: Some(sandbox_clone.clone()),
model: None,
effort: None,
summary: None,
}));
tx.send(AppEvent::UpdateAskForApprovalPolicy(approval));
tx.send(AppEvent::UpdateSandboxPolicy(sandbox_clone));
})]
}
pub(crate) fn open_full_access_confirmation(&mut self, preset: ApprovalPreset) {
let approval = preset.approval;
let sandbox = preset.sandbox;
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![
"When Codex runs with full access, it can edit any file on your computer and run commands with network, without your approval. "
.into(),
"Exercise caution when enabling full access. This significantly increases the risk of data loss, leaks, or unexpected behavior."
.fg(Color::Red),
]);
header_children.push(Box::new(title_line));
header_children.push(Box::new(
Paragraph::new(vec![info_line]).wrap(Wrap { trim: false }),
));
let header = ColumnRenderable::with(header_children);
let mut accept_actions = Self::approval_preset_actions(approval, sandbox.clone());
accept_actions.push(Box::new(|tx| {
tx.send(AppEvent::UpdateFullAccessWarningAcknowledged(true));
}));
let mut accept_and_remember_actions = Self::approval_preset_actions(approval, sandbox);
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(|tx| {
tx.send(AppEvent::OpenApprovalsPopup);
})];
let items = vec![
SelectionItem {
name: "Yes, continue anyway".to_string(),
description: Some("Apply full access for this session".to_string()),
actions: accept_actions,
dismiss_on_select: true,
..Default::default()
},
SelectionItem {
name: "Yes, and don't ask again".to_string(),
description: Some("Enable full access and remember this choice".to_string()),
actions: accept_and_remember_actions,
dismiss_on_select: true,
..Default::default()
},
SelectionItem {
name: "Cancel".to_string(),
description: Some("Go back without enabling full access".to_string()),
actions: deny_actions,
dismiss_on_select: true,
..Default::default()
},
];
self.bottom_pane.show_selection_view(SelectionViewParams {
footer_hint: Some(standard_popup_hint_line()),
items,
header: Box::new(header),
..Default::default()
});
}
/// Set the approval policy in the widget's config copy.
pub(crate) fn set_approval_policy(&mut self, policy: AskForApproval) {
self.config.approval_policy = policy;
@@ -1863,6 +1952,10 @@ impl ChatWidget {
self.config.sandbox_policy = policy;
}
pub(crate) fn set_full_access_warning_acknowledged(&mut self, acknowledged: bool) {
self.config.notices.hide_full_access_warning = Some(acknowledged);
}
/// Set the reasoning effort in the widget's config copy.
pub(crate) fn set_reasoning_effort(&mut self, effort: Option<ReasoningEffortConfig>) {
self.config.model_reasoning_effort = effort;

View File

@@ -0,0 +1,17 @@
---
source: tui/src/chatwidget/tests.rs
expression: popup
---
Select Approval Mode
1. Read Only (current) Codex can read files and answer questions. Codex
requires approval to make edits, run commands, or
access network
2. Auto Codex can read files, make edits, and run commands
in the workspace. Codex requires approval to work
outside the workspace or access network
3. Full Access Codex can read files, make edits, and run commands
with network access, without approval. Exercise
caution
Press enter to confirm or esc to go back

View File

@@ -0,0 +1,15 @@
---
source: tui/src/chatwidget/tests.rs
expression: popup
---
Enable full access?
When Codex runs with full access, it can edit any file on your computer and
run commands with network, without your approval. Exercise caution when
enabling full access. This significantly increases the risk of data loss,
leaks, or unexpected behavior.
1. Yes, continue anyway Apply full access for this session
2. Yes, and don't ask again Enable full access and remember this choice
3. Cancel Go back without enabling full access
Press enter to confirm or esc to go back

View File

@@ -4,6 +4,7 @@ use crate::app_event_sender::AppEventSender;
use crate::test_backend::VT100Backend;
use crate::tui::FrameRequester;
use assert_matches::assert_matches;
use codex_common::approval_presets::builtin_approval_presets;
use codex_core::AuthManager;
use codex_core::CodexAuth;
use codex_core::config::Config;
@@ -1087,6 +1088,31 @@ fn model_selection_popup_snapshot() {
assert_snapshot!("model_selection_popup", popup);
}
#[test]
fn approvals_selection_popup_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual();
chat.config.notices.hide_full_access_warning = None;
chat.open_approvals_popup();
let popup = render_bottom_popup(&chat, 80);
assert_snapshot!("approvals_selection_popup", popup);
}
#[test]
fn full_access_confirmation_popup_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual();
let preset = builtin_approval_presets()
.into_iter()
.find(|preset| preset.id == "full-access")
.expect("full access preset");
chat.open_full_access_confirmation(preset);
let popup = render_bottom_popup(&chat, 80);
assert_snapshot!("full_access_confirmation_popup", popup);
}
#[test]
fn model_reasoning_selection_popup_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual();