codex-rs/tui/src/bottom_pane/approval_overlay.rs (2,214 lines of code) (raw):
//! Approval modal rendering and decision routing for high-risk operations.
//!
//! This module converts agent approval requests (exec/apply-patch/MCP
//! elicitation) into a list-selection view with action-specific options and
//! shortcuts. It owns two important contracts:
//!
//! 1. Selection always emits an explicit decision event back to the app.
//! 2. MCP elicitation keeps `Esc` mapped to `Cancel`, even with custom
//! keybindings, so dismissal never silently becomes "continue without info".
//!
//! This module does not evaluate whether an action is safe to run; it only
//! presents choices and routes user decisions.
use std::collections::HashMap;
use std::path::PathBuf;
use crate::app::app_server_requests::ResolvedAppServerRequest;
#[cfg(test)]
use crate::app_command::AppCommand as Op;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::BottomPaneView;
use crate::bottom_pane::CancellationEvent;
use crate::bottom_pane::list_selection_view::ListSelectionView;
use crate::bottom_pane::list_selection_view::SelectionItem;
use crate::bottom_pane::list_selection_view::SelectionViewParams;
use crate::bottom_pane::popup_consts::accept_cancel_hint_line;
use crate::diff_model::FileChange;
use crate::exec_command::strip_bash_lc_and_escape;
use crate::history_cell;
use crate::history_cell::ReviewDecision;
use crate::key_hint;
use crate::key_hint::KeyBinding;
use crate::key_hint::KeyBindingListExt;
use crate::keymap::ApprovalKeymap;
use crate::keymap::ListKeymap;
use crate::keymap::primary_binding;
use crate::render::highlight::highlight_bash_to_lines;
use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::Renderable;
use codex_app_server_protocol::AdditionalPermissionProfile;
use codex_app_server_protocol::CommandExecutionApprovalDecision;
use codex_app_server_protocol::FileChangeApprovalDecision;
use codex_app_server_protocol::FileSystemAccessMode;
use codex_app_server_protocol::FileSystemPath;
use codex_app_server_protocol::FileSystemSandboxEntry;
use codex_app_server_protocol::FileSystemSpecialPath;
use codex_app_server_protocol::McpServerElicitationAction;
use codex_app_server_protocol::NetworkApprovalContext;
use codex_app_server_protocol::NetworkApprovalProtocol;
use codex_app_server_protocol::NetworkPolicyRuleAction;
use codex_app_server_protocol::RequestId;
use codex_features::Features;
use codex_protocol::ThreadId;
use codex_protocol::request_permissions::PermissionGrantScope;
use codex_protocol::request_permissions::RequestPermissionProfile;
use codex_utils_absolute_path::AbsolutePathBuf;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Wrap;
/// Request coming from the agent that needs user approval.
#[derive(Clone, Debug)]
pub(crate) enum ApprovalRequest {
Exec {
thread_id: ThreadId,
thread_label: Option<String>,
id: String,
command: Vec<String>,
reason: Option<String>,
available_decisions: Vec<CommandExecutionApprovalDecision>,
network_approval_context: Option<NetworkApprovalContext>,
additional_permissions: Option<AdditionalPermissionProfile>,
},
Permissions {
thread_id: ThreadId,
thread_label: Option<String>,
call_id: String,
environment_id: Option<String>,
reason: Option<String>,
permissions: RequestPermissionProfile,
},
ApplyPatch {
thread_id: ThreadId,
thread_label: Option<String>,
id: String,
reason: Option<String>,
cwd: AbsolutePathBuf,
changes: HashMap<PathBuf, FileChange>,
},
McpElicitation {
thread_id: ThreadId,
thread_label: Option<String>,
server_name: String,
request_id: RequestId,
message: String,
},
}
impl ApprovalRequest {
fn thread_id(&self) -> ThreadId {
match self {
ApprovalRequest::Exec { thread_id, .. }
| ApprovalRequest::Permissions { thread_id, .. }
| ApprovalRequest::ApplyPatch { thread_id, .. }
| ApprovalRequest::McpElicitation { thread_id, .. } => *thread_id,
}
}
fn thread_label(&self) -> Option<&str> {
match self {
ApprovalRequest::Exec { thread_label, .. }
| ApprovalRequest::Permissions { thread_label, .. }
| ApprovalRequest::ApplyPatch { thread_label, .. }
| ApprovalRequest::McpElicitation { thread_label, .. } => thread_label.as_deref(),
}
}
pub(super) fn matches_resolved_request(&self, request: &ResolvedAppServerRequest) -> bool {
match (self, request) {
(
ApprovalRequest::Exec { id, .. },
ResolvedAppServerRequest::ExecApproval { id: resolved_id },
) => id == resolved_id,
(
ApprovalRequest::Permissions { call_id, .. },
ResolvedAppServerRequest::PermissionsApproval { id },
) => call_id == id,
(
ApprovalRequest::ApplyPatch { id, .. },
ResolvedAppServerRequest::FileChangeApproval { id: resolved_id },
) => id == resolved_id,
(
ApprovalRequest::McpElicitation {
server_name,
request_id,
..
},
ResolvedAppServerRequest::McpElicitation {
server_name: resolved_server_name,
request_id: resolved_request_id,
},
) => server_name == resolved_server_name && request_id == resolved_request_id,
_ => false,
}
}
}
/// Modal overlay asking the user to approve or deny one or more requests.
pub(crate) struct ApprovalOverlay {
current_request: Option<ApprovalRequest>,
queue: Vec<ApprovalRequest>,
app_event_tx: AppEventSender,
list: ListSelectionView,
options: Vec<ApprovalOption>,
current_complete: bool,
done: bool,
features: Features,
approval_keymap: ApprovalKeymap,
list_keymap: ListKeymap,
}
impl ApprovalOverlay {
pub fn new(
request: ApprovalRequest,
app_event_tx: AppEventSender,
features: Features,
approval_keymap: ApprovalKeymap,
list_keymap: ListKeymap,
) -> Self {
let mut view = Self {
current_request: None,
queue: Vec::new(),
app_event_tx: app_event_tx.clone(),
list: ListSelectionView::new(Default::default(), app_event_tx, list_keymap.clone()),
options: Vec::new(),
current_complete: false,
done: false,
features,
approval_keymap,
list_keymap,
};
view.set_current(request);
view
}
pub fn enqueue_request(&mut self, req: ApprovalRequest) {
self.queue.push(req);
}
fn dismiss_resolved_request(&mut self, request: &ResolvedAppServerRequest) -> bool {
let queue_len = self.queue.len();
self.queue
.retain(|queued_request| !queued_request.matches_resolved_request(request));
if self
.current_request
.as_ref()
.is_some_and(|current_request| current_request.matches_resolved_request(request))
{
self.current_complete = true;
self.advance_queue();
return true;
}
self.queue.len() != queue_len
}
fn set_current(&mut self, request: ApprovalRequest) {
self.current_complete = false;
let header = build_header(&request);
let (options, params) = Self::build_options(
&request,
header,
&self.features,
&self.approval_keymap,
&self.list_keymap,
);
self.current_request = Some(request);
self.options = options;
self.list =
ListSelectionView::new(params, self.app_event_tx.clone(), self.list_keymap.clone());
}
fn build_options(
request: &ApprovalRequest,
header: Box<dyn Renderable>,
_features: &Features,
approval_keymap: &ApprovalKeymap,
list_keymap: &ListKeymap,
) -> (Vec<ApprovalOption>, SelectionViewParams) {
let (options, title) = match request {
ApprovalRequest::Exec {
available_decisions,
network_approval_context,
additional_permissions,
..
} => (
exec_options(
available_decisions,
network_approval_context.as_ref(),
additional_permissions.as_ref(),
approval_keymap,
),
network_approval_context.as_ref().map_or_else(
|| "Would you like to run the following command?".to_string(),
|network_approval_context| {
format!(
"Do you want to approve network access to \"{}\"?",
network_approval_context.host
)
},
),
),
ApprovalRequest::Permissions { .. } => (
permissions_options(approval_keymap),
"Would you like to grant these permissions?".to_string(),
),
ApprovalRequest::ApplyPatch { .. } => (
patch_options(approval_keymap),
"Would you like to make the following edits?".to_string(),
),
ApprovalRequest::McpElicitation { server_name, .. } => (
elicitation_options(approval_keymap),
format!("{server_name} needs your approval."),
),
};
let header = Box::new(ColumnRenderable::with([
Line::from(title.bold()).into(),
Line::from("").into(),
header,
]));
let items = options
.iter()
.map(|opt| SelectionItem {
name: opt.label.clone(),
display_shortcut: opt.shortcuts.first().copied(),
dismiss_on_select: false,
..Default::default()
})
.collect();
let params = SelectionViewParams {
footer_hint: Some(approval_footer_hint(request, approval_keymap, list_keymap)),
items,
header,
..Default::default()
};
(options, params)
}
fn apply_selection(&mut self, actual_idx: usize) {
if self.current_complete {
return;
}
let Some(option) = self.options.get(actual_idx) else {
return;
};
if let Some(request) = self.current_request.as_ref() {
match (request, &option.decision) {
(
ApprovalRequest::Exec { id, command, .. },
ApprovalDecision::Command(decision),
) => {
self.handle_exec_decision(id, command, decision.clone());
}
(
ApprovalRequest::Permissions {
call_id,
permissions,
..
},
ApprovalDecision::Permissions(decision),
) => self.handle_permissions_decision(call_id, permissions, *decision),
(
ApprovalRequest::ApplyPatch { id, .. },
ApprovalDecision::FileChange(decision),
) => {
self.handle_patch_decision(id, decision.clone());
}
(
ApprovalRequest::McpElicitation {
server_name,
request_id,
..
},
ApprovalDecision::McpElicitation(decision),
) => {
self.handle_elicitation_decision(server_name, request_id, *decision);
}
_ => {}
}
}
self.current_complete = true;
self.advance_queue();
}
fn handle_exec_decision(
&self,
id: &str,
command: &[String],
decision: CommandExecutionApprovalDecision,
) {
let Some(request) = self.current_request.as_ref() else {
return;
};
if request.thread_label().is_none() {
let subject = match request {
ApprovalRequest::Exec {
network_approval_context: Some(network_approval_context),
..
} => history_cell::ApprovalDecisionSubject::NetworkAccess {
target: network_approval_target(network_approval_context, command),
},
_ => {
if let Some(target) = network_approval_command_target(command) {
history_cell::ApprovalDecisionSubject::NetworkAccess {
target: target.to_string(),
}
} else {
history_cell::ApprovalDecisionSubject::Command(command.to_vec())
}
}
};
let cell = history_cell::new_approval_decision_cell(
subject,
command_decision_to_review_decision(&decision),
history_cell::ApprovalDecisionActor::User,
);
self.app_event_tx.send(AppEvent::InsertHistoryCell(cell));
}
let thread_id = request.thread_id();
self.app_event_tx
.exec_approval(thread_id, id.to_string(), decision);
}
fn handle_permissions_decision(
&self,
call_id: &str,
permissions: &RequestPermissionProfile,
decision: PermissionsDecision,
) {
let Some(request) = self.current_request.as_ref() else {
return;
};
let granted_permissions = match decision {
PermissionsDecision::GrantForTurn
| PermissionsDecision::GrantForTurnWithStrictAutoReview
| PermissionsDecision::GrantForSession => permissions.clone(),
PermissionsDecision::Deny => Default::default(),
};
let scope = if matches!(decision, PermissionsDecision::GrantForSession) {
PermissionGrantScope::Session
} else {
PermissionGrantScope::Turn
};
let strict_auto_review = matches!(
decision,
PermissionsDecision::GrantForTurnWithStrictAutoReview
);
if request.thread_label().is_none() {
let message = if granted_permissions.is_empty() {
"You did not grant additional permissions"
} else if strict_auto_review {
"You granted additional permissions with strict auto review"
} else if matches!(scope, PermissionGrantScope::Session) {
"You granted additional permissions for this session"
} else {
"You granted additional permissions"
};
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
crate::history_cell::PlainHistoryCell::new(vec![message.into()]),
)));
}
let thread_id = request.thread_id();
self.app_event_tx.request_permissions_response(
thread_id,
call_id.to_string(),
codex_protocol::request_permissions::RequestPermissionsResponse {
permissions: granted_permissions,
scope,
strict_auto_review,
},
);
}
fn handle_patch_decision(&self, id: &str, decision: FileChangeApprovalDecision) {
let Some(thread_id) = self
.current_request
.as_ref()
.map(ApprovalRequest::thread_id)
else {
return;
};
self.app_event_tx
.patch_approval(thread_id, id.to_string(), decision);
}
fn handle_elicitation_decision(
&self,
server_name: &str,
request_id: &RequestId,
decision: McpServerElicitationAction,
) {
let Some(thread_id) = self
.current_request
.as_ref()
.map(ApprovalRequest::thread_id)
else {
return;
};
self.app_event_tx.resolve_elicitation(
thread_id,
server_name.to_string(),
request_id.clone(),
decision,
/*content*/ None,
/*meta*/ None,
);
}
fn advance_queue(&mut self) {
if let Some(next) = self.queue.pop() {
self.set_current(next);
} else {
self.done = true;
}
}
fn cancel_current_request(&mut self) {
if self.done {
return;
}
if !self.current_complete
&& let Some(request) = self.current_request.as_ref()
{
match request {
ApprovalRequest::Exec { id, command, .. } => {
self.handle_exec_decision(
id,
command,
CommandExecutionApprovalDecision::Cancel,
);
}
ApprovalRequest::Permissions {
call_id,
permissions,
..
} => {
self.handle_permissions_decision(
call_id,
permissions,
PermissionsDecision::Deny,
);
}
ApprovalRequest::ApplyPatch { id, .. } => {
self.handle_patch_decision(id, FileChangeApprovalDecision::Cancel);
}
ApprovalRequest::McpElicitation {
server_name,
request_id,
..
} => {
self.handle_elicitation_decision(
server_name,
request_id,
McpServerElicitationAction::Cancel,
);
}
}
}
self.queue.clear();
self.done = true;
}
/// Apply approval-specific shortcuts before delegating to list navigation.
///
/// `open_fullscreen` is handled here because it is orthogonal to list item
/// selection and should work regardless of current highlighted row.
fn try_handle_shortcut(&mut self, key_event: &KeyEvent) -> bool {
if key_event.kind == KeyEventKind::Press
&& self.approval_keymap.open_fullscreen.is_pressed(*key_event)
&& let Some(request) = self.current_request.as_ref()
{
self.app_event_tx
.send(AppEvent::FullScreenApprovalRequest(request.clone()));
return true;
}
if key_event.kind == KeyEventKind::Press
&& self.approval_keymap.open_thread.is_pressed(*key_event)
&& let Some(request) = self.current_request.as_ref()
&& request.thread_label().is_some()
{
self.app_event_tx
.send(AppEvent::SelectAgentThread(request.thread_id()));
return true;
}
if self.list_keymap.cancel.is_pressed(*key_event) {
self.cancel_current_request();
return true;
}
if let Some(idx) = self
.options
.iter()
.position(|opt| opt.shortcuts.iter().any(|s| s.is_press(*key_event)))
{
self.apply_selection(idx);
true
} else {
false
}
}
}
impl BottomPaneView for ApprovalOverlay {
fn handle_key_event(&mut self, key_event: KeyEvent) {
if self.try_handle_shortcut(&key_event) {
return;
}
self.list.handle_key_event(key_event);
if let Some(idx) = self.list.take_last_selected_index() {
self.apply_selection(idx);
}
}
fn on_ctrl_c(&mut self) -> CancellationEvent {
self.cancel_current_request();
CancellationEvent::Handled
}
fn is_complete(&self) -> bool {
self.done
}
fn try_consume_approval_request(
&mut self,
request: ApprovalRequest,
) -> Option<ApprovalRequest> {
self.enqueue_request(request);
None
}
fn dismiss_app_server_request(&mut self, request: &ResolvedAppServerRequest) -> bool {
self.dismiss_resolved_request(request)
}
fn terminal_title_requires_action(&self) -> bool {
true
}
}
impl Renderable for ApprovalOverlay {
fn desired_height(&self, width: u16) -> u16 {
self.list.desired_height(width)
}
fn render(&self, area: Rect, buf: &mut Buffer) {
self.list.render(area, buf);
}
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
self.list.cursor_pos(area)
}
}
fn approval_footer_hint(
request: &ApprovalRequest,
approval_keymap: &ApprovalKeymap,
list_keymap: &ListKeymap,
) -> Line<'static> {
let mut spans = accept_cancel_hint_line(
primary_binding(&list_keymap.accept),
"to confirm",
primary_binding(&list_keymap.cancel),
"to cancel",
)
.spans;
if request.thread_label().is_some()
&& let Some(open_thread) = primary_binding(&approval_keymap.open_thread)
{
if !spans.is_empty() {
spans.push(" or ".into());
} else {
spans.push("Press ".into());
}
spans.extend([open_thread.into(), " to open thread".into()]);
}
Line::from(spans)
}
fn network_approval_target(
network_approval_context: &NetworkApprovalContext,
command: &[String],
) -> String {
if let Some(target) = network_approval_command_target(command) {
return target.to_string();
}
let scheme = match network_approval_context.protocol {
NetworkApprovalProtocol::Http => "http",
NetworkApprovalProtocol::Https => "https",
NetworkApprovalProtocol::Socks5Tcp => "socks5-tcp",
NetworkApprovalProtocol::Socks5Udp => "socks5-udp",
};
format!("{scheme}://{}", network_approval_context.host)
}
fn network_approval_command_target(command: &[String]) -> Option<&str> {
match command {
[program, target] if program == "network-access" && !target.is_empty() => {
Some(target.as_str())
}
[command] => command
.strip_prefix("network-access ")
.filter(|target| !target.is_empty()),
_ => None,
}
}
fn build_header(request: &ApprovalRequest) -> Box<dyn Renderable> {
match request {
ApprovalRequest::Exec {
thread_label,
reason,
command,
network_approval_context,
additional_permissions,
..
} => {
let mut header: Vec<Line<'static>> = Vec::new();
if let Some(thread_label) = thread_label {
header.push(Line::from(vec![
"Thread: ".into(),
thread_label.clone().bold(),
]));
header.push(Line::from(""));
}
if let Some(reason) = reason {
header.push(Line::from(vec!["Reason: ".into(), reason.clone().italic()]));
header.push(Line::from(""));
}
if let Some(additional_permissions) = additional_permissions
&& let Some(rule_line) = format_additional_permissions_rule(additional_permissions)
{
header.push(Line::from(vec![
"Permission rule: ".into(),
rule_line.cyan(),
]));
header.push(Line::from(""));
}
let full_cmd = strip_bash_lc_and_escape(command);
let mut full_cmd_lines = highlight_bash_to_lines(&full_cmd);
if let Some(first) = full_cmd_lines.first_mut() {
first.spans.insert(0, Span::from("$ "));
}
if network_approval_context.is_none() {
header.extend(full_cmd_lines);
}
Box::new(Paragraph::new(header).wrap(Wrap { trim: false }))
}
ApprovalRequest::Permissions {
thread_label,
environment_id,
reason,
permissions,
..
} => {
let mut header: Vec<Line<'static>> = Vec::new();
if let Some(thread_label) = thread_label {
header.push(Line::from(vec![
"Thread: ".into(),
thread_label.clone().bold(),
]));
header.push(Line::from(""));
}
if let Some(environment_id) = environment_id {
header.push(Line::from(vec![
"Environment: ".into(),
environment_id.clone().bold(),
]));
header.push(Line::from(""));
}
if let Some(reason) = reason {
header.push(Line::from(vec!["Reason: ".into(), reason.clone().italic()]));
header.push(Line::from(""));
}
if let Some(rule_line) = format_requested_permissions_rule(permissions) {
header.push(Line::from(vec![
"Permission rule: ".into(),
rule_line.cyan(),
]));
}
Box::new(Paragraph::new(header).wrap(Wrap { trim: false }))
}
ApprovalRequest::ApplyPatch {
thread_label,
reason,
..
} => {
let mut header: Vec<Box<dyn Renderable>> = Vec::new();
if let Some(thread_label) = thread_label {
header.push(Box::new(Line::from(vec![
"Thread: ".into(),
thread_label.clone().bold(),
])));
}
if let Some(reason) = reason
&& !reason.is_empty()
{
if !header.is_empty() {
header.push(Box::new(Line::from("")));
}
header.push(Box::new(
Paragraph::new(Line::from_iter([
"Reason: ".into(),
reason.clone().italic(),
]))
.wrap(Wrap { trim: false }),
));
}
Box::new(ColumnRenderable::with(header))
}
ApprovalRequest::McpElicitation {
thread_label,
server_name,
message,
..
} => {
let mut lines = Vec::new();
if let Some(thread_label) = thread_label {
lines.push(Line::from(vec![
"Thread: ".into(),
thread_label.clone().bold(),
]));
lines.push(Line::from(""));
}
lines.extend([
Line::from(vec!["Server: ".into(), server_name.clone().bold()]),
Line::from(""),
Line::from(message.clone()),
]);
let header = Paragraph::new(lines).wrap(Wrap { trim: false });
Box::new(header)
}
}
}
#[derive(Clone)]
enum ApprovalDecision {
Command(CommandExecutionApprovalDecision),
FileChange(FileChangeApprovalDecision),
Permissions(PermissionsDecision),
McpElicitation(McpServerElicitationAction),
}
#[derive(Clone, Copy)]
enum PermissionsDecision {
GrantForTurn,
GrantForTurnWithStrictAutoReview,
GrantForSession,
Deny,
}
#[derive(Clone)]
struct ApprovalOption {
label: String,
decision: ApprovalDecision,
shortcuts: Vec<KeyBinding>,
}
fn command_decision_to_review_decision(
decision: &CommandExecutionApprovalDecision,
) -> ReviewDecision {
match decision {
CommandExecutionApprovalDecision::Accept => ReviewDecision::Approved,
CommandExecutionApprovalDecision::AcceptForSession => ReviewDecision::ApprovedForSession,
CommandExecutionApprovalDecision::AcceptWithExecpolicyAmendment {
execpolicy_amendment,
} => ReviewDecision::ApprovedExecpolicyAmendment {
proposed_execpolicy_amendment: execpolicy_amendment.clone().into_core(),
},
CommandExecutionApprovalDecision::ApplyNetworkPolicyAmendment {
network_policy_amendment,
} => ReviewDecision::NetworkPolicyAmendment {
network_policy_amendment: network_policy_amendment.clone().into_core(),
},
CommandExecutionApprovalDecision::Decline => ReviewDecision::Denied,
CommandExecutionApprovalDecision::Cancel => ReviewDecision::Abort,
}
}
fn exec_options(
available_decisions: &[CommandExecutionApprovalDecision],
network_approval_context: Option<&NetworkApprovalContext>,
additional_permissions: Option<&AdditionalPermissionProfile>,
keymap: &ApprovalKeymap,
) -> Vec<ApprovalOption> {
available_decisions
.iter()
.filter_map(|decision| match decision {
CommandExecutionApprovalDecision::Accept => Some(ApprovalOption {
label: if network_approval_context.is_some() {
"Yes, just this once".to_string()
} else {
"Yes, proceed".to_string()
},
decision: ApprovalDecision::Command(CommandExecutionApprovalDecision::Accept),
shortcuts: keymap.approve.clone(),
}),
CommandExecutionApprovalDecision::AcceptWithExecpolicyAmendment {
execpolicy_amendment,
} => {
let rendered_prefix = strip_bash_lc_and_escape(&execpolicy_amendment.command);
if rendered_prefix.contains('\n') || rendered_prefix.contains('\r') {
return None;
}
Some(ApprovalOption {
label: format!(
"Yes, and don't ask again for commands that start with `{rendered_prefix}`"
),
decision: ApprovalDecision::Command(
CommandExecutionApprovalDecision::AcceptWithExecpolicyAmendment {
execpolicy_amendment: execpolicy_amendment.clone(),
},
),
shortcuts: keymap.approve_for_prefix.clone(),
})
}
CommandExecutionApprovalDecision::AcceptForSession => Some(ApprovalOption {
label: if network_approval_context.is_some() {
"Yes, and allow this host for this conversation".to_string()
} else if additional_permissions.is_some() {
"Yes, and allow these permissions for this session".to_string()
} else {
"Yes, and don't ask again for this command in this session".to_string()
},
decision: ApprovalDecision::Command(
CommandExecutionApprovalDecision::AcceptForSession,
),
shortcuts: keymap.approve_for_session.clone(),
}),
CommandExecutionApprovalDecision::ApplyNetworkPolicyAmendment {
network_policy_amendment,
} => {
let (label, shortcuts) = match network_policy_amendment.action {
NetworkPolicyRuleAction::Allow => (
"Yes, and allow this host in the future".to_string(),
keymap.approve_for_prefix.clone(),
),
NetworkPolicyRuleAction::Deny => (
"No, and block this host in the future".to_string(),
keymap.deny.clone(),
),
};
Some(ApprovalOption {
label,
decision: ApprovalDecision::Command(
CommandExecutionApprovalDecision::ApplyNetworkPolicyAmendment {
network_policy_amendment: network_policy_amendment.clone(),
},
),
shortcuts,
})
}
CommandExecutionApprovalDecision::Decline => Some(ApprovalOption {
label: "No, continue without running it".to_string(),
decision: ApprovalDecision::Command(CommandExecutionApprovalDecision::Decline),
shortcuts: keymap.deny.clone(),
}),
CommandExecutionApprovalDecision::Cancel => Some(ApprovalOption {
label: "No, and tell Codex what to do differently".to_string(),
decision: ApprovalDecision::Command(CommandExecutionApprovalDecision::Cancel),
shortcuts: keymap.decline.clone(),
}),
})
.collect()
}
pub(crate) fn format_additional_permissions_rule(
additional_permissions: &AdditionalPermissionProfile,
) -> Option<String> {
let mut parts = Vec::new();
if additional_permissions
.network
.as_ref()
.and_then(|network| network.enabled)
.unwrap_or(false)
{
parts.push("network".to_string());
}
if let Some(file_system) = additional_permissions.file_system.as_ref() {
let reads = format_file_system_entry_paths(
file_system
.entries
.iter()
.flatten()
.filter(|entry| entry.access == FileSystemAccessMode::Read),
);
if !reads.is_empty() {
parts.push(format!("read {reads}"));
}
let writes = format_file_system_entry_paths(
file_system
.entries
.iter()
.flatten()
.filter(|entry| entry.access == FileSystemAccessMode::Write),
);
if !writes.is_empty() {
parts.push(format!("write {writes}"));
}
let denied_reads = format_file_system_entry_paths(
file_system
.entries
.iter()
.flatten()
.filter(|entry| entry.access == FileSystemAccessMode::Deny),
);
if !denied_reads.is_empty() {
parts.push(format!("deny read {denied_reads}"));
}
}
if parts.is_empty() {
None
} else {
Some(parts.join("; "))
}
}
pub(crate) fn format_requested_permissions_rule(
permissions: &RequestPermissionProfile,
) -> Option<String> {
let permissions =
crate::app_server_approval_conversions::granted_permission_profile_from_request(
permissions.clone(),
);
format_additional_permissions_rule(&AdditionalPermissionProfile {
network: permissions.network,
file_system: permissions.file_system,
})
}
fn format_file_system_entry_paths<'a>(
entries: impl Iterator<Item = &'a FileSystemSandboxEntry>,
) -> String {
entries
.map(|entry| match &entry.path {
FileSystemPath::Path { path } => format!("`{}`", path.display()),
FileSystemPath::GlobPattern { pattern } => format!("glob `{pattern}`"),
FileSystemPath::Special { value } => format!("`{}`", special_path_label(value)),
})
.collect::<Vec<_>>()
.join(", ")
}
fn special_path_label(value: &FileSystemSpecialPath) -> String {
match value {
FileSystemSpecialPath::Root => ":root".to_string(),
FileSystemSpecialPath::Minimal => ":minimal".to_string(),
FileSystemSpecialPath::ProjectRoots { subpath } => path_label(":workspace_roots", subpath),
FileSystemSpecialPath::Tmpdir => ":tmpdir".to_string(),
FileSystemSpecialPath::SlashTmp => "/tmp".to_string(),
FileSystemSpecialPath::Unknown { path, subpath } => path_label(path, subpath),
}
}
fn path_label(base: &str, subpath: &Option<PathBuf>) -> String {
match subpath {
Some(subpath) => format!("{base}/{}", subpath.display()),
None => base.to_string(),
}
}
fn patch_options(keymap: &ApprovalKeymap) -> Vec<ApprovalOption> {
vec![
ApprovalOption {
label: "Yes, proceed".to_string(),
decision: ApprovalDecision::FileChange(FileChangeApprovalDecision::Accept),
shortcuts: keymap.approve.clone(),
},
ApprovalOption {
label: "Yes, and don't ask again for these files".to_string(),
decision: ApprovalDecision::FileChange(FileChangeApprovalDecision::AcceptForSession),
shortcuts: keymap.approve_for_session.clone(),
},
ApprovalOption {
label: "No, and tell Codex what to do differently".to_string(),
decision: ApprovalDecision::FileChange(FileChangeApprovalDecision::Cancel),
shortcuts: keymap.decline.clone(),
},
]
}
fn permissions_options(keymap: &ApprovalKeymap) -> Vec<ApprovalOption> {
let deny_shortcuts = keymap
.deny
.iter()
.copied()
.filter(|shortcut| shortcut.parts() != (KeyCode::Esc, KeyModifiers::NONE))
.collect();
vec![
ApprovalOption {
label: "Yes, grant these permissions for this turn".to_string(),
decision: ApprovalDecision::Permissions(PermissionsDecision::GrantForTurn),
shortcuts: keymap.approve.clone(),
},
ApprovalOption {
label: "Yes, grant for this turn with strict auto review".to_string(),
decision: ApprovalDecision::Permissions(
PermissionsDecision::GrantForTurnWithStrictAutoReview,
),
shortcuts: vec![key_hint::plain(KeyCode::Char('r'))],
},
ApprovalOption {
label: "Yes, grant these permissions for this session".to_string(),
decision: ApprovalDecision::Permissions(PermissionsDecision::GrantForSession),
shortcuts: keymap.approve_for_session.clone(),
},
ApprovalOption {
label: "No, continue without permissions".to_string(),
decision: ApprovalDecision::Permissions(PermissionsDecision::Deny),
shortcuts: deny_shortcuts,
},
]
}
/// Build MCP elicitation options with stable cancellation semantics.
///
/// `Esc` is always treated as cancel for elicitation prompts, even if users
/// customize `decline`/`cancel` bindings. We keep this as a hard contract so
/// dismissal remains a safe abort path and never silently maps to "continue
/// without requested info." Any decline/cancel overlap is removed from the
/// decline option in elicitation mode to preserve this invariant.
fn elicitation_options(keymap: &ApprovalKeymap) -> Vec<ApprovalOption> {
let mut cancel_shortcuts = vec![key_hint::plain(KeyCode::Esc)];
for shortcut in &keymap.cancel {
if !cancel_shortcuts.contains(shortcut) {
cancel_shortcuts.push(*shortcut);
}
}
let decline_shortcuts: Vec<KeyBinding> = keymap
.decline
.iter()
.copied()
.filter(|shortcut| !cancel_shortcuts.contains(shortcut))
.collect();
vec![
ApprovalOption {
label: "Yes, provide the requested info".to_string(),
decision: ApprovalDecision::McpElicitation(McpServerElicitationAction::Accept),
shortcuts: keymap.approve.clone(),
},
ApprovalOption {
label: "No, but continue without it".to_string(),
decision: ApprovalDecision::McpElicitation(McpServerElicitationAction::Decline),
shortcuts: decline_shortcuts,
},
ApprovalOption {
label: "Cancel this request".to_string(),
decision: ApprovalDecision::McpElicitation(McpServerElicitationAction::Cancel),
shortcuts: cancel_shortcuts,
},
]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app_event::AppEvent;
use codex_app_server_protocol::AdditionalFileSystemPermissions;
use codex_app_server_protocol::AdditionalNetworkPermissions;
use codex_app_server_protocol::ExecPolicyAmendment;
use codex_app_server_protocol::NetworkApprovalProtocol;
use codex_app_server_protocol::NetworkPolicyAmendment;
use codex_protocol::models::FileSystemPermissions;
use codex_protocol::models::NetworkPermissions;
use codex_utils_absolute_path::AbsolutePathBuf;
use crossterm::event::KeyModifiers;
use insta::assert_snapshot;
use pretty_assertions::assert_eq;
use tokio::sync::mpsc::unbounded_channel;
fn absolute_path(path: &str) -> AbsolutePathBuf {
AbsolutePathBuf::from_absolute_path(path).expect("absolute path")
}
fn render_overlay_lines(view: &ApprovalOverlay, width: u16) -> String {
let height = view.desired_height(width);
let mut buf = Buffer::empty(Rect::new(0, 0, width, height));
view.render(Rect::new(0, 0, width, height), &mut buf);
(0..buf.area.height)
.map(|row| {
(0..buf.area.width)
.map(|col| buf[(col, row)].symbol().to_string())
.collect::<String>()
.trim_end()
.to_string()
})
.collect::<Vec<_>>()
.join("\n")
}
fn render_history_cell_lines(
cell: &dyn crate::history_cell::HistoryCell,
width: u16,
) -> Vec<String> {
cell.display_lines(width)
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
})
.collect()
}
fn normalize_snapshot_paths(rendered: String) -> String {
[
(absolute_path("/tmp/readme.txt"), "/tmp/readme.txt"),
(absolute_path("/tmp/out.txt"), "/tmp/out.txt"),
]
.into_iter()
.fold(rendered, |rendered, (path, normalized)| {
rendered.replace(&path.display().to_string(), normalized)
})
}
fn make_overlay(
request: ApprovalRequest,
app_event_tx: AppEventSender,
features: Features,
) -> ApprovalOverlay {
let keymap = crate::keymap::RuntimeKeymap::defaults();
make_overlay_with_keymap(
request,
app_event_tx,
features,
keymap.approval,
keymap.list,
)
}
fn make_overlay_with_keymap(
request: ApprovalRequest,
app_event_tx: AppEventSender,
features: Features,
approval_keymap: ApprovalKeymap,
list_keymap: ListKeymap,
) -> ApprovalOverlay {
ApprovalOverlay::new(
request,
app_event_tx,
features,
approval_keymap,
list_keymap,
)
}
fn make_exec_request() -> ApprovalRequest {
ApprovalRequest::Exec {
thread_id: ThreadId::new(),
thread_label: None,
id: "test".to_string(),
command: vec!["echo".to_string(), "hi".to_string()],
reason: Some("reason".to_string()),
available_decisions: vec![
CommandExecutionApprovalDecision::Accept,
CommandExecutionApprovalDecision::Cancel,
],
network_approval_context: None,
additional_permissions: None,
}
}
fn make_permissions_request() -> ApprovalRequest {
ApprovalRequest::Permissions {
thread_id: ThreadId::new(),
thread_label: None,
call_id: "test".to_string(),
environment_id: None,
reason: Some("need workspace access".to_string()),
permissions: RequestPermissionProfile {
network: Some(NetworkPermissions {
enabled: Some(true),
}),
file_system: Some(FileSystemPermissions::from_read_write_roots(
Some(vec![absolute_path("/tmp/readme.txt")]),
Some(vec![absolute_path("/tmp/out.txt")]),
)),
},
}
}
fn make_elicitation_request() -> ApprovalRequest {
ApprovalRequest::McpElicitation {
thread_id: ThreadId::new(),
thread_label: None,
server_name: "test-server".to_string(),
request_id: RequestId::String("request-1".to_string()),
message: "Need more information".to_string(),
}
}
#[test]
fn ctrl_c_aborts_and_clears_queue() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx);
let mut view = make_overlay(make_exec_request(), tx, Features::with_defaults());
view.enqueue_request(make_exec_request());
assert_eq!(CancellationEvent::Handled, view.on_ctrl_c());
assert!(view.queue.is_empty());
assert!(view.is_complete());
}
#[test]
fn configured_list_cancel_aborts_exec_approval() {
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut keymap = crate::keymap::RuntimeKeymap::defaults();
keymap.list.cancel = vec![key_hint::plain(KeyCode::Char('q'))];
let mut view = make_overlay_with_keymap(
make_exec_request(),
tx,
Features::with_defaults(),
keymap.approval,
keymap.list,
);
view.handle_key_event(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE));
assert!(view.is_complete());
let mut decision = None;
while let Ok(ev) = rx.try_recv() {
if let AppEvent::SubmitThreadOp {
op: Op::ExecApproval { decision: d, .. },
..
} = ev
{
decision = Some(d);
break;
}
}
assert_eq!(decision, Some(CommandExecutionApprovalDecision::Cancel));
}
#[test]
fn configured_list_cancel_cancels_mcp_elicitation() {
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut keymap = crate::keymap::RuntimeKeymap::defaults();
keymap.list.cancel = vec![key_hint::plain(KeyCode::Char('q'))];
let mut view = make_overlay_with_keymap(
make_elicitation_request(),
tx,
Features::with_defaults(),
keymap.approval,
keymap.list,
);
view.handle_key_event(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE));
assert!(view.is_complete());
let mut decision = None;
while let Ok(ev) = rx.try_recv() {
if let AppEvent::SubmitThreadOp {
op: Op::ResolveElicitation { decision: d, .. },
..
} = ev
{
decision = Some(d);
break;
}
}
assert_eq!(decision, Some(McpServerElicitationAction::Cancel));
}
#[test]
fn shortcut_triggers_selection() {
let (tx, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx);
let mut view = make_overlay(make_exec_request(), tx, Features::with_defaults());
assert!(!view.is_complete());
view.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
// We expect at least one thread-scoped approval op message in the queue.
let mut saw_op = false;
while let Ok(ev) = rx.try_recv() {
if matches!(ev, AppEvent::SubmitThreadOp { .. }) {
saw_op = true;
break;
}
}
assert!(saw_op, "expected approval decision to emit an op");
}
#[test]
fn deny_shortcut_submits_denied_exec_decision() {
let (tx, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx);
let mut view = make_overlay(
ApprovalRequest::Exec {
thread_id: ThreadId::new(),
thread_label: None,
id: "test".to_string(),
command: vec!["echo".to_string(), "hi".to_string()],
reason: None,
available_decisions: vec![
CommandExecutionApprovalDecision::Accept,
CommandExecutionApprovalDecision::Decline,
],
network_approval_context: None,
additional_permissions: None,
},
tx,
Features::with_defaults(),
);
view.handle_key_event(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE));
let mut saw_denied = false;
while let Ok(ev) = rx.try_recv() {
if let AppEvent::SubmitThreadOp {
op: Op::ExecApproval { decision, .. },
..
} = ev
{
assert_eq!(decision, CommandExecutionApprovalDecision::Decline);
saw_denied = true;
break;
}
}
assert!(saw_denied, "expected deny shortcut to emit denied decision");
}
#[test]
fn network_deny_shortcut_submits_policy_deny_decision() {
let (tx, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx);
let amendment = NetworkPolicyAmendment {
host: "example.com".to_string(),
action: NetworkPolicyRuleAction::Deny,
};
let mut view = make_overlay(
ApprovalRequest::Exec {
thread_id: ThreadId::new(),
thread_label: None,
id: "test".to_string(),
command: vec!["curl".to_string(), "https://example.com".to_string()],
reason: None,
available_decisions: vec![
CommandExecutionApprovalDecision::Accept,
CommandExecutionApprovalDecision::ApplyNetworkPolicyAmendment {
network_policy_amendment: amendment.clone(),
},
],
network_approval_context: Some(NetworkApprovalContext {
host: "example.com".to_string(),
protocol: NetworkApprovalProtocol::Https,
}),
additional_permissions: None,
},
tx,
Features::with_defaults(),
);
view.handle_key_event(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE));
let mut saw_deny = false;
while let Ok(ev) = rx.try_recv() {
if let AppEvent::SubmitThreadOp {
op: Op::ExecApproval { decision, .. },
..
} = ev
{
assert_eq!(
decision,
CommandExecutionApprovalDecision::ApplyNetworkPolicyAmendment {
network_policy_amendment: amendment
}
);
saw_deny = true;
break;
}
}
assert!(
saw_deny,
"expected deny shortcut to emit network policy deny decision"
);
}
#[test]
fn resolved_request_dismisses_overlay_without_emitting_abort() {
let (tx, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx);
let mut view = make_overlay(make_exec_request(), tx, Features::with_defaults());
assert!(
view.dismiss_app_server_request(&ResolvedAppServerRequest::ExecApproval {
id: "test".to_string(),
})
);
assert!(
view.is_complete(),
"resolved request should close the overlay"
);
assert!(
rx.try_recv().is_err(),
"dismissing a stale request should not emit an approval op"
);
}
#[test]
fn o_opens_source_thread_for_cross_thread_approval() {
let (tx, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx);
let thread_id = ThreadId::new();
let mut view = make_overlay(
ApprovalRequest::Exec {
thread_id,
thread_label: Some("Robie [explorer]".to_string()),
id: "test".to_string(),
command: vec!["echo".to_string(), "hi".to_string()],
reason: None,
available_decisions: vec![
CommandExecutionApprovalDecision::Accept,
CommandExecutionApprovalDecision::Cancel,
],
network_approval_context: None,
additional_permissions: None,
},
tx,
Features::with_defaults(),
);
view.handle_key_event(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE));
let event = rx.try_recv().expect("expected select-agent-thread event");
assert_eq!(
matches!(event, AppEvent::SelectAgentThread(id) if id == thread_id),
true
);
}
#[test]
fn configured_open_thread_shortcut_opens_source_thread() {
let (tx, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx);
let thread_id = ThreadId::new();
let mut keymap = crate::keymap::RuntimeKeymap::defaults();
keymap.approval.open_thread = vec![key_hint::plain(KeyCode::Char('x'))];
let mut view = make_overlay_with_keymap(
ApprovalRequest::Exec {
thread_id,
thread_label: Some("Robie [explorer]".to_string()),
id: "test".to_string(),
command: vec!["echo".to_string(), "hi".to_string()],
reason: None,
available_decisions: vec![
CommandExecutionApprovalDecision::Accept,
CommandExecutionApprovalDecision::Cancel,
],
network_approval_context: None,
additional_permissions: None,
},
tx,
Features::with_defaults(),
keymap.approval,
keymap.list,
);
view.handle_key_event(KeyEvent::new(
KeyCode::Char('o'),
/*modifiers*/ KeyModifiers::NONE,
));
assert!(rx.try_recv().is_err());
view.handle_key_event(KeyEvent::new(
KeyCode::Char('x'),
/*modifiers*/ KeyModifiers::NONE,
));
let event = rx.try_recv().expect("expected select-agent-thread event");
assert!(matches!(event, AppEvent::SelectAgentThread(id) if id == thread_id));
}
#[test]
fn cross_thread_footer_hint_mentions_o_shortcut() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx);
let view = make_overlay(
ApprovalRequest::Exec {
thread_id: ThreadId::new(),
thread_label: Some("Robie [explorer]".to_string()),
id: "test".to_string(),
command: vec!["echo".to_string(), "hi".to_string()],
reason: None,
available_decisions: vec![
CommandExecutionApprovalDecision::Accept,
CommandExecutionApprovalDecision::Cancel,
],
network_approval_context: None,
additional_permissions: None,
},
tx,
Features::with_defaults(),
);
assert_snapshot!(
"approval_overlay_cross_thread_prompt",
render_overlay_lines(&view, /*width*/ 80)
);
}
#[test]
fn exec_prefix_option_emits_execpolicy_amendment() {
let (tx, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx);
let mut view = make_overlay(
ApprovalRequest::Exec {
thread_id: ThreadId::new(),
thread_label: None,
id: "test".to_string(),
command: vec!["echo".to_string()],
reason: None,
available_decisions: vec![
CommandExecutionApprovalDecision::Accept,
CommandExecutionApprovalDecision::AcceptWithExecpolicyAmendment {
execpolicy_amendment: ExecPolicyAmendment {
command: vec!["echo".to_string()],
},
},
CommandExecutionApprovalDecision::Cancel,
],
network_approval_context: None,
additional_permissions: None,
},
tx,
Features::with_defaults(),
);
view.handle_key_event(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE));
let mut saw_op = false;
while let Ok(ev) = rx.try_recv() {
if let AppEvent::SubmitThreadOp {
op: Op::ExecApproval { decision, .. },
..
} = ev
{
assert_eq!(
decision,
CommandExecutionApprovalDecision::AcceptWithExecpolicyAmendment {
execpolicy_amendment: ExecPolicyAmendment {
command: vec!["echo".to_string()],
}
}
);
saw_op = true;
break;
}
}
assert!(
saw_op,
"expected approval decision to emit an op with command prefix"
);
}
#[test]
fn network_deny_forever_shortcut_is_not_bound() {
let (tx, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx);
let mut view = make_overlay(
ApprovalRequest::Exec {
thread_id: ThreadId::new(),
thread_label: None,
id: "test".to_string(),
command: vec!["curl".to_string(), "https://example.com".to_string()],
reason: None,
available_decisions: vec![
CommandExecutionApprovalDecision::Accept,
CommandExecutionApprovalDecision::AcceptForSession,
CommandExecutionApprovalDecision::ApplyNetworkPolicyAmendment {
network_policy_amendment: NetworkPolicyAmendment {
host: "example.com".to_string(),
action: NetworkPolicyRuleAction::Allow,
},
},
CommandExecutionApprovalDecision::Cancel,
],
network_approval_context: Some(NetworkApprovalContext {
host: "example.com".to_string(),
protocol: NetworkApprovalProtocol::Https,
}),
additional_permissions: None,
},
tx,
Features::with_defaults(),
);
view.handle_key_event(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE));
assert!(
rx.try_recv().is_err(),
"unexpected approval event emitted for hidden network deny shortcut"
);
}
#[test]
fn header_includes_command_snippet() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx);
let command = vec!["echo".into(), "hello".into(), "world".into()];
let exec_request = ApprovalRequest::Exec {
thread_id: ThreadId::new(),
thread_label: None,
id: "test".into(),
command,
reason: None,
available_decisions: vec![
CommandExecutionApprovalDecision::Accept,
CommandExecutionApprovalDecision::Cancel,
],
network_approval_context: None,
additional_permissions: None,
};
let view = make_overlay(exec_request, tx, Features::with_defaults());
let mut buf = Buffer::empty(Rect::new(0, 0, 80, view.desired_height(/*width*/ 80)));
view.render(
Rect::new(0, 0, 80, view.desired_height(/*width*/ 80)),
&mut buf,
);
let rendered: Vec<String> = (0..buf.area.height)
.map(|row| {
(0..buf.area.width)
.map(|col| buf[(col, row)].symbol().to_string())
.collect()
})
.collect();
assert!(
rendered
.iter()
.any(|line| line.contains("echo hello world")),
"expected header to include command snippet, got {rendered:?}"
);
}
#[test]
fn network_exec_options_use_expected_labels_and_hide_execpolicy_amendment() {
let network_context = NetworkApprovalContext {
host: "example.com".to_string(),
protocol: NetworkApprovalProtocol::Https,
};
let keymap = crate::keymap::RuntimeKeymap::defaults();
let options = exec_options(
&[
CommandExecutionApprovalDecision::Accept,
CommandExecutionApprovalDecision::AcceptForSession,
CommandExecutionApprovalDecision::ApplyNetworkPolicyAmendment {
network_policy_amendment: NetworkPolicyAmendment {
host: "example.com".to_string(),
action: NetworkPolicyRuleAction::Allow,
},
},
CommandExecutionApprovalDecision::Cancel,
],
Some(&network_context),
/*additional_permissions*/ None,
&keymap.approval,
);
let labels: Vec<String> = options.into_iter().map(|option| option.label).collect();
assert_eq!(
labels,
vec![
"Yes, just this once".to_string(),
"Yes, and allow this host for this conversation".to_string(),
"Yes, and allow this host in the future".to_string(),
"No, and tell Codex what to do differently".to_string(),
]
);
}
#[test]
fn generic_exec_options_can_offer_allow_for_session() {
let keymap = crate::keymap::RuntimeKeymap::defaults();
let options = exec_options(
&[
CommandExecutionApprovalDecision::Accept,
CommandExecutionApprovalDecision::AcceptForSession,
CommandExecutionApprovalDecision::Cancel,
],
/*network_approval_context*/ None,
/*additional_permissions*/ None,
&keymap.approval,
);
let labels: Vec<String> = options.into_iter().map(|option| option.label).collect();
assert_eq!(
labels,
vec![
"Yes, proceed".to_string(),
"Yes, and don't ask again for this command in this session".to_string(),
"No, and tell Codex what to do differently".to_string(),
]
);
}
#[test]
fn additional_permissions_exec_options_hide_execpolicy_amendment() {
let keymap = crate::keymap::RuntimeKeymap::defaults();
let additional_permissions = AdditionalPermissionProfile {
network: None,
file_system: Some(
FileSystemPermissions::from_read_write_roots(
Some(vec![absolute_path("/tmp/readme.txt")]),
Some(vec![absolute_path("/tmp/out.txt")]),
)
.into(),
),
};
let options = exec_options(
&[
CommandExecutionApprovalDecision::Accept,
CommandExecutionApprovalDecision::Cancel,
],
/*network_approval_context*/ None,
Some(&additional_permissions),
&keymap.approval,
);
let labels: Vec<String> = options.into_iter().map(|option| option.label).collect();
assert_eq!(
labels,
vec![
"Yes, proceed".to_string(),
"No, and tell Codex what to do differently".to_string(),
]
);
}
#[test]
fn permissions_options_use_expected_labels() {
let keymap = crate::keymap::RuntimeKeymap::defaults();
let labels: Vec<String> = permissions_options(&keymap.approval)
.into_iter()
.map(|option| option.label)
.collect();
assert_eq!(
labels,
vec![
"Yes, grant these permissions for this turn".to_string(),
"Yes, grant for this turn with strict auto review".to_string(),
"Yes, grant these permissions for this session".to_string(),
"No, continue without permissions".to_string(),
]
);
}
#[test]
fn additional_permissions_rule_shows_non_path_file_system_entries() {
let additional_permissions = AdditionalPermissionProfile {
network: None,
file_system: Some(AdditionalFileSystemPermissions {
read: None,
write: None,
entries: Some(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Write,
},
FileSystemSandboxEntry {
path: FileSystemPath::GlobPattern {
pattern: "**/*.env".to_string(),
},
access: FileSystemAccessMode::Deny,
},
]),
glob_scan_max_depth: None,
}),
};
assert_eq!(
format_additional_permissions_rule(&additional_permissions),
Some("write `:root`; deny read glob `**/*.env`".to_string())
);
}
#[test]
fn additional_permissions_rule_uses_workspace_roots_label() {
let additional_permissions = AdditionalPermissionProfile {
network: None,
file_system: Some(AdditionalFileSystemPermissions {
read: None,
write: None,
entries: Some(vec![FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::ProjectRoots {
subpath: Some(".git".into()),
},
},
access: FileSystemAccessMode::Read,
}]),
glob_scan_max_depth: None,
}),
};
assert_eq!(
format_additional_permissions_rule(&additional_permissions),
Some("read `:workspace_roots/.git`".to_string())
);
}
#[test]
fn permissions_session_shortcut_submits_session_scope() {
let (tx, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx);
let mut view = make_overlay(make_permissions_request(), tx, Features::with_defaults());
view.handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE));
let mut saw_op = false;
while let Ok(ev) = rx.try_recv() {
if let AppEvent::SubmitThreadOp {
op: Op::RequestPermissionsResponse { response, .. },
..
} = ev
{
assert_eq!(response.scope, PermissionGrantScope::Session);
saw_op = true;
break;
}
}
assert!(
saw_op,
"expected permission approval decision to emit a session-scoped response"
);
}
#[test]
fn permissions_deny_shortcut_uses_deny_keymap() {
let (tx, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx);
let mut keymap = crate::keymap::RuntimeKeymap::defaults();
keymap.approval.deny = vec![key_hint::plain(KeyCode::Char('x'))];
keymap.approval.decline = Vec::new();
let mut view = make_overlay_with_keymap(
make_permissions_request(),
tx,
Features::with_defaults(),
keymap.approval,
keymap.list,
);
view.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
let mut saw_op = false;
while let Ok(ev) = rx.try_recv() {
if let AppEvent::SubmitThreadOp {
op: Op::RequestPermissionsResponse { response, .. },
..
} = ev
{
assert!(response.permissions.is_empty());
assert_eq!(response.scope, PermissionGrantScope::Turn);
assert!(!response.strict_auto_review);
saw_op = true;
break;
}
}
assert!(
saw_op,
"expected permission deny shortcut to emit an empty permission response"
);
}
#[test]
fn permissions_strict_auto_review_shortcut_submits_turn_scope_with_strict_review() {
let (tx, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx);
let mut view = make_overlay(make_permissions_request(), tx, Features::with_defaults());
view.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE));
let mut saw_op = false;
while let Ok(ev) = rx.try_recv() {
if let AppEvent::SubmitThreadOp {
op: Op::RequestPermissionsResponse { response, .. },
..
} = ev
{
assert_eq!(response.scope, PermissionGrantScope::Turn);
assert!(response.strict_auto_review);
saw_op = true;
break;
}
}
assert!(
saw_op,
"expected permission approval decision to emit a strict auto review response"
);
}
#[test]
fn additional_permissions_prompt_shows_permission_rule_line() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx);
let exec_request = ApprovalRequest::Exec {
thread_id: ThreadId::new(),
thread_label: None,
id: "test".into(),
command: vec!["cat".into(), "/tmp/readme.txt".into()],
reason: None,
available_decisions: vec![
CommandExecutionApprovalDecision::Accept,
CommandExecutionApprovalDecision::Cancel,
],
network_approval_context: None,
additional_permissions: Some(AdditionalPermissionProfile {
network: Some(AdditionalNetworkPermissions {
enabled: Some(true),
}),
file_system: Some(
FileSystemPermissions::from_read_write_roots(
Some(vec![absolute_path("/tmp/readme.txt")]),
Some(vec![absolute_path("/tmp/out.txt")]),
)
.into(),
),
}),
};
let view = make_overlay(exec_request, tx, Features::with_defaults());
let mut buf = Buffer::empty(Rect::new(0, 0, 100, view.desired_height(/*width*/ 100)));
view.render(
Rect::new(0, 0, 100, view.desired_height(/*width*/ 100)),
&mut buf,
);
let rendered: Vec<String> = (0..buf.area.height)
.map(|row| {
(0..buf.area.width)
.map(|col| buf[(col, row)].symbol().to_string())
.collect()
})
.collect();
assert!(
rendered
.iter()
.any(|line| line.contains("Permission rule:")),
"expected permission-rule line, got {rendered:?}"
);
assert!(
rendered.iter().any(|line| line.contains("network;")),
"expected network permission text, got {rendered:?}"
);
}
#[test]
fn additional_permissions_prompt_snapshot() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx);
let exec_request = ApprovalRequest::Exec {
thread_id: ThreadId::new(),
thread_label: None,
id: "test".into(),
command: vec!["cat".into(), "/tmp/readme.txt".into()],
reason: Some("need filesystem access".into()),
available_decisions: vec![
CommandExecutionApprovalDecision::Accept,
CommandExecutionApprovalDecision::Cancel,
],
network_approval_context: None,
additional_permissions: Some(AdditionalPermissionProfile {
network: Some(AdditionalNetworkPermissions {
enabled: Some(true),
}),
file_system: Some(
FileSystemPermissions::from_read_write_roots(
Some(vec![absolute_path("/tmp/readme.txt")]),
Some(vec![absolute_path("/tmp/out.txt")]),
)
.into(),
),
}),
};
let view = make_overlay(exec_request, tx, Features::with_defaults());
assert_snapshot!(
"approval_overlay_additional_permissions_prompt",
normalize_snapshot_paths(render_overlay_lines(&view, /*width*/ 120))
);
}
#[test]
fn permissions_prompt_snapshot() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx);
let view = make_overlay(make_permissions_request(), tx, Features::with_defaults());
assert_snapshot!(
"approval_overlay_permissions_prompt",
normalize_snapshot_paths(render_overlay_lines(&view, /*width*/ 120))
);
}
#[test]
fn apply_patch_prompt_with_thread_label_omits_command_line() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx);
let mut changes = HashMap::new();
changes.insert(
PathBuf::from("bug1.txt"),
FileChange::Add {
content: "one\ntwo\nthree\n".to_string(),
},
);
let request = ApprovalRequest::ApplyPatch {
thread_id: ThreadId::new(),
thread_label: Some("Banach [worker]".to_string()),
id: "test".to_string(),
reason: None,
cwd: absolute_path("/tmp"),
changes,
};
let keymap = crate::keymap::RuntimeKeymap::defaults();
let view = ApprovalOverlay::new(
request,
tx,
Features::with_defaults(),
keymap.approval,
keymap.list,
);
let rendered = render_overlay_lines(&view, /*width*/ 120);
assert!(rendered.contains("Thread: Banach [worker]"));
assert!(rendered.contains("o to open thread"));
assert!(!rendered.contains("$ apply_patch"));
}
#[test]
fn network_exec_prompt_title_includes_host() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx);
let exec_request = ApprovalRequest::Exec {
thread_id: ThreadId::new(),
thread_label: None,
id: "test".into(),
command: vec!["curl".into(), "https://example.com".into()],
reason: Some("network request blocked".into()),
available_decisions: vec![
CommandExecutionApprovalDecision::Accept,
CommandExecutionApprovalDecision::AcceptForSession,
CommandExecutionApprovalDecision::ApplyNetworkPolicyAmendment {
network_policy_amendment: NetworkPolicyAmendment {
host: "example.com".to_string(),
action: NetworkPolicyRuleAction::Allow,
},
},
CommandExecutionApprovalDecision::Cancel,
],
network_approval_context: Some(NetworkApprovalContext {
host: "example.com".to_string(),
protocol: NetworkApprovalProtocol::Https,
}),
additional_permissions: None,
};
let view = make_overlay(exec_request, tx, Features::with_defaults());
let mut buf = Buffer::empty(Rect::new(0, 0, 100, view.desired_height(/*width*/ 100)));
view.render(
Rect::new(0, 0, 100, view.desired_height(/*width*/ 100)),
&mut buf,
);
assert_snapshot!("network_exec_prompt", format!("{buf:?}"));
let rendered: Vec<String> = (0..buf.area.height)
.map(|row| {
(0..buf.area.width)
.map(|col| buf[(col, row)].symbol().to_string())
.collect()
})
.collect();
assert!(
rendered.iter().any(|line| {
line.contains("Do you want to approve network access to \"example.com\"?")
}),
"expected network title to include host, got {rendered:?}"
);
assert!(
!rendered.iter().any(|line| line.contains("$ curl")),
"network prompt should not show command line, got {rendered:?}"
);
assert!(
!rendered.iter().any(|line| line.contains("don't ask again")),
"network prompt should not show execpolicy option, got {rendered:?}"
);
}
#[test]
fn ctrl_shift_a_opens_fullscreen() {
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut view = make_overlay(make_exec_request(), tx, Features::with_defaults());
view.handle_key_event(KeyEvent::new(
KeyCode::Char('a'),
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
));
let mut saw_fullscreen = false;
while let Ok(ev) = rx.try_recv() {
if matches!(ev, AppEvent::FullScreenApprovalRequest(_)) {
saw_fullscreen = true;
break;
}
}
assert!(saw_fullscreen, "expected ctrl+shift+a to open fullscreen");
}
#[test]
fn exec_history_cell_wraps_with_two_space_indent() {
let command = vec![
"/bin/zsh".into(),
"-lc".into(),
"git add tui/src/render/mod.rs tui/src/render/renderable.rs".into(),
];
let cell = history_cell::new_approval_decision_cell(
history_cell::ApprovalDecisionSubject::Command(command),
ReviewDecision::Approved,
history_cell::ApprovalDecisionActor::User,
);
let lines = cell.display_lines(/*width*/ 28);
let rendered: Vec<String> = lines
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
})
.collect();
let expected = vec![
"✔ You approved codex to run".to_string(),
" git add tui/src/render/".to_string(),
" mod.rs tui/src/render/".to_string(),
" renderable.rs this time".to_string(),
];
assert_eq!(rendered, expected);
}
#[test]
fn exec_history_cell_does_not_render_blank_action_for_empty_command() {
let approved = history_cell::new_approval_decision_cell(
history_cell::ApprovalDecisionSubject::Command(Vec::new()),
ReviewDecision::Approved,
history_cell::ApprovalDecisionActor::User,
);
assert_eq!(
render_history_cell_lines(approved.as_ref(), /*width*/ 80),
vec!["✔ You approved this request this time".to_string()]
);
let approved_for_session = history_cell::new_approval_decision_cell(
history_cell::ApprovalDecisionSubject::Command(Vec::new()),
ReviewDecision::ApprovedForSession,
history_cell::ApprovalDecisionActor::User,
);
assert_eq!(
render_history_cell_lines(approved_for_session.as_ref(), /*width*/ 80),
vec!["✔ You approved this request every time this session".to_string()]
);
}
#[test]
fn network_access_command_history_uses_target_without_structured_context() {
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut view = make_overlay(
ApprovalRequest::Exec {
thread_id: ThreadId::new(),
thread_label: None,
id: "test".into(),
command: vec![
"network-access".to_string(),
"https://example.com:8443".to_string(),
],
reason: None,
available_decisions: vec![
CommandExecutionApprovalDecision::Accept,
CommandExecutionApprovalDecision::Cancel,
],
network_approval_context: None,
additional_permissions: None,
},
tx,
Features::with_defaults(),
);
view.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
let mut decision = None;
while let Ok(event) = rx.try_recv() {
if let AppEvent::InsertHistoryCell(cell) = event {
decision = Some(cell);
break;
}
}
let decision = decision.expect("expected decision cell in history");
assert_eq!(
render_history_cell_lines(decision.as_ref(), /*width*/ 80),
vec![
"✔ You approved codex network access to https://example.com:8443 this time"
.to_string(),
]
);
}
#[test]
fn esc_cancels_mcp_elicitation() {
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut view = make_overlay(make_elicitation_request(), tx, Features::with_defaults());
view.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
let mut decision = None;
while let Ok(ev) = rx.try_recv() {
if let AppEvent::SubmitThreadOp {
op: Op::ResolveElicitation { decision: d, .. },
..
} = ev
{
decision = Some(d);
break;
}
}
assert_eq!(decision, Some(McpServerElicitationAction::Cancel));
}
#[test]
fn esc_still_cancels_elicitation_with_custom_overlap() {
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut keymap = crate::keymap::RuntimeKeymap::defaults();
keymap.approval.decline = vec![
key_hint::plain(KeyCode::Esc),
key_hint::plain(KeyCode::Char('n')),
];
keymap.approval.cancel = vec![key_hint::plain(KeyCode::Char('x'))];
let mut view = make_overlay_with_keymap(
make_elicitation_request(),
tx,
Features::with_defaults(),
keymap.approval,
keymap.list,
);
view.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
let mut esc_decision = None;
while let Ok(ev) = rx.try_recv() {
if let AppEvent::SubmitThreadOp {
op: Op::ResolveElicitation { decision, .. },
..
} = ev
{
esc_decision = Some(decision);
break;
}
}
assert_eq!(esc_decision, Some(McpServerElicitationAction::Cancel));
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut keymap = crate::keymap::RuntimeKeymap::defaults();
keymap.approval.decline = vec![
key_hint::plain(KeyCode::Esc),
key_hint::plain(KeyCode::Char('n')),
];
keymap.approval.cancel = vec![key_hint::plain(KeyCode::Char('x'))];
let mut view = make_overlay_with_keymap(
make_elicitation_request(),
tx,
Features::with_defaults(),
keymap.approval,
keymap.list,
);
view.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE));
let mut n_decision = None;
while let Ok(ev) = rx.try_recv() {
if let AppEvent::SubmitThreadOp {
op: Op::ResolveElicitation { decision, .. },
..
} = ev
{
n_decision = Some(decision);
break;
}
}
assert_eq!(n_decision, Some(McpServerElicitationAction::Decline));
}
#[test]
fn enter_sets_last_selected_index_without_dismissing() {
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut view = make_overlay(make_exec_request(), tx, Features::with_defaults());
view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(
view.is_complete(),
"exec approval should complete without queued requests"
);
let mut decision = None;
while let Ok(ev) = rx.try_recv() {
if let AppEvent::SubmitThreadOp {
op: Op::ExecApproval { decision: d, .. },
..
} = ev
{
decision = Some(d);
break;
}
}
assert_eq!(decision, Some(CommandExecutionApprovalDecision::Accept));
}
}