Compare commits

...

1 Commits

Author SHA1 Message Date
Dylan
5f30780df3 Add TUI full access confirmation warning 2025-10-08 17:26:06 -07:00
8 changed files with 233 additions and 13 deletions

View File

@@ -136,6 +136,8 @@ pub struct Config {
/// TUI notifications preference. When set, the TUI will send OSC 9 notifications on approvals
/// and turn completions when not focused.
pub tui_notifications: Notifications,
/// Whether to suppress the confirmation dialog before enabling the Full Access preset in the TUI.
pub skip_full_access_warning: bool,
/// The directory that should be treated as the current working directory
/// for the session. All relative paths inside the business-logic layer are
@@ -1181,6 +1183,11 @@ impl Config {
.as_ref()
.map(|t| t.notifications.clone())
.unwrap_or_default(),
skip_full_access_warning: cfg
.tui
.as_ref()
.map(|t| t.skip_full_access_warning)
.unwrap_or(false),
otel: {
let t: OtelConfigToml = cfg.otel.unwrap_or_default();
let log_user_prompt = t.log_user_prompt.unwrap_or(false);
@@ -1355,6 +1362,7 @@ persistence = "none"
let tui = parsed.tui.expect("config should include tui section");
assert_eq!(tui.notifications, Notifications::Enabled(false));
assert!(!tui.skip_full_access_warning);
}
#[test]
@@ -2081,6 +2089,7 @@ model_verbosity = "high"
windows_wsl_setup_acknowledged: false,
disable_paste_burst: false,
tui_notifications: Default::default(),
skip_full_access_warning: false,
otel: OtelConfig::default(),
},
o3_profile_config
@@ -2144,6 +2153,7 @@ model_verbosity = "high"
windows_wsl_setup_acknowledged: false,
disable_paste_burst: false,
tui_notifications: Default::default(),
skip_full_access_warning: false,
otel: OtelConfig::default(),
};
@@ -2222,6 +2232,7 @@ model_verbosity = "high"
windows_wsl_setup_acknowledged: false,
disable_paste_burst: false,
tui_notifications: Default::default(),
skip_full_access_warning: false,
otel: OtelConfig::default(),
};
@@ -2286,6 +2297,7 @@ model_verbosity = "high"
windows_wsl_setup_acknowledged: false,
disable_paste_burst: false,
tui_notifications: Default::default(),
skip_full_access_warning: false,
otel: OtelConfig::default(),
};
@@ -2399,7 +2411,10 @@ mod notifications_tests {
#[derive(Deserialize, Debug, PartialEq)]
struct TuiTomlTest {
#[serde(default)]
notifications: Notifications,
#[serde(default)]
skip_full_access_warning: bool,
}
#[derive(Deserialize, Debug, PartialEq)]
@@ -2415,6 +2430,7 @@ mod notifications_tests {
"#;
let parsed: RootTomlTest = toml::from_str(toml).expect("deserialize notifications=true");
assert_matches!(parsed.tui.notifications, Notifications::Enabled(true));
assert!(!parsed.tui.skip_full_access_warning);
}
#[test]
@@ -2429,5 +2445,17 @@ mod notifications_tests {
parsed.tui.notifications,
Notifications::Custom(ref v) if v == &vec!["foo".to_string()]
);
assert!(!parsed.tui.skip_full_access_warning);
}
#[test]
fn test_skip_full_access_warning_true() {
let toml = r#"
[tui]
skip_full_access_warning = true
"#;
let parsed: RootTomlTest =
toml::from_str(toml).expect("deserialize skip_full_access_warning");
assert!(parsed.tui.skip_full_access_warning);
}
}

View File

@@ -309,6 +309,10 @@ pub struct Tui {
/// Defaults to `false`.
#[serde(default)]
pub notifications: Notifications,
/// Skip the confirmation warning shown before enabling the dangerous Full Access preset.
#[serde(default)]
pub skip_full_access_warning: bool,
}
#[derive(Deserialize, Debug, Clone, PartialEq, Default)]

