//! 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::ListAction; use crate::keymap::ListKeymap; 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::CommandExecutionApprovalKind; 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 codex_utils_path_uri::LegacyAppPathString; 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(ExecApprovalRequest), Permissions(PermissionsApprovalRequest), ApplyPatch(ApplyPatchApprovalRequest), McpElicitation(McpElicitationApprovalRequest), } #[derive(Clone, Debug)] pub(crate) struct ExecApprovalRequest { pub kind: CommandExecutionApprovalKind, pub thread_id: ThreadId, pub thread_label: Option, pub id: String, pub environment_id: Option, pub command: Vec, pub reason: Option, pub available_decisions: Vec, pub network_approval_context: Option, pub additional_permissions: Option, } #[derive(Clone, Debug)] pub(crate) struct PermissionsApprovalRequest { pub thread_id: ThreadId, pub thread_label: Option, pub call_id: String, pub environment_id: Option, pub reason: Option, pub permissions: RequestPermissionProfile, } #[derive(Clone, Debug)] pub(crate) struct ApplyPatchApprovalRequest { pub thread_id: ThreadId, pub thread_label: Option, pub id: String, pub reason: Option, pub cwd: AbsolutePathBuf, pub changes: HashMap, } #[derive(Clone, Debug)] pub(crate) struct McpElicitationApprovalRequest { pub thread_id: ThreadId, pub thread_label: Option, pub server_name: String, pub request_id: RequestId, pub message: String, } impl ApprovalRequest { pub(super) fn thread_id(&self) -> ThreadId { match self { ApprovalRequest::Exec(request) => request.thread_id, ApprovalRequest::Permissions(request) => request.thread_id, ApprovalRequest::ApplyPatch(request) => request.thread_id, ApprovalRequest::McpElicitation(request) => request.thread_id, } } fn thread_label(&self) -> Option<&str> { match self { ApprovalRequest::Exec(request) => request.thread_label.as_deref(), ApprovalRequest::Permissions(request) => request.thread_label.as_deref(), ApprovalRequest::ApplyPatch(request) => request.thread_label.as_deref(), ApprovalRequest::McpElicitation(request) => request.thread_label.as_deref(), } } pub(super) fn matches_resolved_request(&self, request: &ResolvedAppServerRequest) -> bool { match (self, request) { ( ApprovalRequest::Exec(request), ResolvedAppServerRequest::ExecApproval { thread_id, id }, ) => request.thread_id.to_string() == *thread_id && request.id == *id, ( ApprovalRequest::Permissions(request), ResolvedAppServerRequest::PermissionsApproval { thread_id, id }, ) => request.thread_id.to_string() == *thread_id && request.call_id == *id, ( ApprovalRequest::ApplyPatch(request), ResolvedAppServerRequest::FileChangeApproval { thread_id, id }, ) => request.thread_id.to_string() == *thread_id && request.id == *id, ( ApprovalRequest::McpElicitation(request), ResolvedAppServerRequest::McpElicitation { server_name: resolved_server_name, request_id: resolved_request_id, }, ) => { request.server_name == *resolved_server_name && request.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, queue: Vec, app_event_tx: AppEventSender, list: ListSelectionView, options: Vec, 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, _features: &Features, approval_keymap: &ApprovalKeymap, list_keymap: &ListKeymap, ) -> (Vec, SelectionViewParams) { let (options, title) = match request { ApprovalRequest::Exec(request) => { let title = if request.kind == CommandExecutionApprovalKind::WriteStdin { request.command.get(2).map_or_else( || "Would you like to send input to the existing terminal?".to_string(), |process_id| { format!("Would you like to send input to terminal {process_id}?") }, ) } else { request.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 ) }, ) }; ( exec_options( &request.available_decisions, request.network_approval_context.as_ref(), request.additional_permissions.as_ref(), approval_keymap, ), title, ) } 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(request) => ( elicitation_options(approval_keymap), format!("{} needs your approval.", request.server_name), ), }; 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: approval_keymap.hint_for_bindings(&opt.shortcuts), 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(request), ApprovalDecision::Command(decision)) => { self.handle_exec_decision(&request.id, &request.command, decision.clone()); } ( ApprovalRequest::Permissions(request), ApprovalDecision::Permissions(decision), ) => { self.handle_permissions_decision( &request.call_id, &request.permissions, *decision, ); } (ApprovalRequest::ApplyPatch(request), ApprovalDecision::FileChange(decision)) => { self.handle_patch_decision(&request.id, decision.clone()); } ( ApprovalRequest::McpElicitation(request), ApprovalDecision::McpElicitation(decision), ) => { self.handle_elicitation_decision( &request.server_name, &request.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 network_approval_context = match request { ApprovalRequest::Exec(request) => request.network_approval_context.as_ref(), ApprovalRequest::Permissions(_) | ApprovalRequest::ApplyPatch(_) | ApprovalRequest::McpElicitation(_) => None, }; let subject = if let Some(network_approval_context) = network_approval_context { history_cell::ApprovalDecisionSubject::NetworkAccess { target: network_approval_target(network_approval_context, command), } } else 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(request) => { self.handle_exec_decision( &request.id, &request.command, CommandExecutionApprovalDecision::Cancel, ); } ApprovalRequest::Permissions(request) => { self.handle_permissions_decision( &request.call_id, &request.permissions, PermissionsDecision::Deny, ); } ApprovalRequest::ApplyPatch(request) => { self.handle_patch_decision(&request.id, FileChangeApprovalDecision::Cancel); } ApprovalRequest::McpElicitation(request) => { self.handle_elicitation_decision( &request.server_name, &request.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 keymap_contexts(&self) -> crate::keymap::KeymapContextSet { crate::keymap::KeymapContextSet::new(crate::keymap::KeymapContext::Approval) .with(crate::keymap::KeymapContext::List) } 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 { 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( list_keymap.primary_hint(ListAction::Accept), "to confirm", list_keymap.primary_hint(ListAction::Cancel), "to cancel", ) .spans; if request.thread_label().is_some() && let Some(open_thread) = approval_keymap.primary_hint("open_thread", &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 { match request { ApprovalRequest::Exec(request) => { let mut header: Vec> = Vec::new(); if let Some(thread_label) = &request.thread_label { header.push(Line::from(vec![ "Thread: ".into(), thread_label.clone().bold(), ])); header.push(Line::from("")); } if let Some(environment_id) = &request.environment_id { header.push(Line::from(vec![ "Environment: ".into(), environment_id.clone().bold(), ])); header.push(Line::from("")); } if let Some(reason) = &request.reason { header.push(Line::from(vec!["Reason: ".into(), reason.clone().italic()])); header.push(Line::from("")); } if let Some(additional_permissions) = &request.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("")); } if request.kind == CommandExecutionApprovalKind::WriteStdin { let input = request.command.last().map_or("", String::as_str); header.push(vec!["Input: ".into(), format!("{input:?}").into()].into()); } else { let full_cmd = strip_bash_lc_and_escape(&request.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 request.network_approval_context.is_none() { header.extend(full_cmd_lines); } } Box::new(Paragraph::new(header).wrap(Wrap { trim: false })) } ApprovalRequest::Permissions(request) => { let mut header: Vec> = Vec::new(); if let Some(thread_label) = &request.thread_label { header.push(Line::from(vec![ "Thread: ".into(), thread_label.clone().bold(), ])); header.push(Line::from("")); } if let Some(environment_id) = &request.environment_id { header.push(Line::from(vec![ "Environment: ".into(), environment_id.clone().bold(), ])); header.push(Line::from("")); } if let Some(reason) = &request.reason { header.push(Line::from(vec!["Reason: ".into(), reason.clone().italic()])); header.push(Line::from("")); } if let Some(rule_line) = format_requested_permissions_rule(&request.permissions) { header.push(Line::from(vec![ "Permission rule: ".into(), rule_line.cyan(), ])); } Box::new(Paragraph::new(header).wrap(Wrap { trim: false })) } ApprovalRequest::ApplyPatch(request) => super::apply_patch_header::build_header(request), ApprovalRequest::McpElicitation(request) => { let mut lines = Vec::new(); if let Some(thread_label) = &request.thread_label { lines.push(Line::from(vec![ "Thread: ".into(), thread_label.clone().bold(), ])); lines.push(Line::from("")); } lines.extend([ Line::from(vec!["Server: ".into(), request.server_name.clone().bold()]), Line::from(""), Line::from(request.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, } 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 { 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 { 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 { 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, ) -> String { entries .map(|entry| match &entry.path { FileSystemPath::Path { path } => format!("`{path}`"), FileSystemPath::GlobPattern { pattern } => format!("glob `{pattern}`"), FileSystemPath::Special { value } => format!("`{}`", special_path_label(value)), }) .collect::>() .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) -> String { match subpath { Some(subpath) => format!("{base}/{subpath}"), None => base.to_string(), } } fn patch_options(keymap: &ApprovalKeymap) -> Vec { 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 { 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 { 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 = 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 crate::keymap::RuntimeKeymap; 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_config::types::KeybindingSpec; use codex_config::types::KeybindingsSpec; use codex_config::types::TuiKeymap; use codex_protocol::models::FileSystemPermissions; use codex_protocol::models::NetworkPermissions; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::test_support::PathExt; use codex_utils_absolute_path::test_support::test_path_buf; 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::() .trim_end() .to_string() }) .collect::>() .join("\n") } fn render_history_cell_lines( cell: &dyn crate::history_cell::HistoryCell, width: u16, ) -> Vec { cell.display_lines(width) .iter() .map(|line| { line.spans .iter() .map(|span| span.content.as_ref()) .collect::() }) .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(ExecApprovalRequest { kind: Default::default(), thread_id: ThreadId::new(), thread_label: None, id: "test".to_string(), environment_id: None, 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(PermissionsApprovalRequest { 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(McpElicitationApprovalRequest { 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 approval_shortcuts_display_chords_in_configured_order() { let chord = crate::key_hint::ShortcutHint::Chord { prefix: key_hint::ctrl(KeyCode::Char('x')), completion: key_hint::plain(KeyCode::Char('y')), }; let single = crate::key_hint::ShortcutHint::Single(key_hint::plain(KeyCode::Char('y'))); let binding = |spec: &str| KeybindingSpec(spec.to_string()); for (specs, expected_shortcut) in [ (KeybindingsSpec::One(binding("ctrl-x y")), chord), ( KeybindingsSpec::Many(vec![binding("y"), binding("ctrl-x y")]), single, ), ( KeybindingsSpec::Many(vec![binding("ctrl-x y"), binding("y")]), chord, ), ] { let mut config = TuiKeymap::default(); config.approval.approve = Some(specs); let keymap = RuntimeKeymap::from_config(&config).expect("valid approval keymap"); let request = make_exec_request(); let (_, params) = ApprovalOverlay::build_options( &request, build_header(&request), &Features::with_defaults(), &keymap.approval, &keymap.list, ); let approval = params .items .iter() .find(|item| item.name == "Yes, proceed") .expect("approval selection"); assert_eq!(approval.display_shortcut, Some(expected_shortcut)); if matches!( expected_shortcut, crate::key_hint::ShortcutHint::Chord { .. } ) { let dispatch_token = keymap.approval.approve.last().expect("chord token"); assert_eq!( keymap .approval .hint_for_bindings(std::slice::from_ref(dispatch_token)), Some(expected_shortcut) ); } } } #[test] fn ctrl_c_aborts_and_clears_queue() { let (tx, _rx) = unbounded_channel::(); 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::(); 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::(); 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::(); 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::(); let tx = AppEventSender::new(tx); let mut view = make_overlay( ApprovalRequest::Exec(ExecApprovalRequest { kind: Default::default(), thread_id: ThreadId::new(), thread_label: None, id: "test".to_string(), environment_id: None, 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::(); let tx = AppEventSender::new(tx); let amendment = NetworkPolicyAmendment { host: "example.com".to_string(), action: NetworkPolicyRuleAction::Deny, }; let mut view = make_overlay( ApprovalRequest::Exec(ExecApprovalRequest { kind: Default::default(), thread_id: ThreadId::new(), thread_label: None, id: "test".to_string(), environment_id: None, 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::(); let tx = AppEventSender::new(tx); let request = make_exec_request(); let thread_id = request.thread_id(); let mut view = make_overlay(request, tx, Features::with_defaults()); assert!( !view.dismiss_app_server_request(&ResolvedAppServerRequest::ExecApproval { thread_id: ThreadId::new().to_string(), id: "test".to_string(), }) ); assert!(!view.is_complete()); assert!( view.dismiss_app_server_request(&ResolvedAppServerRequest::ExecApproval { thread_id: thread_id.to_string(), 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::(); let tx = AppEventSender::new(tx); let thread_id = ThreadId::new(); let mut view = make_overlay( ApprovalRequest::Exec(ExecApprovalRequest { kind: Default::default(), thread_id, thread_label: Some("Robie [explorer]".to_string()), id: "test".to_string(), environment_id: None, 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::(); 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(ExecApprovalRequest { kind: Default::default(), thread_id, thread_label: Some("Robie [explorer]".to_string()), id: "test".to_string(), environment_id: None, 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::(); let tx = AppEventSender::new(tx); let view = make_overlay( ApprovalRequest::Exec(ExecApprovalRequest { kind: Default::default(), thread_id: ThreadId::new(), thread_label: Some("Robie [explorer]".to_string()), id: "test".to_string(), environment_id: None, 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::(); let tx = AppEventSender::new(tx); let mut view = make_overlay( ApprovalRequest::Exec(ExecApprovalRequest { kind: Default::default(), thread_id: ThreadId::new(), thread_label: None, id: "test".to_string(), environment_id: None, 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::(); let tx = AppEventSender::new(tx); let mut view = make_overlay( ApprovalRequest::Exec(ExecApprovalRequest { kind: Default::default(), thread_id: ThreadId::new(), thread_label: None, id: "test".to_string(), environment_id: None, 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::(); let tx = AppEventSender::new(tx); let command = vec!["echo".into(), "hello".into(), "world".into()]; let exec_request = ApprovalRequest::Exec(ExecApprovalRequest { kind: Default::default(), thread_id: ThreadId::new(), thread_label: None, id: "test".into(), environment_id: None, 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 = (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 = 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 = 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 = 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 = 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(LegacyAppPathString::from_path(std::path::Path::new( ".git", ))), }, }, 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::(); 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::(); 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::(); 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::(); let tx = AppEventSender::new(tx); let exec_request = ApprovalRequest::Exec(ExecApprovalRequest { kind: Default::default(), thread_id: ThreadId::new(), thread_label: None, id: "test".into(), environment_id: None, 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 = (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::(); let tx = AppEventSender::new(tx); let exec_request = ApprovalRequest::Exec(ExecApprovalRequest { kind: Default::default(), thread_id: ThreadId::new(), thread_label: None, id: "test".into(), environment_id: None, 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::(); 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::(); 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(), }, ); changes.insert( PathBuf::from("old.txt"), FileChange::Update { unified_diff: String::new(), move_path: Some(PathBuf::from("new.txt")), }, ); #[cfg(windows)] let (foreign_path, foreign_move_path) = ("/remote/old.txt", "/remote/new.txt"); #[cfg(not(windows))] let (foreign_path, foreign_move_path) = (r"C:\workspace\old.txt", r"C:\workspace\new.txt"); changes.insert( PathBuf::from(foreign_path), FileChange::Update { unified_diff: String::new(), move_path: Some(PathBuf::from(foreign_move_path)), }, ); let request = ApprovalRequest::ApplyPatch(ApplyPatchApprovalRequest { thread_id: ThreadId::new(), thread_label: Some("Banach [worker]".to_string()), id: "test".to_string(), reason: None, cwd: test_path_buf("/tmp").abs(), 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("Description: Apply proposed file edits")); for path in ["/tmp/bug1.txt", "/tmp/new.txt", "/tmp/old.txt"] { assert!(rendered.contains(&format!("Destination: {}", test_path_buf(path).display()))); } for path in [foreign_path, foreign_move_path] { assert!(rendered.contains(&format!("Destination: {path}"))); } assert!(rendered.contains("o to open thread")); assert!(!rendered.contains("$ apply_patch")); } #[test] fn apply_patch_prompt_without_changes_shows_unavailable_destination() { let (tx, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx); let request = ApprovalRequest::ApplyPatch(ApplyPatchApprovalRequest { thread_id: ThreadId::new(), thread_label: None, id: "test".to_string(), reason: None, cwd: absolute_path("/tmp"), changes: HashMap::new(), }); let view = make_overlay(request, tx, Features::with_defaults()); assert_snapshot!( "approval_overlay_patch_destination_unavailable", normalize_snapshot_paths(render_overlay_lines(&view, /*width*/ 120)) ); } #[test] fn network_exec_prompt_title_includes_host() { let (tx, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx); let exec_request = ApprovalRequest::Exec(ExecApprovalRequest { kind: Default::default(), thread_id: ThreadId::new(), thread_label: None, id: "test".into(), environment_id: None, 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 = (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::(); 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 = lines .iter() .map(|line| { line.spans .iter() .map(|span| span.content.as_ref()) .collect::() }) .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::(); let tx = AppEventSender::new(tx_raw); let mut view = make_overlay( ApprovalRequest::Exec(ExecApprovalRequest { kind: Default::default(), thread_id: ThreadId::new(), thread_label: None, id: "test".into(), environment_id: None, 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::(); 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::(); 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::(); 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::(); 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)); } }