mirror of
https://github.com/openai/codex.git
synced 2026-09-16 12:13:30 +00:00
tui: disable auto-scope for /secreview options 1 & 2; remove default auto-scope prompt and auto-accept for Quick bug sweep; rebuild
This commit is contained in:
@@ -115,6 +115,7 @@ async fn run_rg_search(
|
||||
limit: usize,
|
||||
cwd: &Path,
|
||||
) -> Result<Vec<String>, FunctionCallError> {
|
||||
// First attempt: regex search
|
||||
let mut command = Command::new("rg");
|
||||
command
|
||||
.current_dir(cwd)
|
||||
@@ -146,8 +147,49 @@ async fn run_rg_search(
|
||||
Some(1) => Ok(Vec::new()),
|
||||
_ => {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stderr_trimmed = stderr.trim();
|
||||
// Retry with fixed-strings if the regex failed to parse.
|
||||
if stderr_trimmed.contains("regex parse error")
|
||||
|| stderr_trimmed.contains("error parsing regex")
|
||||
|| stderr_trimmed.contains("unclosed group")
|
||||
{
|
||||
let mut fixed = Command::new("rg");
|
||||
fixed
|
||||
.current_dir(cwd)
|
||||
.arg("--files-with-matches")
|
||||
.arg("--sortr=modified")
|
||||
.arg("--fixed-strings")
|
||||
.arg(pattern)
|
||||
.arg("--no-messages");
|
||||
if let Some(glob) = include {
|
||||
fixed.arg("--glob").arg(glob);
|
||||
}
|
||||
fixed.arg("--").arg(search_path);
|
||||
let second = timeout(COMMAND_TIMEOUT, fixed.output())
|
||||
.await
|
||||
.map_err(|_| {
|
||||
FunctionCallError::RespondToModel(
|
||||
"rg timed out after 30 seconds".to_string(),
|
||||
)
|
||||
})?
|
||||
.map_err(|err| {
|
||||
FunctionCallError::RespondToModel(format!(
|
||||
"failed to launch rg: {err}. Ensure ripgrep is installed and on PATH."
|
||||
))
|
||||
})?;
|
||||
return match second.status.code() {
|
||||
Some(0) => Ok(parse_results(&second.stdout, limit)),
|
||||
Some(1) => Ok(Vec::new()),
|
||||
_ => {
|
||||
let second_stderr = String::from_utf8_lossy(&second.stderr);
|
||||
Err(FunctionCallError::RespondToModel(format!(
|
||||
"rg failed: {second_stderr}"
|
||||
)))
|
||||
}
|
||||
};
|
||||
}
|
||||
Err(FunctionCallError::RespondToModel(format!(
|
||||
"rg failed: {stderr}"
|
||||
"rg failed: {stderr_trimmed}"
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -347,6 +347,7 @@ struct SecurityReviewContext {
|
||||
provider_name: String,
|
||||
started_at: Instant,
|
||||
last_log: Option<String>,
|
||||
thinking_lines: Vec<String>,
|
||||
}
|
||||
|
||||
struct SecurityReviewFollowUpState {
|
||||
@@ -2393,23 +2394,10 @@ impl ChatWidget {
|
||||
}
|
||||
}
|
||||
|
||||
let mut scope_prompt = scope_prompt;
|
||||
if matches!(mode, SecurityReviewMode::Bugs)
|
||||
&& resolved_paths.is_empty()
|
||||
&& scope_prompt.is_none()
|
||||
{
|
||||
scope_prompt = Some(
|
||||
"Suggest up to 20 directories most likely to contain critical or high-risk code paths. Prioritise request parsing, input validation, authentication, authorisation, and secret handling. Skip tests, vendor bundles, docs, and generated files.".to_string(),
|
||||
);
|
||||
}
|
||||
let skip_auto_scope_confirmation = false;
|
||||
|
||||
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 context_paths = display_paths.clone();
|
||||
// Do not echo the auto-scope prompt into the scope list; keep the header concise.
|
||||
|
||||
let timestamp = Utc::now().format("%Y%m%d-%H%M%S").to_string();
|
||||
let output_root = repo_path.join("appsec_review").join(timestamp);
|
||||
@@ -2446,6 +2434,7 @@ impl ChatWidget {
|
||||
provider_name: self.config.model_provider.name.clone(),
|
||||
started_at: Instant::now(),
|
||||
last_log: None,
|
||||
thinking_lines: Vec::new(),
|
||||
});
|
||||
|
||||
let annotated_scope_prompt = if matches!(mode, SecurityReviewMode::Bugs) {
|
||||
@@ -2467,6 +2456,7 @@ impl ChatWidget {
|
||||
provider: self.config.model_provider.clone(),
|
||||
auth: self.auth_manager.auth(),
|
||||
progress_sender: Some(self.app_event_tx.clone()),
|
||||
skip_auto_scope_confirmation,
|
||||
auto_scope_prompt: annotated_scope_prompt,
|
||||
};
|
||||
|
||||
@@ -2562,10 +2552,84 @@ impl ChatWidget {
|
||||
|
||||
pub(crate) fn on_security_review_log(&mut self, message: String) {
|
||||
if let Some(ctx) = self.security_review_context.as_mut() {
|
||||
// Drop overly verbose heartbeat for bug analysis; header already shows progress.
|
||||
if message.starts_with("Still waiting for bug analysis response from model") {
|
||||
return;
|
||||
}
|
||||
// Extract trailing percent in the form " - NN%" and move it to the front.
|
||||
// Enhance with a small 10-slot progress bar.
|
||||
let mut percent_prefix = String::new();
|
||||
let mut core = message.as_str();
|
||||
if let Some(idx) = message.rfind(" - ") {
|
||||
let tail = &message[idx + 3..];
|
||||
if let Some(no_dot) = tail
|
||||
.strip_suffix('.')
|
||||
.or_else(|| Some(tail).filter(|t| !t.is_empty()))
|
||||
&& let Some(num_str) = no_dot.strip_suffix('%').filter(|s| !s.is_empty())
|
||||
&& num_str.chars().all(|c| c.is_ascii_digit())
|
||||
{
|
||||
let pct: usize = num_str.parse::<usize>().unwrap_or(0).min(100);
|
||||
let width = 10usize;
|
||||
let filled = (pct * width) / 100;
|
||||
let mut bar = String::new();
|
||||
if filled > 0 {
|
||||
bar.push_str(&"█".repeat(filled));
|
||||
}
|
||||
if width > filled {
|
||||
bar.push_str(&"░".repeat(width - filled));
|
||||
}
|
||||
percent_prefix = format!("{pct}% {bar} ");
|
||||
core = &message[..idx];
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
// Compact known progress messages: show counts succinctly.
|
||||
let mut display_core = core.trim();
|
||||
if let Some(rest) = display_core
|
||||
.strip_prefix("File triage progress:")
|
||||
.or_else(|| display_core.strip_prefix("Bug analysis progress:"))
|
||||
{
|
||||
let tail = rest.trim();
|
||||
if let Some(slash_pos) = tail.find('/') {
|
||||
// Keep "N/M" and append " files" for clarity.
|
||||
let (a, b) = tail.split_at(slash_pos);
|
||||
let _ = a.trim();
|
||||
let _ = b.trim();
|
||||
// Use the original tail (N/M) if it looks correct.
|
||||
if tail.chars().any(|c| c == '/') {
|
||||
display_core = Box::leak(format!("{tail} files").into_boxed_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let truncated = truncate_text(display_core, 96);
|
||||
let header = format!(
|
||||
"Security review ({}) - {}{}",
|
||||
ctx.mode.as_str(),
|
||||
percent_prefix,
|
||||
truncated
|
||||
);
|
||||
|
||||
if message.starts_with("Model reasoning:") {
|
||||
let reason = message.trim_start_matches("Model reasoning:").trim();
|
||||
let line = truncate_text(reason, 160);
|
||||
ctx.thinking_lines.push(line);
|
||||
if ctx.thinking_lines.len() > 4 {
|
||||
let start = ctx.thinking_lines.len() - 4;
|
||||
ctx.thinking_lines = ctx.thinking_lines.split_off(start);
|
||||
}
|
||||
self.bottom_pane.update_status_snapshot(
|
||||
crate::status_indicator_widget::StatusSnapshot {
|
||||
header,
|
||||
progress: None,
|
||||
thinking: ctx.thinking_lines.clone(),
|
||||
tool_calls: Vec::new(),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
self.bottom_pane.update_status_header(header);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2707,7 +2771,8 @@ impl ChatWidget {
|
||||
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());
|
||||
let prefix = security_review_log_prefix(entry);
|
||||
log_lines.push(vec![prefix.dim(), entry.clone().into()].into());
|
||||
}
|
||||
self.add_to_history(PlainHistoryCell::new(log_lines));
|
||||
}
|
||||
@@ -2735,6 +2800,16 @@ impl ChatWidget {
|
||||
"Bugs".to_string()
|
||||
};
|
||||
let follow_up_display = display_path_for(&follow_up_path, &repo_path);
|
||||
|
||||
// Show bug summary table at the end, just before the follow-up line.
|
||||
if let Some(table) = result.bug_summary_table.as_ref() {
|
||||
let mut table_lines: Vec<Line<'static>> = Vec::new();
|
||||
table_lines.push("Bug summary table".bold().into());
|
||||
for row in table.lines() {
|
||||
table_lines.push(Line::from(row.to_string()));
|
||||
}
|
||||
self.add_to_history(PlainHistoryCell::new(table_lines));
|
||||
}
|
||||
self.add_info_message(
|
||||
format!(
|
||||
"Security review follow-up ready — questions will include context from {follow_up_label} ({follow_up_display})."
|
||||
@@ -2767,7 +2842,8 @@ impl ChatWidget {
|
||||
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());
|
||||
let prefix = security_review_log_prefix(&entry);
|
||||
log_lines.push(vec![prefix.dim(), entry.into()].into());
|
||||
}
|
||||
self.add_to_history(PlainHistoryCell::new(log_lines));
|
||||
}
|
||||
@@ -3074,5 +3150,49 @@ pub(crate) fn show_review_commit_picker_with_entries(
|
||||
});
|
||||
}
|
||||
|
||||
fn security_review_log_prefix(entry: &str) -> &'static str {
|
||||
if is_security_review_tool_log(entry) {
|
||||
" ↳ "
|
||||
} else {
|
||||
" ↳ "
|
||||
}
|
||||
}
|
||||
|
||||
fn is_security_review_tool_log(entry: &str) -> bool {
|
||||
const TOOL_PREFIXES: &[&str] = &[
|
||||
"Search `",
|
||||
"No content matches found for `",
|
||||
"Ripgrep content search for `",
|
||||
"Stopping search commands for ",
|
||||
"grep_files for `",
|
||||
"No files matched `",
|
||||
"Auto scope content search `",
|
||||
"Auto scope grep_files search",
|
||||
"Auto scope read `",
|
||||
];
|
||||
if TOOL_PREFIXES.iter().any(|prefix| entry.starts_with(prefix)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if entry.starts_with("Auto scope ") && (entry.contains(" search ") || entry.contains(" read "))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if entry.contains(" verification for ") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const TOOL_FRAGMENTS: &[&str] = &[
|
||||
": curl",
|
||||
": failed to run curl",
|
||||
": python",
|
||||
": failed to run python",
|
||||
];
|
||||
TOOL_FRAGMENTS
|
||||
.iter()
|
||||
.any(|fragment| entry.contains(fragment))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests;
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
assertion_line: 1500
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"• Thinking (0s • esc to interrupt) "
|
||||
"• Thinking - **Thinking** (0s • esc to i"
|
||||
"› Ask Codex to do anything "
|
||||
|
||||
@@ -9,8 +9,7 @@ expression: term.backend().vt100().screen().contents()
|
||||
└ Search Change Approved
|
||||
Read diff_render.rs
|
||||
|
||||
• Investigating rendering code (0s • esc to interrupt)
|
||||
↺ **Investigating rendering code**
|
||||
• Investigating rendering code - **Investigating rendering code** (0s • esc to i
|
||||
|
||||
|
||||
› Summarize recent commits
|
||||
|
||||
@@ -3,8 +3,7 @@ source: tui/src/chatwidget/tests.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"• Analyzing (0s • esc to interrupt) "
|
||||
" ↺ **Analyzing** "
|
||||
"• Analyzing - **Analyzing** (0s • esc to interrupt) "
|
||||
" "
|
||||
" "
|
||||
"› Ask Codex to do anything "
|
||||
|
||||
@@ -60,6 +60,7 @@ mod pager_overlay;
|
||||
pub mod public_widgets;
|
||||
mod render;
|
||||
mod resume_picker;
|
||||
mod security_prompts;
|
||||
mod security_report_viewer;
|
||||
mod security_review;
|
||||
mod session_log;
|
||||
|
||||
165
codex-rs/tui/src/security_prompts.rs
Normal file
165
codex-rs/tui/src/security_prompts.rs
Normal file
@@ -0,0 +1,165 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
// Centralized prompt strings for the security review feature.
|
||||
|
||||
// Auto-scope prompts
|
||||
pub(crate) const AUTO_SCOPE_SYSTEM_PROMPT: &str = "You are an application security engineer helping select the minimal set of directories that should be examined for a security review. Only respond with JSON lines that follow the requested schema.";
|
||||
pub(crate) const AUTO_SCOPE_PROMPT_TEMPLATE: &str = r#"
|
||||
You are assisting with an application security review. Identify the minimal set of directories that should be in scope.
|
||||
|
||||
# Repository overview
|
||||
{repo_overview}
|
||||
|
||||
# Request
|
||||
<intent>{user_query}</intent>
|
||||
|
||||
# Request keywords
|
||||
{keywords}
|
||||
|
||||
# Conversation history
|
||||
{conversation}
|
||||
|
||||
# Available tools
|
||||
- GREP_FILES: respond with `GREP_FILES: {"pattern":"needle","include":"*.rs","path":"subdir","limit":200}` to list files whose contents match. Fields:
|
||||
- pattern: regex string (required)
|
||||
- include: optional glob filter (ripgrep --glob)
|
||||
- path: optional directory/file to search (defaults to repo root)
|
||||
- limit: optional max paths to return (default 100, max 2000)
|
||||
- READ: respond with `READ: <relative path>#L<start>-L<end>` to inspect source code (omit the range to read roughly {read_window} lines starting at the top of the file).
|
||||
|
||||
Issue at most one tool command per message and wait for the tool output before continuing. When you have gathered enough information, respond only with JSON Lines as described below.
|
||||
|
||||
# Selection rules
|
||||
- Prefer code that serves production traffic, handles external input, or configures deployed infrastructure.
|
||||
- Return directories (not files). Use the highest level that contains the relevant implementation; avoid returning both a parent and its child.
|
||||
- Skip tests, docs, vendored dependencies, caches, build artefacts, editor configuration, or directories that do not exist.
|
||||
- Limit to the most relevant 3–8 directories when possible.
|
||||
|
||||
# Output format
|
||||
Return JSON Lines: each line must be a single JSON object with keys {"path", "include", "reason"}. Omit fences and additional commentary. If unsure, set include=false and explain in reason. Output `ALL` alone on one line to include the entire repository.
|
||||
"#;
|
||||
pub(crate) const AUTO_SCOPE_JSON_GUARD: &str =
|
||||
"Respond only with JSON Lines as described. Do not include markdown fences, prose, or lists.";
|
||||
pub(crate) const AUTO_SCOPE_KEYWORD_SYSTEM_PROMPT: &str = "You expand security review prompts into concise code search keywords. Respond only with JSON Lines.";
|
||||
pub(crate) const AUTO_SCOPE_KEYWORD_PROMPT_TEMPLATE: &str = r#"
|
||||
Determine the most relevant search keywords for the repository request below. Produce at most {max_keywords} keywords.
|
||||
|
||||
Request:
|
||||
{user_query}
|
||||
|
||||
Guidelines:
|
||||
- Prefer feature, component, service, or technology names that are likely to appear in directory names.
|
||||
- Keep each keyword to 1–3 words; follow repository naming conventions (snake_case, kebab-case) when obvious.
|
||||
- Skip generic words like "security", "review", "code", "bug", or "analysis".
|
||||
- If nothing applies, return a single JSON object {{"keyword": "{fallback_keyword}"}} that restates the subject clearly.
|
||||
|
||||
Output format: JSON Lines, each {{"keyword": "<term>"}}. Do not add commentary or fences.
|
||||
"#;
|
||||
|
||||
// Spec generation prompts
|
||||
pub(crate) const SPEC_SYSTEM_PROMPT: &str = "You are an application security engineer documenting how a project is built. Produce an architecture specification that focuses on components, flows, and controls. Stay within the provided code locations and keep the output in markdown.";
|
||||
pub(crate) const SPEC_COMBINE_SYSTEM_PROMPT: &str = "You are consolidating multiple specification drafts into a single, cohesive project specification. Merge overlapping content, keep terminology consistent, and follow the supplied template. Preserve important security-relevant details while avoiding repetition.";
|
||||
pub(crate) const SPEC_PROMPT_TEMPLATE: &str = "You have access to the source code inside the following locations:\n{project_locations}\n\nFocus on {target_label}.\nGenerate a security-focused project specification. Parallelize discovery when enumerating files and avoid spending time on tests, vendored dependencies, or build artefacts. Follow the template exactly and return only markdown.\n\nTemplate:\n{spec_template}\n";
|
||||
pub(crate) const MARKDOWN_OUTPUT_GUARD: &str = "\n# Output Guard (strict)\n - Output only the final markdown content requested.\n - Do not include goal, analysis, planning, chain-of-thought, or step lists.\n - Do not echo prompt sections like \"Task\", \"Steps\", \"Output\", or \"Important\".\n - Do not include any XML/angle-bracket blocks (e.g., <...> inputs) in the output.\n - Do not wrap the entire response in code fences; use code fences only for code snippets.\n - Do not include apologies, disclaimers, or references to being an AI model.\n";
|
||||
pub(crate) const MARKDOWN_FIX_SYSTEM_PROMPT: &str = "You are a meticulous technical editor. Polish markdown formatting while preserving the original security analysis content. Focus on fixing numbering, bullet spacing, code fences, and diagram syntax without adding or removing information.";
|
||||
pub(crate) const SPEC_COMBINE_PROMPT_TEMPLATE: &str = "You previously generated specification drafts for the following code locations:\n{project_locations}\n\nDraft content:\n{spec_drafts}\n\nTask: merge these drafts into one comprehensive specification that describes the entire project. Remove duplication, keep terminology consistent, and ensure the final document reads as a single report. Follow the template exactly and return only markdown.\n\nTemplate:\n{combined_template}\n";
|
||||
pub(crate) const SPEC_DIR_FILTER_SYSTEM_PROMPT: &str = r#"
|
||||
You triage directories for a security review specification. Only choose directories that hold core product or security-relevant code.
|
||||
- Prefer application source directories (services, packages, libs).
|
||||
- Exclude build artifacts, vendored dependencies, generated code, or documentation-only folders.
|
||||
- Limit the selection to the most critical directories (ideally 3-8).
|
||||
Respond with a newline-separated list containing only the directory paths chosen from the provided list. Respond with `ALL` if every directory should be included. Do not add quotes or extra commentary.
|
||||
"#;
|
||||
pub(crate) const SPEC_MARKDOWN_TEMPLATE: &str = "# Project Specification\n- Location: {target_label}\n- Prepared by: {model_name}\n- Date: {date}\n- In-scope paths:\n```\n{project_locations}\n```\n\n## Overview\nSummarize the product or service, primary users, and the business problem it solves. Highlight the most security relevant entry points.\n\n## Architecture Summary\nDescribe the high-level system architecture, major services, data stores, and external integrations. Include a concise mermaid flowchart when it improves clarity.\n\n## Components\nList 5-8 major components. For each, note the role, responsibilities, key dependencies, and security-critical behavior.\n\n## Business Flows\nDocument up to 5 important flows (CRUD, external integrations, workflow orchestration). For each flow capture triggers, main steps, data touched, and security notes. Include a short mermaid sequence diagram if helpful.\n\n## Authentication\nExplain how principals authenticate, token lifecycles, libraries used, and how secrets are managed.\n\n## Authorization\nDescribe the authorization model, enforcement points, privileged roles, and escalation paths.\n\n## Data Classification\nIdentify sensitive data types handled by the project and where they are stored or transmitted.\n\n## Infrastructure and Deployment\nSummarize infrastructure-as-code, runtime platforms, and configuration or secret handling that affects security posture.\n";
|
||||
pub(crate) const SPEC_COMBINED_MARKDOWN_TEMPLATE: &str = "# Project Specification\n## Executive Overview\nProvide a concise overview of the system, its primary entry points, and the highest-value assets.\n\n## Architecture\nDescribe the overall architecture, including diagrams (mermaid flowchart) where they add clarity. Call out trust boundaries and external dependencies.\n\n## Components\nSummarize each major component grouped by domain (frontend, API, workers, data stores, external integrations). For each component include responsibilities, key dependencies, and notable security considerations.\n\n## Business Flows\nDocument 3-6 critical flows (CRUD, integrations, orchestrations). Explain inputs, key steps, data touched, and defensive controls. Include concise mermaid sequence diagrams when useful.\n\n## Authentication\nDocument authentication methods, token lifecycles, libraries, and secret storage.\n\n## Authorization\nDescribe the authorization model, enforcement mechanisms, privilege boundaries, and escalation paths.\n\n## Data Classification\nSummarize sensitive data handled by the system and where it resides.\n\n## Infrastructure and Deployment\nHighlight infrastructure-as-code, runtime platforms, and configuration or secret delivery mechanisms that influence security posture.\n";
|
||||
|
||||
// Threat model prompts
|
||||
pub(crate) const THREAT_MODEL_SYSTEM_PROMPT: &str = "You are a senior application security engineer preparing a threat model. Use the provided architecture specification and repository summary to enumerate realistic threats, prioritised by risk.";
|
||||
pub(crate) const THREAT_MODEL_PROMPT_TEMPLATE: &str = "# Repository Summary\n{repository_summary}\n\n# Architecture Specification\n{combined_spec}\n\n# In-Scope Locations\n{locations}\n\n# Task\nConstruct a concise threat model for the system. Focus on meaningful attacker goals and concrete impacts.\n\n## Output Requirements\n- Start with a short paragraph summarising the most important threat themes and high-risk areas.\n- Follow with a markdown table named `Threat Model` with columns: `Threat ID`, `Threat source`, `Prerequisites`, `Threat action`, `Threat impact`, `Impacted assets`, `Priority`, `Recommended mitigations`.\n- Use integer IDs starting at 1. Priority must be one of high, medium, low.\n- Keep prerequisite and mitigation text succinct (single sentence each).\n- Do not include any other sections or commentary outside the summary paragraph and table.\n";
|
||||
|
||||
// Bug analysis prompts
|
||||
pub(crate) const BUGS_SYSTEM_PROMPT: &str = "You are an application security engineer reviewing a codebase.\nYou read the provided project context and code excerpts to identify concrete, exploitable security vulnerabilities.\nFor each vulnerability you find, produce a thorough, actionable write-up that a security team could ship directly to engineers.\n\nStrict requirements:\n- Only report real vulnerabilities with a plausible attacker-controlled input and a meaningful impact.\n- Quote exact file paths and GitHub-style line fragments, e.g. `src/server/auth.ts#L42-L67`.\n- Provide dataflow analysis (source, propagation, sink) where relevant.\n- Include a severity rating (high, medium, low, ignore) plus impact and likelihood reasoning.\n- Include a taxonomy line exactly as `TAXONOMY: {...}` containing JSON with keys vuln_class, cwe_ids[], owasp_categories[], vuln_tag.\n- If you cannot find a security-relevant issue, respond with exactly `no bugs found`.\n- Do not invent commits or authors if unavailable; leave fields blank instead.\n- Keep the response in markdown.";
|
||||
|
||||
// The body of the bug analysis user prompt that follows the repository summary.
|
||||
pub(crate) const BUGS_USER_CODE_AND_TASK: &str = r#"
|
||||
# Code excerpts
|
||||
{code_context}
|
||||
|
||||
# Task
|
||||
Evaluate the project for concrete, exploitable security vulnerabilities. Prefer precise, production-relevant issues to theoretical concerns.
|
||||
|
||||
Follow these rules:
|
||||
- Read this file in full and review the provided context to understand intended behavior before judging safety.
|
||||
- Use the search tools below to inspect additional in-scope files when tracing data flows or confirming a hypothesis; cite the relevant variables, functions, and any validation or sanitization steps you discover.
|
||||
- Trace attacker-controlled inputs through the call graph to the ultimate sink. Highlight any sanitization or missing validation along the way.
|
||||
- Ignore unit tests, example scripts, or tooling unless they ship to production in this repo.
|
||||
- Only report real vulnerabilities that an attacker can trigger with meaningful impact. If none are found, respond with exactly `no bugs found` (no additional text).
|
||||
- Quote code snippets and locations using GitHub-style ranges (e.g. `src/service.rs#L10-L24`). Include git blame details when you have them: `<short-sha> <author> <YYYY-MM-DD> L<start>-L<end>`.
|
||||
- Keep all output in markdown and avoid generic disclaimers.
|
||||
- If you need more repository context, request it explicitly while staying within the provided scope:
|
||||
- Emit `GREP_FILES: {"pattern":"needle","include":"*.rs","path":"subdir","limit":200}` to list files whose contents match, ordered by modification time. Prefer searching for meaningful identifiers.
|
||||
- If you need a specific file by path, prefer `READ: <relative path>` directly.
|
||||
|
||||
# Output format
|
||||
For each vulnerability, emit a markdown block:
|
||||
|
||||
### <short title>
|
||||
- **File & Lines:** `<relative path>#Lstart-Lend`
|
||||
- **Severity:** <high|medium|low|ignore>
|
||||
- **Impact:** <concise impact analysis>
|
||||
- **Likelihood:** <likelihood analysis>
|
||||
- **Description:** Detailed narrative with annotated code references explaining the bug.
|
||||
- **Snippet:** Fenced code block (specify language) showing only the relevant lines with inline comments or numbered markers that you reference in the description.
|
||||
- **Dataflow:** Describe sources, propagation, sanitization, and sinks using relative paths and `L<start>-L<end>` ranges.
|
||||
- **PoC:** Concrete steps or payload to reproduce (or `n/a` if infeasible).
|
||||
- **Recommendation:** Actionable remediation guidance.
|
||||
- **Verification Type:** JSON array subset of ["network_api", "crash_poc", "web_browser"].
|
||||
- TAXONOMY: {{"vuln_class": "...", "cwe_ids": [...], "owasp_categories": [...], "vuln_tag": "..."}}
|
||||
|
||||
Ensure severity selections are justified by the described impact and likelihood."#;
|
||||
|
||||
// Bug rerank prompts
|
||||
pub(crate) const BUG_RERANK_SYSTEM_PROMPT: &str = "You are a senior application security engineer triaging review findings. Reassess customer-facing risk using the supplied repository context and previously generated specs. Only respond with JSON Lines.";
|
||||
pub(crate) const BUG_RERANK_PROMPT_TEMPLATE: &str = r#"
|
||||
Repository summary (trimmed):
|
||||
{repository_summary}
|
||||
|
||||
Spec excerpt (trimmed; pull in concrete details or note if unavailable):
|
||||
{spec_excerpt}
|
||||
|
||||
Examples:
|
||||
- External unauthenticated remote code execution on a production API ⇒ risk_score 95, severity "High", reason "unauth RCE takeover".
|
||||
- Stored XSS on user dashboards that leaks session tokens ⇒ risk_score 72, severity "High", reason "persistent session theft".
|
||||
- Originally escalated CSRF on an internal admin tool behind SSO ⇒ risk_score 28, severity "Low", reason "internal-only with SSO".
|
||||
- Header injection in a deprecated endpoint with response sanitization ⇒ risk_score 18, severity "Informational", reason "sanitized legacy endpoint".
|
||||
- Static analysis high alert that only touches dead code ⇒ risk_score 10, severity "Informational", reason "dead code path".
|
||||
- High-severity SQL injection finding that uses fully parameterized queries ⇒ risk_score 20, severity "Low", reason "parameterized queries".
|
||||
- SSRF flagged as critical but the target requires internal metadata access tokens ⇒ risk_score 24, severity "Low", reason "internal metadata token".
|
||||
- Critical-looking command injection in an internal-only CLI guarded by SSO and audited logging ⇒ risk_score 22, severity "Low", reason "internal CLI".
|
||||
- Reported secret leak found in sample dev config with rotate-on-startup hook ⇒ risk_score 12, severity "Informational", reason "sample config only".
|
||||
|
||||
# Available tools
|
||||
- READ: respond with `READ: <relative path>#Lstart-Lend` (range optional) to inspect specific source code.
|
||||
- SEARCH: respond with `SEARCH: literal:<term>` or `SEARCH: regex:<pattern>` to run ripgrep over the repository root (returns colored matches with line numbers).
|
||||
- GREP_FILES: respond with `GREP_FILES: {"pattern":"needle","include":"*.rs","path":"subdir","limit":200}` to list files whose contents match, ordered by modification time.
|
||||
- Issue at most one tool command per round and wait for the tool output before continuing. Reuse earlier tool outputs when possible.
|
||||
|
||||
Instructions:
|
||||
- Output severity **only** from ["High","Medium","Low","Informational"]. Map "critical"/"p0" to "High".
|
||||
- Produce `risk_score` between 0-100 (higher means greater customer impact) and use the full range for comparability.
|
||||
- Review the repository summary, spec excerpt, blame metadata, and file locations before requesting anything new; reuse existing specs or context attachments when possible.
|
||||
- If you still lack certainty, request concrete follow-up (e.g., repo_search, read_file, git blame) in the reason and cite the spec section you need.
|
||||
- Reference concrete evidence (spec section, tool name, log line) in the reason when you confirm mitigations or reclassify a finding.
|
||||
- Prefer reusing existing tool outputs and cached specs before launching new expensive calls; only request fresh tooling when the supplied artifacts truly lack the needed context.
|
||||
- Down-rank issues when mitigations or limited blast radius materially reduce customer risk, even if the initial triage labeled them "High".
|
||||
- Upgrade issues when exploitability or exposure was understated, or when multiple components amplify the blast radius.
|
||||
- Respond with one JSON object per finding, **in the same order**, formatted exactly as:
|
||||
{{"id": <number>, "risk_score": <0-100>, "severity": "<High|Medium|Low|Informational>", "reason": "<≤12 words>"}}
|
||||
|
||||
Findings:
|
||||
{findings}
|
||||
"#;
|
||||
|
||||
// File triage prompts
|
||||
pub(crate) const FILE_TRIAGE_SYSTEM_PROMPT: &str = "You are an application security engineer triaging source files to decide which ones warrant deep security review.\nFocus on entry points, authentication and authorization, network or process interactions, secrets handling, and other security-sensitive functionality.\nWhen uncertain, err on the side of including a file for further analysis.";
|
||||
pub(crate) const FILE_TRIAGE_PROMPT_TEMPLATE: &str = "You will receive JSON objects describing candidate files from a repository. For each object, output a single JSON line with the same `id`, a boolean `include`, and a short `reason`.\n- Use include=true for files that likely influence production behaviour, handle user input, touch the network/filesystem, perform authentication/authorization, execute commands, or otherwise impact security.\n- Use include=false for files that are clearly documentation, tests, generated artefacts, or otherwise irrelevant to security review.\n\nReply with one JSON object per line in this exact form:\n{\"id\": <number>, \"include\": true|false, \"reason\": \"...\"}\n\nFiles:\n{files}";
|
||||
@@ -322,6 +322,10 @@
|
||||
const rawHtml = usedMarked ? marked.parse(text) : basicMarkdown(text);
|
||||
contentEl.innerHTML = rawHtml;
|
||||
|
||||
// Fix up any ordered lists that were split by intervening blocks
|
||||
// (e.g., fenced code or diagrams) and ended up as plain paragraphs.
|
||||
try { fixLooseOrderedLists(); } catch {}
|
||||
|
||||
const headings = Array.from(contentEl.querySelectorAll('h1, h2, h3, h4, h5, h6'));
|
||||
const used = new Set();
|
||||
headings.forEach(h => {
|
||||
@@ -376,6 +380,70 @@
|
||||
enhanceBugTicketUI();
|
||||
}
|
||||
|
||||
// Convert top-level paragraphs that begin with a numeric marker (e.g.,
|
||||
// "2. Title") into an ordered list, absorbing following siblings until the
|
||||
// next numbered paragraph or a heading. This helps when the Markdown parser
|
||||
// fails to keep a single <ol> across interspersed blocks.
|
||||
function fixLooseOrderedLists() {
|
||||
const container = contentEl;
|
||||
let currentOl = null;
|
||||
let currentLi = null;
|
||||
|
||||
function closeList() {
|
||||
currentLi = null;
|
||||
currentOl = null;
|
||||
}
|
||||
|
||||
function startList(startAt, beforeNode) {
|
||||
currentOl = document.createElement('ol');
|
||||
if (startAt && startAt !== 1) currentOl.setAttribute('start', String(startAt));
|
||||
container.insertBefore(currentOl, beforeNode);
|
||||
}
|
||||
|
||||
// Iterate over a snapshot of children; nodes may move during the loop
|
||||
let i = 0;
|
||||
while (i < container.childNodes.length) {
|
||||
const node = container.childNodes[i];
|
||||
if (!node) { i += 1; continue; }
|
||||
|
||||
const isHeading = node.nodeType === 1 && /^H[1-6]$/.test(node.nodeName);
|
||||
if (isHeading && currentOl) { closeList(); i += 1; continue; }
|
||||
|
||||
let markerMatch = null;
|
||||
if (node.nodeType === 1 && node.nodeName === 'P') {
|
||||
const txt = (node.textContent || '').trim();
|
||||
markerMatch = txt.match(/^(\d+)\.\s+(.*)$/);
|
||||
}
|
||||
|
||||
if (markerMatch) {
|
||||
const start = parseInt(markerMatch[1], 10) || 1;
|
||||
if (!currentOl) startList(start, node);
|
||||
else if (!currentLi && start && start !== 1) currentOl.setAttribute('start', String(start));
|
||||
|
||||
// Begin a new list item and move paragraph contents into it
|
||||
currentLi = document.createElement('li');
|
||||
const html = (node.innerHTML || '').replace(/^\s*\d+\.\s+/, '');
|
||||
currentLi.innerHTML = html;
|
||||
currentOl.appendChild(currentLi);
|
||||
// Remove marker paragraph from the container
|
||||
container.removeChild(node);
|
||||
// Do not advance index; the next sibling slid into this index
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentOl) {
|
||||
// Absorb non-heading siblings (paragraphs, code blocks, diagrams, lists)
|
||||
// into the current list item until next numbered paragraph or heading.
|
||||
if (!currentLi) { closeList(); i += 1; continue; }
|
||||
currentLi.appendChild(node);
|
||||
// Node moved from container; do not increment i
|
||||
continue;
|
||||
}
|
||||
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// no cleanup — we rely on correct Markdown
|
||||
|
||||
function applyInlineCodeColor() {
|
||||
|
||||
@@ -1004,8 +1004,13 @@ fn render_bug_sections(snapshots: &[BugSnapshot]) -> String {
|
||||
continue;
|
||||
}
|
||||
let mut composed = String::new();
|
||||
composed.push_str(&format!("<a id=\"bug-{}\"></a>\n", snapshot.bug.summary_id));
|
||||
composed.push_str(base);
|
||||
let anchor_snippet = format!("<a id=\"bug-{}\"", snapshot.bug.summary_id);
|
||||
if base.contains(&anchor_snippet) {
|
||||
composed.push_str(base);
|
||||
} else {
|
||||
composed.push_str(&format!("<a id=\"bug-{}\"></a>\n", snapshot.bug.summary_id));
|
||||
composed.push_str(base);
|
||||
}
|
||||
if !matches!(snapshot.bug.validation.status, BugValidationStatus::Pending) {
|
||||
composed.push_str("\n\n#### Validation\n");
|
||||
let status_label = validation_status_label(&snapshot.bug.validation);
|
||||
@@ -1577,6 +1582,12 @@ pub(crate) async fn run_security_review(
|
||||
summary.markdown = updated.clone();
|
||||
replacements.insert(summary.id, updated);
|
||||
}
|
||||
if let Some(updated) =
|
||||
rewrite_bug_markdown_heading_id(summary.markdown.as_str(), summary.id)
|
||||
{
|
||||
summary.markdown = updated.clone();
|
||||
replacements.insert(summary.id, updated);
|
||||
}
|
||||
}
|
||||
if !replacements.is_empty() {
|
||||
for detail in all_details.iter_mut() {
|
||||
@@ -3986,6 +3997,12 @@ async fn analyze_files_individually(
|
||||
summary.markdown = updated.clone();
|
||||
replacements.insert(summary.id, updated);
|
||||
}
|
||||
if let Some(updated) =
|
||||
rewrite_bug_markdown_heading_id(summary.markdown.as_str(), summary.id)
|
||||
{
|
||||
summary.markdown = updated.clone();
|
||||
replacements.insert(summary.id, updated);
|
||||
}
|
||||
}
|
||||
if !replacements.is_empty() {
|
||||
for detail in bug_details.iter_mut() {
|
||||
@@ -4070,6 +4087,12 @@ async fn analyze_files_individually(
|
||||
summary.markdown = updated.clone();
|
||||
replacements.insert(summary.id, updated);
|
||||
}
|
||||
if let Some(updated) =
|
||||
rewrite_bug_markdown_heading_id(summary.markdown.as_str(), summary.id)
|
||||
{
|
||||
summary.markdown = updated.clone();
|
||||
replacements.insert(summary.id, updated);
|
||||
}
|
||||
}
|
||||
if !replacements.is_empty() {
|
||||
for detail in bug_details.iter_mut() {
|
||||
@@ -4947,6 +4970,38 @@ fn rewrite_bug_markdown_severity(markdown: &str, severity: &str) -> Option<Strin
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure bug detail heading includes the canonical summary ID and not a model-provided index
|
||||
fn rewrite_bug_markdown_heading_id(markdown: &str, summary_id: usize) -> Option<String> {
|
||||
if markdown.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
let mut changed = false;
|
||||
let mut updated_first_heading = false;
|
||||
for line in markdown.lines() {
|
||||
if !updated_first_heading {
|
||||
let trimmed = line.trim_start();
|
||||
if let Some(rest) = trimmed.strip_prefix("### ") {
|
||||
// Drop any leading bracketed id like "[12] " from the heading text
|
||||
let clean = rest
|
||||
.trim_start()
|
||||
.trim_start_matches('[')
|
||||
.trim_start_matches(|c: char| c.is_ascii_digit())
|
||||
.trim_start_matches(']')
|
||||
.trim_start();
|
||||
// Prepend an explicit anchor for stable linking
|
||||
out.push(format!("<a id=\"bug-{summary_id}\"></a>"));
|
||||
out.push(format!("### [{summary_id}] {clean}"));
|
||||
changed = true;
|
||||
updated_first_heading = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(line.to_string());
|
||||
}
|
||||
if changed { Some(out.join("\n")) } else { None }
|
||||
}
|
||||
|
||||
fn make_bug_summary_table(bugs: &[BugSummary]) -> Option<String> {
|
||||
if bugs.is_empty() {
|
||||
return None;
|
||||
@@ -4967,7 +5022,16 @@ fn make_bug_summary_table(bugs: &[BugSummary]) -> Option<String> {
|
||||
for (display_idx, bug) in ordered.iter().enumerate() {
|
||||
let id = display_idx + 1;
|
||||
let anchor_id = bug.id;
|
||||
let raw_title = sanitize_table_field(&bug.title);
|
||||
let mut raw_title = sanitize_table_field(&bug.title);
|
||||
// Strip any leading bracketed numeric id from titles (e.g., "[5] Title")
|
||||
if let Some(stripped) = raw_title
|
||||
.trim_start()
|
||||
.strip_prefix('[')
|
||||
.and_then(|s| s.split_once(']'))
|
||||
.map(|(_, rest)| rest.trim_start())
|
||||
{
|
||||
raw_title = stripped.to_string();
|
||||
}
|
||||
let link_label = if raw_title == "-" {
|
||||
format!("Bug {anchor_id}")
|
||||
} else {
|
||||
@@ -5015,7 +5079,16 @@ fn make_bug_summary_table_from_bugs(bugs: &[SecurityReviewBug]) -> Option<String
|
||||
for (display_idx, bug) in ordered.iter().enumerate() {
|
||||
let id = display_idx + 1;
|
||||
let anchor_id = bug.summary_id;
|
||||
let raw_title = sanitize_table_field(&bug.title);
|
||||
let mut raw_title = sanitize_table_field(&bug.title);
|
||||
// Strip any leading bracketed numeric id from titles (e.g., "[5] Title")
|
||||
if let Some(stripped) = raw_title
|
||||
.trim_start()
|
||||
.strip_prefix('[')
|
||||
.and_then(|s| s.split_once(']'))
|
||||
.map(|(_, rest)| rest.trim_start())
|
||||
{
|
||||
raw_title = stripped.to_string();
|
||||
}
|
||||
let link_label = if raw_title == "-" {
|
||||
format!("Bug {anchor_id}")
|
||||
} else {
|
||||
|
||||
@@ -86,8 +86,8 @@ impl StatusIndicatorWidget {
|
||||
// (up to 3 lines per message) + optional ellipsis per truncated message + keybind + spacer line
|
||||
let inner_width = width.max(1) as usize;
|
||||
let mut total: u16 = 1; // status line
|
||||
total = total.saturating_add(self.thinking_lines.len() as u16);
|
||||
total = total.saturating_add(self.tool_calls.len() as u16);
|
||||
total = total.saturating_add(self.thinking_lines.len().saturating_sub(1) as u16);
|
||||
total = total.saturating_add(self.tool_calls.len().saturating_sub(1) as u16);
|
||||
if !self.queued_messages.is_empty() {
|
||||
total = total.saturating_add(1); // blank line between supplemental and queued messages
|
||||
}
|
||||
@@ -198,30 +198,53 @@ impl WidgetRef for StatusIndicatorWidget {
|
||||
let pretty_elapsed = fmt_elapsed_compact(elapsed_duration.as_secs());
|
||||
|
||||
// Plain rendering: no borders or padding so the live cell is visually indistinguishable from terminal scrollback.
|
||||
let mut spans = Vec::with_capacity(5);
|
||||
let latest_thinking = self.thinking_lines.last().map(String::as_str);
|
||||
let latest_tool_call = self.tool_calls.last().map(String::as_str);
|
||||
|
||||
let mut spans = Vec::with_capacity(9);
|
||||
spans.push(spinner(Some(self.last_resume_at)));
|
||||
spans.push(" ".into());
|
||||
spans.extend(shimmer_spans(&self.header));
|
||||
if let Some(progress) = self.progress {
|
||||
let pct = (progress.clamp(0.0, 1.0) * 100.0).round();
|
||||
spans.push(" ".into());
|
||||
spans.push(format!("{pct:.0}%").dim());
|
||||
}
|
||||
if let Some(thinking) = latest_thinking {
|
||||
spans.push(" - ".into());
|
||||
spans.push(thinking.to_string().magenta());
|
||||
}
|
||||
if let Some(tool) = latest_tool_call {
|
||||
spans.push(" - ".into());
|
||||
spans.push(tool.to_string().cyan());
|
||||
}
|
||||
spans.extend(vec![
|
||||
" ".into(),
|
||||
format!("({pretty_elapsed} • ").dim(),
|
||||
key_hint::plain(KeyCode::Esc).into(),
|
||||
" to interrupt)".dim(),
|
||||
]);
|
||||
if let Some(progress) = self.progress {
|
||||
let pct = (progress.clamp(0.0, 1.0) * 100.0).round();
|
||||
spans.push(" ".into());
|
||||
spans.push(format!("{pct:.0}%").dim());
|
||||
}
|
||||
|
||||
// Build lines: status, then queued messages, then spacer.
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
lines.push(Line::from(spans));
|
||||
for thinking in &self.thinking_lines {
|
||||
lines.push(vec![" ↺ ".magenta(), thinking.clone().magenta()].into());
|
||||
let extra_thinking = self
|
||||
.thinking_lines
|
||||
.len()
|
||||
.saturating_sub(usize::from(latest_thinking.is_some()));
|
||||
if extra_thinking > 0 {
|
||||
for thinking in self.thinking_lines.iter().take(extra_thinking) {
|
||||
lines.push(vec![" ↺ ".magenta(), thinking.clone().magenta()].into());
|
||||
}
|
||||
}
|
||||
for call in &self.tool_calls {
|
||||
lines.push(vec![" ↳ ".cyan(), call.clone().cyan()].into());
|
||||
let extra_tool_calls = self
|
||||
.tool_calls
|
||||
.len()
|
||||
.saturating_sub(usize::from(latest_tool_call.is_some()));
|
||||
if extra_tool_calls > 0 {
|
||||
for call in self.tool_calls.iter().take(extra_tool_calls) {
|
||||
lines.push(vec![" ↳ ".cyan(), call.clone().cyan()].into());
|
||||
}
|
||||
}
|
||||
if !self.queued_messages.is_empty() {
|
||||
lines.push(Line::from(""));
|
||||
|
||||
Reference in New Issue
Block a user