View File

@@ -17,6 +17,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_edit::persist_overrides;
use codex_core::model_family::find_family_for_model;
use codex_core::protocol::SessionSource;
use codex_core::protocol::TokenUsage;
@@ -368,9 +369,56 @@ impl App {
}
AppEvent::UpdateAskForApprovalPolicy(policy) => {
self.chat_widget.set_approval_policy(policy);
self.config.approval_policy = policy;
}
AppEvent::UpdateSandboxPolicy(policy) => {
self.chat_widget.set_sandbox_policy(policy);
self.chat_widget.set_sandbox_policy(policy.clone());
self.config.sandbox_policy = policy;
}
AppEvent::OpenFullAccessWarning { approval, sandbox } => {
self.chat_widget.open_full_access_warning(approval, sandbox);
}
AppEvent::UpdateSkipFullAccessWarning(skip) => {
self.chat_widget.set_skip_full_access_warning(skip);
self.config.skip_full_access_warning = skip;
}
AppEvent::PersistSkipFullAccessWarning { skip } => {
let value = if skip { "true" } else { "false" };
match persist_overrides(
&self.config.codex_home,
self.active_profile.as_deref(),
&[(&["tui", "skip_full_access_warning"], value)],
)
.await
{
Ok(()) => {
if skip {
self.chat_widget.add_info_message(
"We'll remember that Full Access is trusted on this machine."
.to_string(),
None,
);
} else {
self.chat_widget.add_info_message(
"Full Access will require confirmation again.".to_string(),
None,
);
}
}
Err(err) => {
tracing::error!(
error = %err,
"failed to persist skip_full_access_warning preference",
);
self.chat_widget.add_error_message(format!(
"Failed to save Full Access warning preference: {err}",
));
if skip {
self.chat_widget.set_skip_full_access_warning(false);
self.config.skip_full_access_warning = false;
}
}
}
}
AppEvent::OpenReviewBranchPicker(cwd) => {
self.chat_widget.show_review_branch_picker(&cwd).await;

View File

@@ -73,6 +73,20 @@ pub(crate) enum AppEvent {
/// Update the current sandbox policy in the running app and widget.
UpdateSandboxPolicy(SandboxPolicy),
/// Show the confirmation warning before enabling the Full Access preset.
OpenFullAccessWarning {
approval: AskForApproval,
sandbox: SandboxPolicy,
},
/// Update whether the Full Access warning should be skipped.
UpdateSkipFullAccessWarning(bool),
/// Persist the Full Access warning preference to config.toml.
PersistSkipFullAccessWarning {
skip: bool,
},
/// Forwarded conversation history snapshot from the current conversation.
ConversationHistory(ConversationPathResponseEvent),

View File

@@ -53,8 +53,12 @@ use ratatui::buffer::Buffer;
use ratatui::layout::Constraint;
use ratatui::layout::Layout;
use ratatui::layout::Rect;
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;
@@ -1767,6 +1771,91 @@ impl ChatWidget {
});
}
fn approval_preset_actions(
&self,
approval: AskForApproval,
sandbox: SandboxPolicy,
) -> 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()));
})]
}
pub(crate) fn open_full_access_warning(
&mut self,
approval: AskForApproval,
sandbox: SandboxPolicy,
) {
let warning_lines = vec![
Line::from(
"Full access gives Codex full control over this machine."
.bold()
.red(),
),
Line::from(
"Codex can edit files, run any command, and access the network without asking.",
),
Line::from("Only continue if you completely trust this session."),
];
let header = Paragraph::new(warning_lines).wrap(Wrap { trim: true });
let accept_once_actions = self.approval_preset_actions(approval, sandbox.clone());
let mut accept_and_skip_actions = self.approval_preset_actions(approval, sandbox);
accept_and_skip_actions.push(Box::new(|tx| {
tx.send(AppEvent::UpdateSkipFullAccessWarning(true));
}));
accept_and_skip_actions.push(Box::new(|tx| {
tx.send(AppEvent::PersistSkipFullAccessWarning { skip: true });
}));
let items = vec![
SelectionItem {
name: "Accept full access".to_string(),
description: Some(
"Enable full access for this session while keeping future warnings."
.to_string(),
),
actions: accept_once_actions,
dismiss_on_select: true,
..Default::default()
},
SelectionItem {
name: "Accept and don't ask again".to_string(),
description: Some(
"Enable full access and remember this choice for future sessions.".to_string(),
),
actions: accept_and_skip_actions,
dismiss_on_select: true,
..Default::default()
},
SelectionItem {
name: "Deny".to_string(),
description: Some("Cancel and keep your current approvals preset.".to_string()),
dismiss_on_select: true,
..Default::default()
},
];
self.bottom_pane.show_selection_view(SelectionViewParams {
title: Some("Enable Full Access?".to_string()),
subtitle: Some("Full access is dangerous and bypasses approval prompts.".to_string()),
footer_hint: Some(standard_popup_hint_line()),
items,
header: Box::new(header),
..Default::default()
});
}
/// Open a popup to choose the approvals mode (ask for approval policy + sandbox policy).
pub(crate) fn open_approvals_popup(&mut self) {
let current_approval = self.config.approval_policy;
@@ -1780,18 +1869,18 @@ impl ChatWidget {
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_warning =
preset.id == "full-access" && !self.config.skip_full_access_warning;
let actions: Vec<SelectionAction> = if requires_warning {
vec![Box::new(move |tx| {
tx.send(AppEvent::OpenFullAccessWarning {
approval,
sandbox: sandbox.clone(),
});
})]
} else {
self.approval_preset_actions(approval, sandbox.clone())
};
items.push(SelectionItem {
name,
description,
@@ -1820,6 +1909,10 @@ impl ChatWidget {
self.config.sandbox_policy = policy;
}
pub(crate) fn set_skip_full_access_warning(&mut self, skip: bool) {
self.config.skip_full_access_warning = skip;
}
/// 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,18 @@
---
source: tui/src/chatwidget/tests.rs
expression: popup
---
Full access gives Codex full control over this machine.
Codex can edit files, run any command, and access the network without
asking.
Only continue if you completely trust this session.
Enable Full Access?
Full access is dangerous and bypasses approval prompts.
1. Accept full access Enable full access for this session while
keeping future warnings.
2. Accept and don't ask again Enable full access and remember this choice
for future sessions.
3. Deny Cancel and keep your current approvals
Press enter to confirm or esc to go back

View File

@@ -31,6 +31,7 @@ use codex_core::protocol::ReviewFinding;
use codex_core::protocol::ReviewLineRange;
use codex_core::protocol::ReviewOutputEvent;
use codex_core::protocol::ReviewRequest;
use codex_core::protocol::SandboxPolicy;
use codex_core::protocol::StreamErrorEvent;
use codex_core::protocol::TaskCompleteEvent;
use codex_core::protocol::TaskStartedEvent;
@@ -1057,6 +1058,16 @@ fn model_reasoning_selection_popup_snapshot() {
assert_snapshot!("model_reasoning_selection_popup", popup);
}
#[test]
fn full_access_warning_popup_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual();
chat.open_full_access_warning(AskForApproval::Never, SandboxPolicy::DangerFullAccess);
let popup = render_bottom_popup(&chat, 80);
assert_snapshot!("full_access_warning_popup", popup);
}
#[test]
fn reasoning_popup_escape_returns_to_model_popup() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual();

View File

@@ -763,6 +763,10 @@ notifications = true
# You can optionally filter to specific notification types.
# Available types are "agent-turn-complete" and "approval-requested".
notifications = [ "agent-turn-complete", "approval-requested" ]
# Skip the confirmation dialog when selecting the "Full Access" approvals preset in the TUI.
# Defaults to false to keep the extra warning enabled.
skip_full_access_warning = true
```
> [!NOTE]