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:
@@ -7,6 +7,7 @@ use crate::config_types::DEFAULT_OTEL_ENVIRONMENT;
|
||||
use crate::config_types::History;
|
||||
use crate::config_types::McpServerConfig;
|
||||
use crate::config_types::McpServerTransportConfig;
|
||||
use crate::config_types::Notice;
|
||||
use crate::config_types::Notifications;
|
||||
use crate::config_types::OtelConfig;
|
||||
use crate::config_types::OtelConfigToml;
|
||||
@@ -242,6 +243,9 @@ pub struct Config {
|
||||
/// Tracks whether the Windows onboarding screen has been acknowledged.
|
||||
pub windows_wsl_setup_acknowledged: bool,
|
||||
|
||||
/// Collection of various notices we show the user
|
||||
pub notices: Notice,
|
||||
|
||||
/// When true, disables burst-paste detection for typed input entirely.
|
||||
/// All characters are inserted as they are received, and no buffering
|
||||
/// or placeholder replacement will occur for fast keypress bursts.
|
||||
@@ -557,6 +561,54 @@ pub fn set_windows_wsl_setup_acknowledged(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Persist the acknowledgement flag for the full access warning prompt.
|
||||
pub fn set_hide_full_access_warning(codex_home: &Path, acknowledged: bool) -> anyhow::Result<()> {
|
||||
let config_path = codex_home.join(CONFIG_TOML_FILE);
|
||||
let mut doc = match std::fs::read_to_string(config_path.clone()) {
|
||||
Ok(s) => s.parse::<DocumentMut>()?,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => DocumentMut::new(),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
|
||||
let notices_table = load_or_create_top_level_table(&mut doc, Notice::TABLE_KEY)?;
|
||||
|
||||
notices_table["hide_full_access_warning"] = toml_edit::value(acknowledged);
|
||||
|
||||
std::fs::create_dir_all(codex_home)?;
|
||||
let tmp_file = NamedTempFile::new_in(codex_home)?;
|
||||
std::fs::write(tmp_file.path(), doc.to_string())?;
|
||||
tmp_file.persist(config_path)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_or_create_top_level_table<'a>(
|
||||
doc: &'a mut DocumentMut,
|
||||
key: &str,
|
||||
) -> anyhow::Result<&'a mut toml_edit::Table> {
|
||||
let mut created_table = false;
|
||||
|
||||
let root = doc.as_table_mut();
|
||||
let needs_table =
|
||||
!root.contains_key(key) || root.get(key).and_then(|item| item.as_table()).is_none();
|
||||
if needs_table {
|
||||
root.insert(key, toml_edit::table());
|
||||
created_table = true;
|
||||
}
|
||||
|
||||
let Some(table) = doc[key].as_table_mut() else {
|
||||
return Err(anyhow::anyhow!(format!(
|
||||
"table [{key}] missing after initialization"
|
||||
)));
|
||||
};
|
||||
|
||||
if created_table {
|
||||
table.set_implicit(true);
|
||||
}
|
||||
|
||||
Ok(table)
|
||||
}
|
||||
|
||||
fn ensure_profile_table<'a>(
|
||||
doc: &'a mut DocumentMut,
|
||||
profile_name: &str,
|
||||
@@ -832,6 +884,10 @@ pub struct ConfigToml {
|
||||
/// Tracks whether the Windows onboarding screen has been acknowledged.
|
||||
pub windows_wsl_setup_acknowledged: Option<bool>,
|
||||
|
||||
/// Collection of in-product notices (different from notifications)
|
||||
/// See [`crate::config_types::Notices`] for more details
|
||||
pub notice: Option<Notice>,
|
||||
|
||||
/// Legacy, now use features
|
||||
pub experimental_instructions_file: Option<PathBuf>,
|
||||
pub experimental_use_exec_command_tool: Option<bool>,
|
||||
@@ -1247,6 +1303,7 @@ impl Config {
|
||||
active_profile: active_profile_name,
|
||||
active_project,
|
||||
windows_wsl_setup_acknowledged: cfg.windows_wsl_setup_acknowledged.unwrap_or(false),
|
||||
notices: cfg.notice.unwrap_or_default(),
|
||||
disable_paste_burst: cfg.disable_paste_burst.unwrap_or(false),
|
||||
tui_notifications: cfg
|
||||
.tui
|
||||
@@ -2330,6 +2387,7 @@ model_verbosity = "high"
|
||||
active_profile: Some("o3".to_string()),
|
||||
active_project: ProjectConfig { trust_level: None },
|
||||
windows_wsl_setup_acknowledged: false,
|
||||
notices: Default::default(),
|
||||
disable_paste_burst: false,
|
||||
tui_notifications: Default::default(),
|
||||
otel: OtelConfig::default(),
|
||||
@@ -2396,6 +2454,7 @@ model_verbosity = "high"
|
||||
active_profile: Some("gpt3".to_string()),
|
||||
active_project: ProjectConfig { trust_level: None },
|
||||
windows_wsl_setup_acknowledged: false,
|
||||
notices: Default::default(),
|
||||
disable_paste_burst: false,
|
||||
tui_notifications: Default::default(),
|
||||
otel: OtelConfig::default(),
|
||||
@@ -2477,6 +2536,7 @@ model_verbosity = "high"
|
||||
active_profile: Some("zdr".to_string()),
|
||||
active_project: ProjectConfig { trust_level: None },
|
||||
windows_wsl_setup_acknowledged: false,
|
||||
notices: Default::default(),
|
||||
disable_paste_burst: false,
|
||||
tui_notifications: Default::default(),
|
||||
otel: OtelConfig::default(),
|
||||
@@ -2544,6 +2604,7 @@ model_verbosity = "high"
|
||||
active_profile: Some("gpt5".to_string()),
|
||||
active_project: ProjectConfig { trust_level: None },
|
||||
windows_wsl_setup_acknowledged: false,
|
||||
notices: Default::default(),
|
||||
disable_paste_burst: false,
|
||||
tui_notifications: Default::default(),
|
||||
otel: OtelConfig::default(),
|
||||
|
||||
@@ -322,6 +322,20 @@ pub struct Tui {
|
||||
pub notifications: Notifications,
|
||||
}
|
||||
|
||||
/// Settings for notices we display to users via the tui and app-server clients
|
||||
/// (primarily the Codex IDE extension). NOTE: these are different from
|
||||
/// notifications - notices are warnings, NUX screens, acknowledgements, etc.
|
||||
#[derive(Deserialize, Debug, Clone, PartialEq, Default)]
|
||||
pub struct Notice {
|
||||
/// Tracks whether the user has acknowledged the full access warning prompt.
|
||||
pub hide_full_access_warning: Option<bool>,
|
||||
}
|
||||
|
||||
impl Notice {
|
||||
/// used by set_hide_full_access_warning until we refactor config updates
|
||||
pub(crate) const TABLE_KEY: &'static str = "notice";
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone, PartialEq, Default)]
|
||||
pub struct SandboxWorkspaceWrite {
|
||||
#[serde(default)]
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user