diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index 47456a64..ae414079 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -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::()?, + 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, + /// Collection of in-product notices (different from notifications) + /// See [`crate::config_types::Notices`] for more details + pub notice: Option, + /// Legacy, now use features pub experimental_instructions_file: Option, pub experimental_use_exec_command_tool: Option, @@ -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(), diff --git a/codex-rs/core/src/config_types.rs b/codex-rs/core/src/config_types.rs index 0ef92485..b724086a 100644 --- a/codex-rs/core/src/config_types.rs +++ b/codex-rs/core/src/config_types.rs @@ -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, +} + +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)] diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 6124f377..11a10d6d 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -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; } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 9d79c8ae..402b0fa4 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -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, }, + /// 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), diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 88249f13..775242a5 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -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 = 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 = 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 { + 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> = 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 = 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) { self.config.model_reasoning_effort = effort; diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup.snap new file mode 100644 index 00000000..1d612966 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup.snap @@ -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 diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__full_access_confirmation_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__full_access_confirmation_popup.snap new file mode 100644 index 00000000..71dac5f5 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__full_access_confirmation_popup.snap @@ -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 diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index a9102fdb..adadcd0a 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -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();