From 88ccfb46b7e33a5f52f36bdbbb2cbd0b40cda7fd Mon Sep 17 00:00:00 2001 From: "kh.ai" Date: Fri, 17 Oct 2025 09:07:52 -0700 Subject: [PATCH] Restore /secreview command and security review UI --- codex-rs/Cargo.lock | 4 + codex-rs/Cargo.toml | 1 + codex-rs/tui/Cargo.toml | 5 + codex-rs/tui/src/app.rs | 41 ++ codex-rs/tui/src/app_event.rs | 68 ++- codex-rs/tui/src/bottom_pane/mod.rs | 3 + .../security_review_scope_confirm_view.rs | 143 +++++ codex-rs/tui/src/chatwidget.rs | 511 ++++++++++++++++++ codex-rs/tui/src/chatwidget/tests.rs | 3 + codex-rs/tui/src/lib.rs | 3 + codex-rs/tui/src/mermaid.rs | 3 + codex-rs/tui/src/security_report_viewer.rs | 23 + codex-rs/tui/src/security_review.rs | 2 + codex-rs/tui/src/slash_command.rs | 4 + 14 files changed, 811 insertions(+), 3 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/security_review_scope_confirm_view.rs create mode 100644 codex-rs/tui/src/mermaid.rs create mode 100644 codex-rs/tui/src/security_report_viewer.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 5576b7cba9..d204a618cc 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1398,6 +1398,7 @@ dependencies = [ "diffy", "dirs", "dunce", + "futures", "image", "insta", "itertools 0.14.0", @@ -1411,7 +1412,9 @@ dependencies = [ "pulldown-cmark", "rand 0.9.2", "ratatui", + "regex", "regex-lite", + "reqwest", "serde", "serde_json", "shlex", @@ -1420,6 +1423,7 @@ dependencies = [ "supports-color", "tempfile", "textwrap 0.16.2", + "time", "tokio", "tokio-stream", "tracing", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 1c1640be3f..aad21da7cc 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -143,6 +143,7 @@ pulldown-cmark = "0.10" rand = "0.9" ratatui = "0.29.0" regex-lite = "0.1.7" +regex = "1.11.1" reqwest = "0.12" rmcp = { version = "0.8.0", default-features = false } schemars = "0.8.22" diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 2c6f32a20a..86b2522e5b 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -60,6 +60,7 @@ ratatui = { workspace = true, features = [ "unstable-widget-ref", ] } regex-lite = { workspace = true } +regex = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true, features = ["preserve_order"] } shlex = { workspace = true } @@ -71,6 +72,7 @@ textwrap = { workspace = true } tree-sitter-highlight = { workspace = true } tree-sitter-bash = { workspace = true } tokio = { workspace = true, features = [ + "fs", "io-std", "macros", "process", @@ -85,6 +87,9 @@ opentelemetry-appender-tracing = { workspace = true } unicode-segmentation = { workspace = true } unicode-width = { workspace = true } url = { workspace = true } +futures = { workspace = true } +reqwest = { workspace = true } +time = { workspace = true, features = ["serde"] } [target.'cfg(unix)'.dependencies] libc = { workspace = true } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index cb3dea5e60..f1bfb3f006 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -381,6 +381,47 @@ impl App { AppEvent::OpenReviewCustomPrompt => { self.chat_widget.show_review_custom_prompt(); } + AppEvent::OpenSecurityReviewPathPrompt(mode) => { + self.chat_widget.show_security_review_path_prompt(mode); + } + AppEvent::StartSecurityReview { + mode, + include_paths, + scope_prompt, + } => { + self.chat_widget + .start_security_review(mode, include_paths, scope_prompt); + } + AppEvent::SecurityReviewAutoScopeConfirm { + mode, + prompt, + selections, + responder, + } => { + self.chat_widget + .show_security_review_scope_confirmation(mode, prompt, selections, responder); + } + AppEvent::SecurityReviewScopeResolved { paths } => { + self.chat_widget.on_security_review_scope_resolved(paths); + } + AppEvent::SecurityReviewCommandStatus { + id, + summary, + state, + preview, + } => { + self.chat_widget + .on_security_review_command_status(id, summary, state, preview); + } + AppEvent::SecurityReviewLog(message) => { + self.chat_widget.on_security_review_log(message); + } + AppEvent::SecurityReviewComplete { result } => { + self.chat_widget.on_security_review_complete(result); + } + AppEvent::SecurityReviewFailed { error } => { + self.chat_widget.on_security_review_failed(error); + } AppEvent::FullScreenApprovalRequest(request) => match request { ApprovalRequest::ApplyPatch { cwd, changes, .. } => { let _ = tui.enter_alt_screen(); diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 9d79c8ae13..24be16a800 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -5,12 +5,30 @@ use codex_core::protocol::ConversationPathResponseEvent; use codex_core::protocol::Event; use codex_file_search::FileMatch; -use crate::bottom_pane::ApprovalRequest; -use crate::history_cell::HistoryCell; - use codex_core::protocol::AskForApproval; use codex_core::protocol::SandboxPolicy; use codex_core::protocol_config_types::ReasoningEffort; +use tokio::sync::oneshot; + +use crate::bottom_pane::ApprovalRequest; +use crate::history_cell::HistoryCell; +use crate::security_review::SecurityReviewFailure; +use crate::security_review::SecurityReviewMode; +use crate::security_review::SecurityReviewResult; + +#[derive(Clone, Debug)] +pub(crate) struct SecurityReviewAutoScopeSelection { + pub display_path: String, + pub reason: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum SecurityReviewCommandState { + Running, + Matches, + NoMatches, + Error, +} #[allow(clippy::large_enum_variant)] #[derive(Debug)] @@ -87,4 +105,48 @@ pub(crate) enum AppEvent { /// Open the approval popup. FullScreenApprovalRequest(ApprovalRequest), + + /// Open the scoped path input for security reviews. + OpenSecurityReviewPathPrompt(SecurityReviewMode), + + /// Begin running a security review with the given mode and optional scoped paths. + StartSecurityReview { + mode: SecurityReviewMode, + include_paths: Vec, + scope_prompt: Option, + }, + + /// Prompt the user to confirm auto-detected scope selections. + SecurityReviewAutoScopeConfirm { + mode: SecurityReviewMode, + prompt: String, + selections: Vec, + responder: oneshot::Sender, + }, + + /// Notify that the security review scope has been resolved to specific paths. + SecurityReviewScopeResolved { + paths: Vec, + }, + + /// Update the command status display for running security review shell commands. + SecurityReviewCommandStatus { + id: u64, + summary: String, + state: SecurityReviewCommandState, + preview: Vec, + }, + + /// Append a progress log emitted during the security review. + SecurityReviewLog(String), + + /// Security review completed successfully. + SecurityReviewComplete { + result: SecurityReviewResult, + }, + + /// Security review failed prior to completion. + SecurityReviewFailed { + error: SecurityReviewFailure, + }, } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index db13a041cd..2f146aefff 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -30,9 +30,12 @@ pub(crate) use list_selection_view::SelectionViewParams; mod paste_burst; pub mod popup_consts; mod scroll_state; +mod security_review_scope_confirm_view; mod selection_popup_common; mod textarea; +pub(crate) use security_review_scope_confirm_view::SecurityReviewScopeConfirmView; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum CancellationEvent { Handled, diff --git a/codex-rs/tui/src/bottom_pane/security_review_scope_confirm_view.rs b/codex-rs/tui/src/bottom_pane/security_review_scope_confirm_view.rs new file mode 100644 index 0000000000..13debe3eb1 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/security_review_scope_confirm_view.rs @@ -0,0 +1,143 @@ +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +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::Clear; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Widget; +use tokio::sync::oneshot; + +use crate::app_event::SecurityReviewAutoScopeSelection; +use crate::render::renderable::Renderable; +use crate::security_review::SecurityReviewMode; +use crate::text_formatting::truncate_text; + +use super::CancellationEvent; +use super::bottom_pane_view::BottomPaneView; + +pub(crate) struct SecurityReviewScopeConfirmView { + mode: SecurityReviewMode, + prompt: String, + selections: Vec, + responder: Option>, + complete: bool, +} + +impl SecurityReviewScopeConfirmView { + pub(crate) fn new( + mode: SecurityReviewMode, + prompt: String, + selections: Vec, + responder: oneshot::Sender, + ) -> Self { + Self { + mode, + prompt, + selections, + responder: Some(responder), + complete: false, + } + } + + fn send_response(&mut self, accept: bool) { + if let Some(responder) = self.responder.take() { + let _ = responder.send(accept); + } + self.complete = true; + } +} + +impl BottomPaneView for SecurityReviewScopeConfirmView { + fn handle_key_event(&mut self, key_event: KeyEvent) { + match key_event.code { + KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => { + self.send_response(true); + } + KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { + self.send_response(false); + } + _ if key_event.modifiers.contains(KeyModifiers::CONTROL) => {} + _ => {} + } + } + + fn on_ctrl_c(&mut self) -> CancellationEvent { + self.send_response(false); + CancellationEvent::Handled + } + + fn is_complete(&self) -> bool { + self.complete + } + + fn handle_paste(&mut self, _pasted: String) -> bool { + false + } + + fn cursor_pos(&self, _area: Rect) -> Option<(u16, u16)> { + None + } +} + +impl Renderable for SecurityReviewScopeConfirmView { + fn desired_height(&self, _width: u16) -> u16 { + let base_lines: u16 = 5; + let selection_lines = if self.selections.is_empty() { + 1 + } else { + self.selections.len() as u16 + }; + base_lines.saturating_add(selection_lines) + } + + fn render(&self, area: Rect, buf: &mut Buffer) { + if area.width == 0 || area.height == 0 { + return; + } + + Clear.render(area, buf); + + let mut lines: Vec = Vec::new(); + lines.push(vec!["Confirm auto-detected scope".bold()].into()); + lines.push(vec![format!("Mode: {}", self.mode.as_str()).dim()].into()); + + if !self.prompt.trim().is_empty() { + let summary = truncate_text(self.prompt.trim(), 96); + lines.push(vec!["Prompt: ".dim(), Span::from(summary)].into()); + } + + if self.selections.is_empty() { + lines.push( + vec!["No specific directories selected; review the entire repository.".dim()] + .into(), + ); + } else { + for (idx, selection) in self.selections.iter().enumerate() { + let label = format!("{:>2}. {}", idx + 1, selection.display_path); + let mut spans: Vec = vec![Span::from(label)]; + if let Some(reason) = selection.reason.as_ref() { + spans.push(" — ".dim()); + spans.push(Span::from(reason.clone()).dim()); + } + lines.push(spans.into()); + } + } + + lines.push(Line::from(Vec::::new())); + lines.push( + vec![ + "Continue with these paths? ".into(), + "(y)es".bold(), + " / ".into(), + "(n)o to refine scope".bold(), + ] + .into(), + ); + + Paragraph::new(lines).render(area, buf); + } +} diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 787e098093..b9c8e0dcdf 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -2,7 +2,9 @@ use std::collections::HashMap; use std::collections::VecDeque; use std::path::PathBuf; use std::sync::Arc; +use std::time::Instant; +use chrono::Utc; use codex_core::config::Config; use codex_core::config_types::Notifications; use codex_core::git_info::current_branch_name; @@ -53,18 +55,25 @@ use ratatui::buffer::Buffer; use ratatui::layout::Constraint; use ratatui::layout::Layout; use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; use ratatui::widgets::Widget; use ratatui::widgets::WidgetRef; use tokio::sync::mpsc::UnboundedSender; +use tokio::sync::oneshot; +use tokio::task::JoinHandle; use tracing::debug; use crate::app_event::AppEvent; +use crate::app_event::SecurityReviewAutoScopeSelection; +use crate::app_event::SecurityReviewCommandState; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::ApprovalRequest; use crate::bottom_pane::BottomPane; use crate::bottom_pane::BottomPaneParams; use crate::bottom_pane::CancellationEvent; use crate::bottom_pane::InputResult; +use crate::bottom_pane::SecurityReviewScopeConfirmView; use crate::bottom_pane::SelectionAction; use crate::bottom_pane::SelectionItem; use crate::bottom_pane::SelectionViewParams; @@ -80,9 +89,16 @@ use crate::history_cell; use crate::history_cell::AgentMessageCell; use crate::history_cell::HistoryCell; use crate::history_cell::McpToolCallCell; +use crate::history_cell::PlainHistoryCell; use crate::markdown::append_markdown; +use crate::security_review::SecurityReviewFailure; +use crate::security_review::SecurityReviewMode; +use crate::security_review::SecurityReviewRequest; +use crate::security_review::SecurityReviewResult; +use crate::security_review::run_security_review; use crate::slash_command::SlashCommand; use crate::status::RateLimitSnapshotDisplay; +use crate::status_indicator_widget::fmt_elapsed_compact; use crate::text_formatting::truncate_text; use crate::tui::FrameRequester; mod interrupts; @@ -263,6 +279,10 @@ pub(crate) struct ChatWidget { // Whether to add a final message separator after the last message needs_final_message_separator: bool, + security_review_task: Option>, + security_review_context: Option, + security_review_artifacts: Option, + last_rendered_width: std::cell::Cell>, } @@ -288,6 +308,26 @@ fn create_initial_user_message(text: String, image_paths: Vec) -> Optio } } +struct SecurityReviewContext { + mode: SecurityReviewMode, + include_paths: Vec, + output_root: PathBuf, + repo_path: PathBuf, + model: String, + provider_name: String, + started_at: Instant, + last_log: Option, +} + +#[allow(dead_code)] +struct SecurityReviewArtifactsState { + repo_root: PathBuf, + snapshot_path: PathBuf, + bugs_path: PathBuf, + report_path: Option, + report_html_path: Option, +} + impl ChatWidget { fn model_description_for(slug: &str) -> Option<&'static str> { if slug.starts_with("gpt-5-codex") { @@ -954,6 +994,9 @@ impl ChatWidget { ghost_snapshots: Vec::new(), ghost_snapshots_disabled: true, needs_final_message_separator: false, + security_review_task: None, + security_review_context: None, + security_review_artifacts: None, last_rendered_width: std::cell::Cell::new(None), } } @@ -1019,6 +1062,9 @@ impl ChatWidget { ghost_snapshots: Vec::new(), ghost_snapshots_disabled: true, needs_final_message_separator: false, + security_review_task: None, + security_review_context: None, + security_review_artifacts: None, last_rendered_width: std::cell::Cell::new(None), } } @@ -1137,6 +1183,9 @@ impl ChatWidget { SlashCommand::Review => { self.open_review_popup(); } + SlashCommand::SecReview => { + self.open_security_review_popup(); + } SlashCommand::Model => { self.open_model_popup(); } @@ -2001,6 +2050,468 @@ impl ChatWidget { }); } + pub(crate) fn open_security_review_popup(&mut self) { + let mut items: Vec = Vec::new(); + + items.push(SelectionItem { + name: "Full security review".to_string(), + description: Some("(bugs + full report)".into()), + actions: vec![Box::new(|tx: &AppEventSender| { + tx.send(AppEvent::StartSecurityReview { + mode: SecurityReviewMode::Full, + include_paths: Vec::new(), + scope_prompt: None, + }); + })], + dismiss_on_select: true, + ..Default::default() + }); + + items.push(SelectionItem { + name: "Quick bug sweep".to_string(), + description: Some("(bugs only)".into()), + actions: vec![Box::new(|tx: &AppEventSender| { + tx.send(AppEvent::StartSecurityReview { + mode: SecurityReviewMode::Bugs, + include_paths: Vec::new(), + scope_prompt: None, + }); + })], + dismiss_on_select: true, + ..Default::default() + }); + + items.push(SelectionItem { + name: "Full review for specific paths".to_string(), + description: Some("Enter relative paths to scope the run".into()), + actions: vec![Box::new(|tx: &AppEventSender| { + tx.send(AppEvent::OpenSecurityReviewPathPrompt( + SecurityReviewMode::Full, + )); + })], + dismiss_on_select: true, + ..Default::default() + }); + + items.push(SelectionItem { + name: "Bug sweep for specific paths".to_string(), + actions: vec![Box::new(|tx: &AppEventSender| { + tx.send(AppEvent::OpenSecurityReviewPathPrompt( + SecurityReviewMode::Bugs, + )); + })], + dismiss_on_select: true, + ..Default::default() + }); + + self.bottom_pane.show_selection_view(SelectionViewParams { + title: Some("Security review options".to_string()), + footer_hint: Some(standard_popup_hint_line()), + items, + ..Default::default() + }); + } + + pub(crate) fn start_security_review( + &mut self, + mode: SecurityReviewMode, + include_paths: Vec, + scope_prompt: Option, + ) { + if self.bottom_pane.is_task_running() || self.security_review_context.is_some() { + self.add_error_message( + "A security review is already running. Wait for it to finish before starting another." + .to_string(), + ); + return; + } + + self.security_review_artifacts = None; + + let repo_path = self.config.cwd.clone(); + if !repo_path.exists() { + self.add_error_message(format!( + "Repository path {} does not exist.", + repo_path.display() + )); + return; + } + + let mut resolved_paths: Vec = Vec::new(); + let mut display_paths: Vec = Vec::new(); + + if !include_paths.is_empty() { + for raw in include_paths { + let trimmed = raw.trim(); + if trimmed.is_empty() { + continue; + } + + let candidate = if Path::new(trimmed).is_absolute() { + PathBuf::from(trimmed) + } else { + repo_path.join(trimmed) + }; + + if !candidate.exists() { + self.add_error_message(format!( + "Path `{}` was not found within {}.", + trimmed, + repo_path.display() + )); + return; + } + + let canonical = candidate.canonicalize().unwrap_or(candidate.clone()); + resolved_paths.push(canonical.clone()); + display_paths.push(display_path_for(&canonical, &repo_path)); + } + + if resolved_paths.is_empty() { + self.add_error_message( + "No valid paths were provided for the security review.".to_string(), + ); + return; + } + } + + let mut context_paths = display_paths.clone(); + if context_paths.is_empty() + && let Some(prompt) = scope_prompt.as_ref() + { + let summary = truncate_text(prompt, 72); + context_paths.push(format!("Auto scope requested: {summary}")); + } + + let timestamp = Utc::now().format("%Y%m%d-%H%M%S").to_string(); + let output_root = repo_path.join("appsec_review").join(timestamp); + + self.bottom_pane.set_task_running(true); + self.bottom_pane + .update_status_header(format!("Security review ({}) — preparing", mode.as_str())); + + let scope_text = if context_paths.is_empty() { + "entire repository".to_string() + } else { + context_paths.join(", ") + }; + + self.add_info_message( + if context_paths.is_empty() { + format!(">> Security review started (mode: {}) <<", mode.as_str()) + } else { + format!( + ">> Security review started (mode: {}, scope: {}) <<", + mode.as_str(), + scope_text + ) + }, + None, + ); + + self.security_review_context = Some(SecurityReviewContext { + mode, + include_paths: context_paths, + output_root: output_root.clone(), + repo_path: repo_path.clone(), + model: self.config.model.clone(), + provider_name: self.config.model_provider.name.clone(), + started_at: Instant::now(), + last_log: None, + }); + + let request = SecurityReviewRequest { + repo_path, + include_paths: resolved_paths, + output_root, + mode, + include_spec_in_bug_analysis: true, + triage_model: self.config.review_model.clone(), + model: self.config.model.clone(), + provider: self.config.model_provider.clone(), + auth: self.auth_manager.auth(), + progress_sender: Some(self.app_event_tx.clone()), + auto_scope_prompt: scope_prompt, + }; + + let tx = self.app_event_tx.clone(); + let handle = tokio::spawn(async move { + match run_security_review(request).await { + Ok(result) => { + tx.send(AppEvent::SecurityReviewComplete { result }); + } + Err(error) => { + tx.send(AppEvent::SecurityReviewFailed { error }); + } + } + }); + self.security_review_task = Some(handle); + } + + pub(crate) fn show_security_review_path_prompt(&mut self, mode: SecurityReviewMode) { + let tx = self.app_event_tx.clone(); + let repo_root = self.config.cwd.clone(); + let title = match mode { + SecurityReviewMode::Full => "Full security review (scoped)".to_string(), + SecurityReviewMode::Bugs => "Bug sweep (scoped)".to_string(), + }; + let hint = match mode { + SecurityReviewMode::Full => "Type relative paths (space-separated) to include, then press Enter to run the full review".to_string(), + SecurityReviewMode::Bugs => "Type relative paths (space-separated) to include, then press Enter to run the bug sweep".to_string(), + }; + let view = CustomPromptView::new( + title, + hint, + None, + Box::new(move |input: String| { + let trimmed = input.trim(); + if trimmed.is_empty() { + return; + } + + let mut include_paths: Vec = Vec::new(); + let mut all_valid = true; + + for segment in trimmed.split_whitespace() { + let candidate = if Path::new(segment).is_absolute() { + PathBuf::from(segment) + } else { + repo_root.join(segment) + }; + if candidate.exists() { + include_paths.push(segment.to_string()); + } else { + all_valid = false; + break; + } + } + + let scope_prompt = if all_valid && !include_paths.is_empty() { + None + } else { + Some(trimmed.to_string()) + }; + + if scope_prompt.is_some() { + include_paths.clear(); + } + + tx.send(AppEvent::StartSecurityReview { + mode, + include_paths, + scope_prompt, + }); + }), + ); + self.bottom_pane.show_view(Box::new(view)); + } + + pub(crate) fn show_security_review_scope_confirmation( + &mut self, + mode: SecurityReviewMode, + prompt: String, + selections: Vec, + responder: oneshot::Sender, + ) { + let view = SecurityReviewScopeConfirmView::new(mode, prompt, selections, responder); + self.bottom_pane.show_view(Box::new(view)); + } + + pub(crate) fn on_security_review_scope_resolved(&mut self, paths: Vec) { + if let Some(ctx) = self.security_review_context.as_mut() { + ctx.include_paths = if paths.is_empty() { Vec::new() } else { paths }; + } + } + + pub(crate) fn on_security_review_log(&mut self, message: String) { + if let Some(ctx) = self.security_review_context.as_mut() { + ctx.last_log = Some(message.clone()); + let truncated = truncate_text(&message, 96); + let header = format!("Security review ({}) — {}", ctx.mode.as_str(), truncated); + self.bottom_pane.update_status_header(header); + } + } + + pub(crate) fn on_security_review_command_status( + &mut self, + _id: u64, + summary: String, + state: SecurityReviewCommandState, + preview: Vec, + ) { + if let Some(ctx) = self.security_review_context.as_mut() { + let state_label = match state { + SecurityReviewCommandState::Running => "running", + SecurityReviewCommandState::Matches => "matches", + SecurityReviewCommandState::NoMatches => "no matches", + SecurityReviewCommandState::Error => "error", + }; + let mut text = summary; + if let Some(first) = preview.first().map(|s| s.trim()).filter(|s| !s.is_empty()) { + text = format!("{text} — {first}"); + } + let truncated = truncate_text(&text, 96); + self.bottom_pane.update_status_header(format!( + "Security review ({}) — [{state_label}] {truncated}", + ctx.mode.as_str() + )); + ctx.last_log = Some(text); + } + } + + pub(crate) fn on_security_review_complete(&mut self, result: SecurityReviewResult) { + self.bottom_pane.set_task_running(false); + self.bottom_pane + .update_status_header(String::from("Working")); + self.security_review_task = None; + + let context = self.security_review_context.take(); + let (mode, scope_paths, output_root, repo_path, model, provider, started_at, last_log) = + if let Some(ctx) = context { + ( + ctx.mode, + ctx.include_paths, + ctx.output_root, + ctx.repo_path, + ctx.model, + ctx.provider_name, + ctx.started_at, + ctx.last_log, + ) + } else { + ( + SecurityReviewMode::Full, + Vec::new(), + PathBuf::new(), + self.config.cwd.clone(), + self.config.model.clone(), + self.config.model_provider.name.clone(), + Instant::now(), + None, + ) + }; + + let duration = started_at.elapsed(); + let duration_display = fmt_elapsed_compact(duration.as_secs()); + let scope_text = if scope_paths.is_empty() { + "entire repository".to_string() + } else { + scope_paths.join(", ") + }; + let findings_summary_text = result.findings_summary.clone(); + let bugs_display = display_path_for(&result.bugs_path, &repo_path); + let report_markdown_display = result + .report_path + .as_ref() + .map(|path| display_path_for(path, &repo_path)); + let report_html_display = result + .report_html_path + .as_ref() + .map(|path| display_path_for(path, &repo_path)); + + let mut summary_lines: Vec> = Vec::new(); + summary_lines.push(vec!["• ".dim(), format!("Mode: {}", mode.as_str()).into()].into()); + summary_lines.push(vec![" • ".into(), format!("Scope: {scope_text}").into()].into()); + summary_lines.push( + vec![ + " • ".into(), + format!("Duration: {duration_display}").into(), + ] + .into(), + ); + summary_lines.push( + vec![ + " • ".into(), + format!("Model: {model} via {provider}").into(), + ] + .into(), + ); + summary_lines.push( + vec![ + " • ".into(), + format!("Findings: {}", findings_summary_text.as_str()).into(), + ] + .into(), + ); + summary_lines.push( + vec![ + " • ".into(), + format!("Bugs: {}", bugs_display.as_str()).into(), + ] + .into(), + ); + if let Some(report_line) = match ( + report_markdown_display.as_ref(), + report_html_display.as_ref(), + ) { + (Some(md), Some(html)) => Some(format!("Report: md {md}, html {html}")), + (Some(md), None) => Some(format!("Report: md {md}")), + (None, Some(html)) => Some(format!("Report: html {html}")), + (None, None) => None, + } { + summary_lines.push(vec![" • ".into(), report_line.into()].into()); + } + if let Some(last_log) = last_log { + summary_lines + .push(vec![" • ".into(), format!("Last update: {last_log}").dim()].into()); + } + summary_lines.push( + vec![ + " • ".into(), + format!("Log entries: {}", result.logs.len()).dim(), + ] + .into(), + ); + + self.add_to_history(PlainHistoryCell::new(summary_lines)); + + if !result.logs.is_empty() { + let mut log_lines: Vec> = Vec::new(); + log_lines.push(vec!["Logs".bold()].into()); + for entry in &result.logs { + log_lines.push(vec![" ↳ ".dim(), entry.clone().into()].into()); + } + self.add_to_history(PlainHistoryCell::new(log_lines)); + } + + self.security_review_artifacts = Some(SecurityReviewArtifactsState { + repo_root: repo_path.clone(), + snapshot_path: result.snapshot_path.clone(), + bugs_path: result.bugs_path.clone(), + report_path: result.report_path.clone(), + report_html_path: result.report_html_path.clone(), + }); + + if !output_root.as_os_str().is_empty() { + self.add_info_message( + format!( + "Security review artifacts saved to {}", + display_path_for(&output_root, &self.config.cwd) + ), + None, + ); + } + } + + pub(crate) fn on_security_review_failed(&mut self, error: SecurityReviewFailure) { + self.bottom_pane.set_task_running(false); + self.bottom_pane + .update_status_header(String::from("Working")); + self.security_review_task = None; + self.security_review_context = None; + + self.add_error_message(format!("Security review failed: {}", error.message)); + + if !error.logs.is_empty() { + let mut log_lines: Vec> = Vec::new(); + log_lines.push(vec!["Logs".bold()].into()); + for entry in error.logs { + log_lines.push(vec![" ↳ ".dim(), entry.into()].into()); + } + self.add_to_history(PlainHistoryCell::new(log_lines)); + } + } + pub(crate) async fn show_review_branch_picker(&mut self, cwd: &Path) { let branches = local_git_branches(cwd).await; let current_branch = current_branch_name(cwd) diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index a92be43365..c504246e7c 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -288,6 +288,9 @@ fn make_chatwidget_manual() -> ( ghost_snapshots: Vec::new(), ghost_snapshots_disabled: false, needs_final_message_separator: false, + security_review_task: None, + security_review_context: None, + security_review_artifacts: None, last_rendered_width: std::cell::Cell::new(None), }; (widget, rx, op_rx) diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 7d7412e0a4..54fb92538b 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -54,11 +54,14 @@ pub mod live_wrap; mod markdown; mod markdown_render; mod markdown_stream; +mod mermaid; pub mod onboarding; mod pager_overlay; pub mod public_widgets; mod render; mod resume_picker; +mod security_report_viewer; +mod security_review; mod session_log; mod shimmer; mod slash_command; diff --git a/codex-rs/tui/src/mermaid.rs b/codex-rs/tui/src/mermaid.rs new file mode 100644 index 0000000000..e41e8d5fd3 --- /dev/null +++ b/codex-rs/tui/src/mermaid.rs @@ -0,0 +1,3 @@ +pub(crate) fn fix_mermaid_blocks(input: &str) -> String { + input.to_string() +} diff --git a/codex-rs/tui/src/security_report_viewer.rs b/codex-rs/tui/src/security_report_viewer.rs new file mode 100644 index 0000000000..a934a972fa --- /dev/null +++ b/codex-rs/tui/src/security_report_viewer.rs @@ -0,0 +1,23 @@ +fn escape_html(input: &str) -> String { + let mut out = String::with_capacity(input.len()); + for ch in input.chars() { + match ch { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + _ => out.push(ch), + } + } + out +} + +pub(crate) fn build_report_html(title: &str, markdown: &str) -> String { + let escaped_title = escape_html(title); + let escaped_markdown = escape_html(markdown); + format!( + "{escaped_title}\ +
{escaped_markdown}
" + ) +} diff --git a/codex-rs/tui/src/security_review.rs b/codex-rs/tui/src/security_review.rs index 352e40ed5e..0b3551806e 100644 --- a/codex-rs/tui/src/security_review.rs +++ b/codex-rs/tui/src/security_review.rs @@ -1,3 +1,5 @@ +#![allow(dead_code)] + use crate::app_event::AppEvent; use crate::app_event::SecurityReviewAutoScopeSelection; use crate::app_event::SecurityReviewCommandState; diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 14604a736d..d51225b74a 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -15,6 +15,8 @@ pub enum SlashCommand { Model, Approvals, Review, + #[strum(serialize = "secreview")] + SecReview, New, Init, Compact, @@ -37,6 +39,7 @@ impl SlashCommand { SlashCommand::Init => "create an AGENTS.md file with instructions for Codex", SlashCommand::Compact => "summarize conversation to prevent hitting the context limit", SlashCommand::Review => "review my current changes and find issues", + SlashCommand::SecReview => "run an AppSec security review over the repo", SlashCommand::Undo => "restore the workspace to the last Codex snapshot", SlashCommand::Quit => "exit Codex", SlashCommand::Diff => "show git diff (including untracked files)", @@ -67,6 +70,7 @@ impl SlashCommand { | SlashCommand::Model | SlashCommand::Approvals | SlashCommand::Review + | SlashCommand::SecReview | SlashCommand::Logout => false, SlashCommand::Diff | SlashCommand::Mention