mirror of
https://github.com/openai/codex.git
synced 2026-10-27 18:48:37 +00:00
Restore /secreview command and security review UI
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
scope_prompt: Option<String>,
|
||||
},
|
||||
|
||||
/// Prompt the user to confirm auto-detected scope selections.
|
||||
SecurityReviewAutoScopeConfirm {
|
||||
mode: SecurityReviewMode,
|
||||
prompt: String,
|
||||
selections: Vec<SecurityReviewAutoScopeSelection>,
|
||||
responder: oneshot::Sender<bool>,
|
||||
},
|
||||
|
||||
/// Notify that the security review scope has been resolved to specific paths.
|
||||
SecurityReviewScopeResolved {
|
||||
paths: Vec<String>,
|
||||
},
|
||||
|
||||
/// Update the command status display for running security review shell commands.
|
||||
SecurityReviewCommandStatus {
|
||||
id: u64,
|
||||
summary: String,
|
||||
state: SecurityReviewCommandState,
|
||||
preview: Vec<String>,
|
||||
},
|
||||
|
||||
/// 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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<SecurityReviewAutoScopeSelection>,
|
||||
responder: Option<oneshot::Sender<bool>>,
|
||||
complete: bool,
|
||||
}
|
||||
|
||||
impl SecurityReviewScopeConfirmView {
|
||||
pub(crate) fn new(
|
||||
mode: SecurityReviewMode,
|
||||
prompt: String,
|
||||
selections: Vec<SecurityReviewAutoScopeSelection>,
|
||||
responder: oneshot::Sender<bool>,
|
||||
) -> 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<Line> = 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<Span> = 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::<Span>::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);
|
||||
}
|
||||
}
|
||||
@@ -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<JoinHandle<()>>,
|
||||
security_review_context: Option<SecurityReviewContext>,
|
||||
security_review_artifacts: Option<SecurityReviewArtifactsState>,
|
||||
|
||||
last_rendered_width: std::cell::Cell<Option<usize>>,
|
||||
}
|
||||
|
||||
@@ -288,6 +308,26 @@ fn create_initial_user_message(text: String, image_paths: Vec<PathBuf>) -> Optio
|
||||
}
|
||||
}
|
||||
|
||||
struct SecurityReviewContext {
|
||||
mode: SecurityReviewMode,
|
||||
include_paths: Vec<String>,
|
||||
output_root: PathBuf,
|
||||
repo_path: PathBuf,
|
||||
model: String,
|
||||
provider_name: String,
|
||||
started_at: Instant,
|
||||
last_log: Option<String>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
struct SecurityReviewArtifactsState {
|
||||
repo_root: PathBuf,
|
||||
snapshot_path: PathBuf,
|
||||
bugs_path: PathBuf,
|
||||
report_path: Option<PathBuf>,
|
||||
report_html_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
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<SelectionItem> = 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<String>,
|
||||
scope_prompt: Option<String>,
|
||||
) {
|
||||
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<PathBuf> = Vec::new();
|
||||
let mut display_paths: Vec<String> = 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<String> = 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<SecurityReviewAutoScopeSelection>,
|
||||
responder: oneshot::Sender<bool>,
|
||||
) {
|
||||
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<String>) {
|
||||
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<String>,
|
||||
) {
|
||||
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<Line<'static>> = 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<Line<'static>> = 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<Line<'static>> = 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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
pub(crate) fn fix_mermaid_blocks(input: &str) -> String {
|
||||
input.to_string()
|
||||
}
|
||||
@@ -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!(
|
||||
"<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>{escaped_title}</title></head>\
|
||||
<body><pre>{escaped_markdown}</pre></body></html>"
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event::SecurityReviewAutoScopeSelection;
|
||||
use crate::app_event::SecurityReviewCommandState;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user