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)); } }