diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs
index 22e1d4cdf0..dee6f62ffb 100644
--- a/codex-rs/core/src/lib.rs
+++ b/codex-rs/core/src/lib.rs
@@ -84,6 +84,11 @@ mod tasks;
mod user_notification;
pub mod util;
+/// Shared jittered exponential backoff used across Codex retries.
+pub fn default_retry_backoff(attempt: u64) -> std::time::Duration {
+ util::backoff(attempt)
+}
+
pub use apply_patch::CODEX_APPLY_PATCH_ARG1;
pub use command_safety::is_safe_command;
pub use safety::get_platform_sandbox;
diff --git a/codex-rs/tui/src/security_review.rs b/codex-rs/tui/src/security_review.rs
index 5e3151137c..5b10787c58 100644
--- a/codex-rs/tui/src/security_review.rs
+++ b/codex-rs/tui/src/security_review.rs
@@ -12,6 +12,8 @@ use crate::text_formatting::truncate_text;
use codex_core::CodexAuth;
use codex_core::ModelProviderInfo;
use codex_core::WireApi;
+use codex_core::config::GPT_5_CODEX_MEDIUM_MODEL;
+use codex_core::default_retry_backoff;
use codex_core::git_info::collect_git_info;
use codex_core::git_info::get_git_repo_root;
use futures::stream::FuturesUnordered;
@@ -26,10 +28,8 @@ use serde_json::Map;
use serde_json::Value;
use serde_json::json;
use std::cmp::Ordering as CmpOrdering;
-use std::collections::BTreeSet;
use std::collections::HashMap;
use std::collections::HashSet;
-use std::collections::VecDeque;
use std::fmt::Write;
use std::fs;
use std::future::Future;
@@ -48,6 +48,7 @@ use tokio::fs as tokio_fs;
use tokio::process::Command;
use tokio::sync::oneshot;
use tokio::task::spawn_blocking;
+use tokio::time::sleep;
use url::Url;
const VALIDATION_SUMMARY_GRAPHEMES: usize = 96;
@@ -69,6 +70,7 @@ const MAX_FILE_SEARCH_RESULTS: usize = 40;
const MAX_FILE_ANALYSIS_ATTEMPTS: usize = 2;
const COMMAND_PREVIEW_MAX_LINES: usize = 2;
const COMMAND_PREVIEW_MAX_GRAPHEMES: usize = 96;
+const MODEL_REASONING_LOG_MAX_GRAPHEMES: usize = 240;
const AUTO_SCOPE_MODEL: &str = "gpt-5-codex";
const SPEC_GENERATION_MODEL: &str = "gpt-5-codex";
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.";
@@ -118,11 +120,10 @@ Respond with a newline-separated list containing only the directory paths chosen
"#;
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.";
const AUTO_SCOPE_PROMPT_TEMPLATE: &str = r#"
-You are assisting with an application security review. Given the repository locations and a natural-language request, identify the minimal set of directories that should be in scope.
+You are assisting with an application security review. Identify the minimal set of directories that should be in scope.
-
-{locations}
-
+# Repository overview
+{repo_overview}
# Request
{user_query}
@@ -130,8 +131,15 @@ You are assisting with an application security review. Given the repository loca
# Request keywords
{keywords}
-# Keyword directory hints
-{keyword_matches}
+# Conversation history
+{conversation}
+
+# Available tools
+- SEARCH: respond with `SEARCH: ` to run ripgrep against file contents (prefix with `regex:` to use regular expressions).
+- SEARCH_FILES: respond with `SEARCH_FILES: ` to search for file or directory paths containing the pattern (prefix with `regex:` to treat the pattern as a regular expression).
+- READ: respond with `READ: #L-L` 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.
@@ -144,13 +152,10 @@ Return JSON Lines: each line must be a single JSON object with keys {"path", "in
"#;
const AUTO_SCOPE_JSON_GUARD: &str =
"Respond only with JSON Lines as described. Do not include markdown fences, prose, or lists.";
-const AUTO_SCOPE_MAX_DIR_DEPTH: usize = 3;
-const AUTO_SCOPE_MAX_DIRS: usize = 64;
-const AUTO_SCOPE_MAX_LANGUAGES: usize = 4;
-const AUTO_SCOPE_MAX_MARKERS: usize = 4;
-const AUTO_SCOPE_CHILD_PREVIEW: usize = 4;
+const AUTO_SCOPE_MAX_PATHS: usize = 20;
const AUTO_SCOPE_MAX_KEYWORDS: usize = 6;
-const AUTO_SCOPE_KEYWORD_MATCH_LIMIT: usize = 5;
+const AUTO_SCOPE_MAX_AGENT_STEPS: usize = 10;
+const AUTO_SCOPE_DEFAULT_READ_WINDOW: usize = 120;
const AUTO_SCOPE_KEYWORD_STOPWORDS: &[&str] = &[
"the", "and", "for", "with", "that", "this", "from", "into", "when", "where", "which", "while",
"using", "use", "need", "please", "should", "scope", "scoped", "bug", "bugs", "review",
@@ -223,6 +228,15 @@ pub(crate) enum SecurityReviewMode {
Bugs,
}
+fn normalize_reasoning(reasoning: String) -> Option {
+ let trimmed = reasoning.trim();
+ if trimmed.is_empty() {
+ None
+ } else {
+ Some(trimmed.to_string())
+ }
+}
+
impl SecurityReviewMode {
pub(crate) fn as_str(self) -> &'static str {
match self {
@@ -362,56 +376,359 @@ struct SpecGenerationOutcome {
logs: Vec,
}
-struct AutoScopeCandidate {
- display_path: String,
- depth: usize,
- languages: Vec,
- markers: Vec,
- child_preview: Vec,
- child_count: usize,
-}
-
-impl AutoScopeCandidate {
- fn summary(&self) -> Option {
- let mut parts: Vec = Vec::new();
-
- if !self.languages.is_empty() {
- let languages_summary = self.languages.join(", ");
- parts.push(format!("languages: {languages_summary}"));
- }
-
- if !self.markers.is_empty() {
- let marker_summary = self.markers.join(", ");
- parts.push(format!("markers: {marker_summary}"));
- }
-
- if !self.child_preview.is_empty() {
- let preview = self.child_preview.join(", ");
- if self.child_count > self.child_preview.len() {
- let remaining = self.child_count - self.child_preview.len();
- parts.push(format!("subs: {preview} (+{remaining} more)"));
- } else {
- parts.push(format!("subs: {preview}"));
- }
- } else if self.child_count > 0 {
- let child_count = self.child_count;
- parts.push(format!("subs: {child_count} dirs"));
- }
-
- if parts.is_empty() {
- None
- } else {
- Some(parts.join("; "))
- }
- }
-}
-
struct AutoScopeSelection {
abs_path: PathBuf,
display_path: String,
reason: Option,
}
+fn truncate_auto_scope_selections(
+ selections: &mut Vec,
+ logs: &mut Vec,
+) {
+ if selections.len() > AUTO_SCOPE_MAX_PATHS {
+ selections.truncate(AUTO_SCOPE_MAX_PATHS);
+ logs.push(format!(
+ "Auto scope limited to the first {AUTO_SCOPE_MAX_PATHS} directories returned by the model."
+ ));
+ }
+}
+
+fn summarize_top_level(repo_root: &Path) -> String {
+ let mut directories: Vec = Vec::new();
+ let mut files: Vec = Vec::new();
+
+ if let Ok(entries) = fs::read_dir(repo_root) {
+ for entry_result in entries.flatten().take(64) {
+ let name = entry_result.file_name().to_string_lossy().into_owned();
+ match entry_result.file_type() {
+ Ok(ft) if ft.is_dir() => {
+ if is_auto_scope_excluded_dir(&name) {
+ continue;
+ }
+ directories.push(format!("{name}/"));
+ }
+ Ok(ft) if ft.is_file() => files.push(name),
+ _ => {}
+ }
+ }
+ }
+
+ directories.sort();
+ files.sort();
+
+ let mut summary = Vec::new();
+ if directories.is_empty() && files.is_empty() {
+ summary.push("No top-level entries detected.".to_string());
+ } else {
+ if !directories.is_empty() {
+ summary.push(format!("Directories: {}", directories.join(", ")));
+ }
+ if !files.is_empty() {
+ summary.push(format!("Files: {}", files.join(", ")));
+ }
+ }
+
+ summary.join("\n")
+}
+
+enum AutoScopeToolCommand {
+ SearchContent {
+ pattern: String,
+ mode: SearchMode,
+ },
+ SearchFiles {
+ pattern: String,
+ mode: SearchMode,
+ },
+ ReadFile {
+ path: PathBuf,
+ start: Option,
+ end: Option,
+ },
+}
+
+fn extract_auto_scope_commands(response: &str) -> Vec {
+ let mut commands = Vec::new();
+ for line in response.lines() {
+ let trimmed = line.trim();
+ if let Some(rest) = trimmed.strip_prefix("SEARCH_FILES:") {
+ let (mode, term) = parse_search_term(rest.trim_matches('`'));
+ if !term.is_empty() {
+ commands.push(AutoScopeToolCommand::SearchFiles {
+ pattern: term.to_string(),
+ mode,
+ });
+ }
+ continue;
+ }
+ if let Some(rest) = trimmed.strip_prefix("SEARCH:") {
+ let (mode, term) = parse_search_term(rest.trim_matches('`'));
+ if !term.is_empty() {
+ commands.push(AutoScopeToolCommand::SearchContent {
+ pattern: term.to_string(),
+ mode,
+ });
+ }
+ continue;
+ }
+ if let Some(rest) = trimmed.strip_prefix("READ:") {
+ let spec = rest.trim();
+ if spec.is_empty() {
+ continue;
+ }
+ let (path_part, range_part) = spec.split_once('#').unwrap_or((spec, ""));
+ let relative = Path::new(path_part.trim()).to_path_buf();
+ if relative.as_os_str().is_empty() || relative.is_absolute() {
+ continue;
+ }
+
+ let mut start = None;
+ let mut end = None;
+ if let Some(range) = range_part.strip_prefix('L') {
+ let mut parts = range.split('-');
+ if let Some(start_str) = parts.next() {
+ if let Ok(value) = start_str.trim().parse::() {
+ if value > 0 {
+ start = Some(value);
+ }
+ }
+ }
+ if let Some(end_str) = parts.next() {
+ let clean_end = end_str.trim().trim_start_matches('L');
+ if let Ok(value) = clean_end.parse::() {
+ if value > 0 {
+ end = Some(value);
+ }
+ }
+ }
+ }
+
+ commands.push(AutoScopeToolCommand::ReadFile {
+ path: relative,
+ start,
+ end,
+ });
+ }
+ }
+ commands
+}
+
+async fn run_path_search(
+ repo_root: &Path,
+ pattern: &str,
+ mode: SearchMode,
+ metrics: &Arc,
+) -> SearchResult {
+ if pattern.is_empty() {
+ return SearchResult::NoMatches;
+ }
+
+ metrics.record_shell_call();
+
+ let output = Command::new("find")
+ .arg(".")
+ .arg("-maxdepth")
+ .arg("12")
+ .arg("-print")
+ .current_dir(repo_root)
+ .output()
+ .await;
+
+ let output = match output {
+ Ok(output) => output,
+ Err(err) => {
+ return SearchResult::Error(format!("failed to run find: {err}"));
+ }
+ };
+
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
+ return SearchResult::Error(format!("find exited with {}: {}", output.status, stderr));
+ }
+
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ let mut matches: HashSet = HashSet::new();
+
+ let matcher = match mode {
+ SearchMode::Literal => None,
+ SearchMode::Regex => match Regex::new(pattern) {
+ Ok(re) => Some(re),
+ Err(err) => return SearchResult::Error(format!("invalid regex `{pattern}`: {err}")),
+ },
+ };
+
+ for line in stdout.lines() {
+ let trimmed = line.trim();
+ if trimmed.is_empty() || trimmed == "." {
+ continue;
+ }
+ let relative = trimmed.trim_start_matches("./");
+ let matches_pattern = match (&matcher, mode) {
+ (Some(re), SearchMode::Regex) => re.is_match(relative),
+ (_, SearchMode::Literal) => relative.contains(pattern),
+ _ => false,
+ };
+ if matches_pattern {
+ matches.insert(relative.to_string());
+ let mut current = Path::new(relative);
+ while let Some(parent) = current.parent() {
+ if parent.as_os_str().is_empty() {
+ break;
+ }
+ if let Some(component) = current.file_name().and_then(|s| s.to_str()) {
+ let matched = match (&matcher, mode) {
+ (Some(re), SearchMode::Regex) => re.is_match(component),
+ (_, SearchMode::Literal) => component.contains(pattern),
+ _ => false,
+ };
+ if matched {
+ matches.insert(parent.to_string_lossy().to_string());
+ }
+ }
+ current = parent;
+ }
+ if matches.len() >= 400 {
+ break;
+ }
+ }
+ }
+
+ if matches.is_empty() {
+ SearchResult::NoMatches
+ } else {
+ let mut ordered: Vec = matches.into_iter().collect();
+ ordered.sort();
+ SearchResult::Matches(ordered.join("\n"))
+ }
+}
+
+async fn execute_auto_scope_search_content(
+ repo_root: &Path,
+ pattern: &str,
+ mode: SearchMode,
+ metrics: &Arc,
+) -> (String, String) {
+ match run_content_search(repo_root, pattern, mode, metrics).await {
+ SearchResult::Matches(output) => (
+ format!("Auto scope content search `{pattern}` returned results."),
+ output,
+ ),
+ SearchResult::NoMatches => (
+ format!("Auto scope content search `{pattern}` returned no matches."),
+ "No matches found.".to_string(),
+ ),
+ SearchResult::Error(err) => (
+ format!("Auto scope content search `{pattern}` failed: {err}"),
+ format!("Search error: {err}"),
+ ),
+ }
+}
+
+async fn execute_auto_scope_search_files(
+ repo_root: &Path,
+ pattern: &str,
+ mode: SearchMode,
+ metrics: &Arc,
+) -> (String, String) {
+ match run_path_search(repo_root, pattern, mode, metrics).await {
+ SearchResult::Matches(output) => (
+ format!("Auto scope path search `{pattern}` returned results."),
+ output,
+ ),
+ SearchResult::NoMatches => (
+ format!("Auto scope path search `{pattern}` returned no matches."),
+ "No matches found.".to_string(),
+ ),
+ SearchResult::Error(err) => (
+ format!("Auto scope path search `{pattern}` failed: {err}"),
+ format!("Search error: {err}"),
+ ),
+ }
+}
+
+async fn execute_auto_scope_read(
+ repo_root: &Path,
+ command_path: &Path,
+ start: Option,
+ end: Option,
+) -> Result {
+ let absolute = repo_root.join(command_path);
+ let canonical = absolute
+ .canonicalize()
+ .map_err(|err| format!("Failed to resolve path {}: {err}", command_path.display()))?;
+ if !canonical.starts_with(repo_root) {
+ return Err(format!(
+ "Path {} escapes the repository root.",
+ command_path.display()
+ ));
+ }
+ if !canonical.is_file() {
+ return Err(format!(
+ "Path {} is not a regular file.",
+ command_path.display()
+ ));
+ }
+
+ let content = tokio_fs::read_to_string(&canonical)
+ .await
+ .map_err(|err| format!("Failed to read {}: {err}", command_path.display()))?;
+
+ let relative = display_path_for(&canonical, repo_root);
+ let lines: Vec<&str> = content.lines().collect();
+ if lines.is_empty() {
+ return Ok(format!("{relative} is empty."));
+ }
+
+ let total_lines = lines.len();
+ let start_line = start.unwrap_or(1).max(1).min(total_lines);
+ let end_line = end
+ .unwrap_or(start_line.saturating_add(AUTO_SCOPE_DEFAULT_READ_WINDOW))
+ .max(start_line)
+ .min(total_lines);
+
+ let slice = &lines[start_line - 1..end_line];
+ let mut formatted = format!("{relative} (L{start_line}-L{end_line}):\n");
+ for (idx, line) in slice.iter().enumerate() {
+ let line_number = start_line + idx;
+ formatted.push_str(&format!("{line_number:>6}: {line}\n"));
+ if formatted.len() > 8000 {
+ formatted.push_str("... (truncated)\n");
+ break;
+ }
+ }
+ Ok(formatted.trim_end().to_string())
+}
+
+fn build_auto_scope_prompt(
+ repo_overview: &str,
+ user_query: &str,
+ keywords: &[String],
+ conversation: &str,
+) -> String {
+ let keywords_section = if keywords.is_empty() {
+ "None".to_string()
+ } else {
+ keywords
+ .iter()
+ .map(|keyword| format!("- {keyword}"))
+ .collect::>()
+ .join("\n")
+ };
+ let conversation_section = if conversation.trim().is_empty() {
+ "No prior exchanges.".to_string()
+ } else {
+ conversation.to_string()
+ };
+ let base = AUTO_SCOPE_PROMPT_TEMPLATE
+ .replace("{repo_overview}", repo_overview)
+ .replace("{user_query}", user_query.trim())
+ .replace("{keywords}", &keywords_section)
+ .replace("{conversation}", &conversation_section)
+ .replace("{read_window}", &AUTO_SCOPE_DEFAULT_READ_WINDOW.to_string());
+ format!("{base}\n{AUTO_SCOPE_JSON_GUARD}")
+}
+
struct ThreatModelOutcome {
markdown: String,
logs: Vec,
@@ -1174,7 +1491,7 @@ pub(crate) async fn run_security_review(
)
.await
{
- Ok(text) => text,
+ Ok(output) => output.text,
Err(err) => {
let message = format!("Failed to polish bug markdown: {err}");
record(message.clone());
@@ -1252,7 +1569,7 @@ pub(crate) async fn run_security_review(
)
.await
{
- Ok(text) => text,
+ Ok(output) => output.text,
Err(err) => {
let message = format!("Failed to polish final security report: {err}");
record(message.clone());
@@ -1622,7 +1939,7 @@ async fn triage_chunk(
.await;
let text = match response {
- Ok(text) => text,
+ Ok(output) => output.text,
Err(err) => {
let message = format!("File triage failed: {err}");
if let Some(tx) = progress_sender.as_ref() {
@@ -1910,176 +2227,6 @@ fn is_auto_scope_marker(name: &str) -> bool {
.any(|marker| marker.eq_ignore_ascii_case(name))
}
-fn collect_auto_scope_candidates(repo_root: &Path) -> Vec {
- let canonical_root = repo_root
- .canonicalize()
- .unwrap_or_else(|_| repo_root.to_path_buf());
- let mut queue: VecDeque<(PathBuf, usize)> = VecDeque::from([(canonical_root.clone(), 0_usize)]);
- let mut seen: HashSet = HashSet::new();
- seen.insert(canonical_root.clone());
-
- let mut root_candidate: Option = None;
- let mut candidates: Vec = Vec::new();
-
- while let Some((dir, depth)) = queue.pop_front() {
- let mut child_dirs: Vec = Vec::new();
- let mut child_names: Vec = Vec::new();
- let mut markers: BTreeSet = BTreeSet::new();
- let mut languages: BTreeSet = BTreeSet::new();
-
- if let Ok(entries) = fs::read_dir(&dir) {
- for entry_result in entries {
- let Ok(entry) = entry_result else {
- continue;
- };
- let Ok(file_type) = entry.file_type() else {
- continue;
- };
- if file_type.is_symlink() {
- continue;
- }
- let path = entry.path();
- let name_os = entry.file_name();
- let name = name_os.to_string_lossy().into_owned();
- if file_type.is_dir() {
- if is_auto_scope_excluded_dir(&name) {
- continue;
- }
- child_dirs.push(path.clone());
- child_names.push(name);
- } else if file_type.is_file() {
- if markers.len() < AUTO_SCOPE_MAX_MARKERS && is_auto_scope_marker(&name) {
- markers.insert(name.clone());
- }
- if languages.len() < AUTO_SCOPE_MAX_LANGUAGES
- && let Some(lang) = determine_language(&path)
- {
- languages.insert(lang.to_string());
- }
- }
- }
- }
-
- let child_count = child_dirs.len();
-
- child_names.sort();
- if child_names.len() > AUTO_SCOPE_CHILD_PREVIEW {
- child_names.truncate(AUTO_SCOPE_CHILD_PREVIEW);
- }
-
- let candidate = AutoScopeCandidate {
- display_path: display_path_for(&dir, &canonical_root),
- depth,
- languages: languages.into_iter().collect(),
- markers: markers.into_iter().collect(),
- child_preview: child_names,
- child_count,
- };
-
- if depth == 0 {
- root_candidate = Some(candidate);
- } else {
- candidates.push(candidate);
- if candidates.len() >= AUTO_SCOPE_MAX_DIRS {
- break;
- }
- }
-
- if depth >= AUTO_SCOPE_MAX_DIR_DEPTH {
- continue;
- }
-
- child_dirs.sort();
- for child in child_dirs {
- let canonical_child = match child.canonicalize() {
- Ok(path) => path,
- Err(_) => continue,
- };
- if !canonical_child.starts_with(&canonical_root) {
- continue;
- }
- if seen.insert(canonical_child.clone()) {
- queue.push_back((canonical_child, depth + 1));
- }
- }
- }
-
- let mut ordered: Vec = Vec::new();
- if let Some(root) = root_candidate {
- ordered.push(root);
- } else {
- ordered.push(AutoScopeCandidate {
- display_path: display_path_for(repo_root, repo_root),
- depth: 0,
- languages: Vec::new(),
- markers: Vec::new(),
- child_preview: Vec::new(),
- child_count: 0,
- });
- }
- ordered.extend(candidates);
- ordered
-}
-
-fn build_auto_scope_prompt(
- repo_root: &Path,
- candidates: &[AutoScopeCandidate],
- user_query: &str,
- keywords: &[String],
- keyword_matches: &[(String, Vec)],
-) -> String {
- let mut lines: Vec = Vec::new();
- if candidates.is_empty() {
- lines.push(display_path_for(repo_root, repo_root));
- } else {
- for candidate in candidates {
- let indent = " ".repeat(candidate.depth);
- let mut label = format!("{indent}{}", candidate.display_path);
- if let Some(summary) = candidate.summary() {
- label.push_str(" — ");
- label.push_str(&summary);
- }
- lines.push(label);
- }
- }
-
- let merged_locations = if lines.is_empty() {
- display_path_for(repo_root, repo_root)
- } else {
- lines.join("\n")
- };
- let keywords_section = if keywords.is_empty() {
- "None".to_string()
- } else {
- keywords
- .iter()
- .map(|keyword| format!("- {keyword}"))
- .collect::>()
- .join("\n")
- };
- let keyword_matches_section = if keyword_matches.is_empty() {
- "No directory matches were found automatically.".to_string()
- } else {
- keyword_matches
- .iter()
- .map(|(keyword, dirs)| {
- if dirs.is_empty() {
- format!("- {keyword}: no matches")
- } else {
- format!("- {keyword}: {}", dirs.join(", "))
- }
- })
- .collect::>()
- .join("\n")
- };
- let base = AUTO_SCOPE_PROMPT_TEMPLATE
- .replace("{locations}", &merged_locations)
- .replace("{user_query}", user_query.trim())
- .replace("{keywords}", &keywords_section)
- .replace("{keyword_matches}", &keyword_matches_section);
- format!("{base}\n{AUTO_SCOPE_JSON_GUARD}")
-}
-
fn normalize_keyword_candidate(candidate: &str) -> Option<(String, String)> {
let trimmed = candidate
.trim()
@@ -2144,10 +2291,7 @@ fn parse_keyword_response(raw: &str) -> Vec {
let mut collected: Vec = Vec::new();
for line in trimmed.lines() {
- let stripped = line
- .trim()
- .trim_start_matches(['-', '*', '•'])
- .trim();
+ let stripped = line.trim().trim_start_matches(['-', '*', '•']).trim();
if stripped.is_empty() || stripped.eq_ignore_ascii_case("none") {
continue;
}
@@ -2227,113 +2371,20 @@ async fn expand_auto_scope_keywords(
.await
.map_err(|err| format!("keyword expansion model call failed: {err}"))?;
- let raw_candidates = parse_keyword_response(&response);
+ let raw_candidates = parse_keyword_response(&response.text);
let mut keywords: Vec = Vec::new();
let mut seen: HashSet = HashSet::new();
for candidate in raw_candidates {
if let Some((display, key)) = normalize_keyword_candidate(&candidate)
- && seen.insert(key) {
- keywords.push(display);
- if keywords.len() >= AUTO_SCOPE_MAX_KEYWORDS {
- break;
- }
- }
- }
- Ok(keywords)
-}
-
-fn parse_search_output_paths(output: &str) -> Vec {
- output
- .lines()
- .filter_map(|line| {
- let trimmed = line.trim();
- if trimmed.is_empty() || trimmed.ends_with("(truncated)") {
- return None;
- }
- let without_prefix = trimmed.strip_prefix("- ").unwrap_or(trimmed);
- let cleaned = without_prefix.trim();
- if cleaned.is_empty() {
- None
- } else {
- Some(cleaned.to_string())
- }
- })
- .collect()
-}
-
-async fn collect_keyword_directory_matches(
- repo_root: &Path,
- keywords: &[String],
- metrics: Arc,
-) -> (Vec<(String, Vec)>, Vec) {
- if keywords.is_empty() {
- return (Vec::new(), Vec::new());
- }
- let mut results: Vec<(String, Vec)> = Vec::new();
- let mut logs: Vec = Vec::new();
-
- for keyword in keywords {
- let search_result = run_file_search(repo_root, keyword, SearchMode::Regex, &metrics).await;
- match search_result {
- SearchResult::Matches(output) => {
- let mut directories: Vec = Vec::new();
- let mut seen_dirs: HashSet = HashSet::new();
- for path in parse_search_output_paths(&output) {
- let absolute = repo_root.join(&path);
- let canonical = absolute.canonicalize().unwrap_or(absolute.clone());
- if !canonical.starts_with(repo_root) {
- continue;
- }
- let directory = if canonical.is_dir() {
- canonical
- } else {
- canonical
- .parent()
- .map(Path::to_path_buf)
- .unwrap_or_else(|| repo_root.to_path_buf())
- };
- if !directory.starts_with(repo_root) {
- continue;
- }
- let display = display_path_for(&directory, repo_root);
- if display == "." {
- continue;
- }
- if seen_dirs.insert(display.clone()) {
- directories.push(display);
- if directories.len() >= AUTO_SCOPE_KEYWORD_MATCH_LIMIT {
- break;
- }
- }
- }
- if directories.is_empty() {
- logs.push(format!(
- "Auto scope keyword `{keyword}` matched files but no directories were recorded."
- ));
- } else {
- logs.push(format!(
- "Auto scope keyword `{keyword}` matched directories: {}",
- directories.join(", ")
- ));
- }
- results.push((keyword.clone(), directories));
- }
- SearchResult::NoMatches => {
- logs.push(format!(
- "Auto scope keyword `{keyword}` produced no matches."
- ));
- results.push((keyword.clone(), Vec::new()));
- }
- SearchResult::Error(err) => {
- logs.push(format!(
- "Auto scope keyword search for `{keyword}` failed: {err}"
- ));
- results.push((keyword.clone(), Vec::new()));
+ && seen.insert(key)
+ {
+ keywords.push(display);
+ if keywords.len() >= AUTO_SCOPE_MAX_KEYWORDS {
+ break;
}
}
}
-
- (results, logs)
+ Ok(keywords)
}
#[derive(Debug, Clone)]
@@ -2528,7 +2579,6 @@ async fn auto_detect_scope(
metrics: Arc,
) -> Result<(Vec, Vec), SecurityReviewFailure> {
let mut logs: Vec = Vec::new();
- let candidates = collect_auto_scope_candidates(repo_root);
let mut keywords =
match expand_auto_scope_keywords(client, provider, auth, user_query, metrics.clone()).await
@@ -2569,82 +2619,165 @@ async fn auto_detect_scope(
}
}
- let (keyword_matches, keyword_logs) =
- collect_keyword_directory_matches(repo_root, &keywords, metrics.clone()).await;
- logs.extend(keyword_logs);
+ let repo_overview = summarize_top_level(repo_root);
+ let mut conversation: Vec = Vec::new();
+ let mut tool_rounds = 0usize;
- let prompt = build_auto_scope_prompt(
- repo_root,
- &candidates,
- user_query,
- &keywords,
- &keyword_matches,
- );
- let response = match call_model(
- client,
- provider,
- auth,
- model,
- AUTO_SCOPE_SYSTEM_PROMPT,
- &prompt,
- metrics,
- 0.0,
- )
- .await
- {
- Ok(text) => text,
- Err(err) => {
- logs.push(format!("Auto scope model request failed: {err}"));
+ loop {
+ if tool_rounds >= AUTO_SCOPE_MAX_AGENT_STEPS {
return Err(SecurityReviewFailure {
- message: format!("Failed to auto-detect scope: {err}"),
+ message: format!(
+ "Auto scope exceeded the maximum number ({AUTO_SCOPE_MAX_AGENT_STEPS}) of tool interactions."
+ ),
logs,
});
}
- };
- let parse_result = parse_auto_scope_response(&response);
- match parse_result {
- AutoScopeParseResult::All => {
- let canonical = repo_root
- .canonicalize()
- .unwrap_or_else(|_| repo_root.to_path_buf());
- logs.push("Auto scope model requested the entire repository.".to_string());
- Ok((
- vec![AutoScopeSelection {
- display_path: display_path_for(&canonical, repo_root),
- abs_path: canonical,
- reason: Some("LLM requested full repository".to_string()),
- }],
- logs,
- ))
- }
- AutoScopeParseResult::Selections(raw_selections) => {
- let mut seen: HashSet = HashSet::new();
- let mut selections: Vec = Vec::new();
-
- for raw in raw_selections {
- let mut candidate = PathBuf::from(&raw.path);
- if !candidate.is_absolute() {
- candidate = repo_root.join(&candidate);
- }
- let canonical = match candidate.canonicalize() {
- Ok(path) => path,
- Err(_) => continue,
- };
- if !canonical.starts_with(repo_root) || !canonical.is_dir() {
- continue;
- }
- if !seen.insert(canonical.clone()) {
- continue;
- }
- selections.push(AutoScopeSelection {
- display_path: display_path_for(&canonical, repo_root),
- abs_path: canonical,
- reason: raw.reason,
+ let conversation_text = conversation.join("\n\n");
+ let prompt =
+ build_auto_scope_prompt(&repo_overview, user_query, &keywords, &conversation_text);
+ let response = match call_model(
+ client,
+ provider,
+ auth,
+ model,
+ AUTO_SCOPE_SYSTEM_PROMPT,
+ &prompt,
+ metrics.clone(),
+ 0.0,
+ )
+ .await
+ {
+ Ok(output) => output.text,
+ Err(err) => {
+ logs.push(format!("Auto scope model request failed: {err}"));
+ return Err(SecurityReviewFailure {
+ message: format!("Failed to auto-detect scope: {err}"),
+ logs,
});
}
+ };
- Ok((selections, logs))
+ let assistant_reply = response.trim();
+ if assistant_reply.is_empty() {
+ return Err(SecurityReviewFailure {
+ message: "Auto scope model returned an empty response.".to_string(),
+ logs,
+ });
+ }
+ conversation.push(format!("Assistant:\n{assistant_reply}"));
+
+ let commands = extract_auto_scope_commands(assistant_reply);
+ if !commands.is_empty() {
+ tool_rounds += 1;
+ for command in commands {
+ match command {
+ AutoScopeToolCommand::SearchContent { pattern, mode } => {
+ let (log_line, output) =
+ execute_auto_scope_search_content(repo_root, &pattern, mode, &metrics)
+ .await;
+ logs.push(log_line);
+ conversation.push(format!("Tool SEARCH `{pattern}`:\n{output}"));
+ }
+ AutoScopeToolCommand::SearchFiles { pattern, mode } => {
+ let (log_line, output) =
+ execute_auto_scope_search_files(repo_root, &pattern, mode, &metrics)
+ .await;
+ logs.push(log_line);
+ conversation.push(format!("Tool SEARCH_FILES `{pattern}`:\n{output}"));
+ }
+ AutoScopeToolCommand::ReadFile { path, start, end } => {
+ match execute_auto_scope_read(repo_root, &path, start, end).await {
+ Ok(output) => {
+ logs.push(format!(
+ "Auto scope read `{}` returned content.",
+ path.display()
+ ));
+ conversation.push(format!(
+ "Tool READ `{}`:\n{}",
+ path.display(),
+ output
+ ));
+ }
+ Err(err) => {
+ logs.push(err.clone());
+ conversation.push(format!(
+ "Tool READ `{}` error: {}",
+ path.display(),
+ err
+ ));
+ }
+ }
+ }
+ }
+ }
+ continue;
+ }
+
+ let parse_result = parse_auto_scope_response(assistant_reply);
+ match parse_result {
+ AutoScopeParseResult::All => {
+ let canonical = repo_root
+ .canonicalize()
+ .unwrap_or_else(|_| repo_root.to_path_buf());
+ logs.push("Auto scope model requested the entire repository.".to_string());
+ return Ok((
+ vec![AutoScopeSelection {
+ display_path: display_path_for(&canonical, repo_root),
+ abs_path: canonical,
+ reason: Some("LLM requested full repository".to_string()),
+ }],
+ logs,
+ ));
+ }
+ AutoScopeParseResult::Selections(raw_selections) => {
+ if raw_selections.is_empty() {
+ logs.push(
+ "Auto scope model returned no included directories in the final response."
+ .to_string(),
+ );
+ return Err(SecurityReviewFailure {
+ message: "Auto scope returned no directories.".to_string(),
+ logs,
+ });
+ }
+
+ let mut seen: HashSet = HashSet::new();
+ let mut selections: Vec = Vec::new();
+
+ for raw in raw_selections {
+ let mut candidate = PathBuf::from(&raw.path);
+ if !candidate.is_absolute() {
+ candidate = repo_root.join(&candidate);
+ }
+ let canonical = match candidate.canonicalize() {
+ Ok(path) => path,
+ Err(_) => continue,
+ };
+ if !canonical.starts_with(repo_root) || !canonical.is_dir() {
+ continue;
+ }
+ if !seen.insert(canonical.clone()) {
+ continue;
+ }
+ selections.push(AutoScopeSelection {
+ display_path: display_path_for(&canonical, repo_root),
+ abs_path: canonical,
+ reason: raw.reason,
+ });
+ }
+
+ if selections.is_empty() {
+ return Err(SecurityReviewFailure {
+ message: "Auto scope returned no directories.".to_string(),
+ logs,
+ });
+ }
+
+ truncate_auto_scope_selections(&mut selections, &mut logs);
+
+ return Ok((selections, logs));
+ }
}
}
}
@@ -2764,7 +2897,7 @@ Return ALL to keep every directory.",
})?;
let mut selected_indices: Vec = Vec::new();
- for raw_line in response.lines() {
+ for raw_line in response.text.lines() {
let trimmed = raw_line.trim().trim_matches('`');
if trimmed.is_empty() {
continue;
@@ -2875,7 +3008,7 @@ async fn generate_spec_for_location(
message: format!("Specification generation failed for {location_label}: {err}"),
logs: Vec::new(),
})?;
- let sanitized = fix_mermaid_blocks(&response);
+ let sanitized = fix_mermaid_blocks(&response.text);
let slug = slugify_label(&location_label);
let file_path = raw_dir.join(format!("{slug}.md"));
@@ -2940,7 +3073,7 @@ async fn generate_threat_model(
logs.push(start_message);
let prompt = build_threat_model_prompt(repository_summary, spec);
- let mut response = call_model(
+ let response_output = call_model(
client,
provider,
auth,
@@ -2967,7 +3100,8 @@ async fn generate_threat_model(
logs: failure_logs,
}
})?;
- let mut sanitized_response = fix_mermaid_blocks(&response);
+ let mut response_text = response_output.text;
+ let mut sanitized_response = fix_mermaid_blocks(&response_text);
sanitized_response = sort_threat_table(&sanitized_response).unwrap_or(sanitized_response);
if !threat_table_has_rows(&sanitized_response) {
@@ -2978,7 +3112,7 @@ async fn generate_threat_model(
logs.push(warn.to_string());
let retry_prompt = build_threat_model_retry_prompt(&prompt, &sanitized_response);
- response = call_model(
+ let response_output = call_model(
client,
provider,
auth,
@@ -3005,7 +3139,8 @@ async fn generate_threat_model(
logs: failure_logs,
}
})?;
- sanitized_response = fix_mermaid_blocks(&response);
+ response_text = response_output.text;
+ sanitized_response = fix_mermaid_blocks(&response_text);
sanitized_response = sort_threat_table(&sanitized_response).unwrap_or(sanitized_response);
if !threat_table_has_rows(&sanitized_response) {
@@ -3080,7 +3215,7 @@ async fn combine_spec_markdown(
)
.await
{
- Ok(text) => text,
+ Ok(output) => output.text,
Err(err) => {
return Err(SecurityReviewFailure {
message: format!("Failed to combine specifications: {err}"),
@@ -3109,7 +3244,7 @@ async fn combine_spec_markdown(
)
.await
{
- Ok(text) => text,
+ Ok(output) => output.text,
Err(err) => {
let message = format!("Failed to polish combined specification markdown: {err}");
if let Some(tx) = progress_sender.as_ref() {
@@ -3472,6 +3607,7 @@ async fn analyze_single_file(
) -> Result {
let mut logs = Vec::new();
let path_display = snippet.relative_path.display().to_string();
+ let search_root_display = repo_root.display().to_string();
let file_size = human_readable_bytes(snippet.bytes);
let prefix = format!("{}/{}", index + 1, total_files);
let mut prompt_adjustment_logs: HashSet = HashSet::new();
@@ -3549,8 +3685,8 @@ async fn analyze_single_file(
)
.await;
- let text = match response {
- Ok(text) => text,
+ let call_output = match response {
+ Ok(output) => output,
Err(err) => {
let attempt_number = analysis_attempt + 1;
let message = format!(
@@ -3585,6 +3721,22 @@ async fn analyze_single_file(
}
};
+ if let Some(reasoning) = call_output.reasoning.as_ref() {
+ for line in reasoning
+ .lines()
+ .map(str::trim)
+ .filter(|line| !line.is_empty())
+ {
+ let truncated = truncate_text(line, MODEL_REASONING_LOG_MAX_GRAPHEMES);
+ let reasoning_message = format!("Model reasoning: {truncated}");
+ if let Some(tx) = progress_sender.as_ref() {
+ tx.send(AppEvent::SecurityReviewLog(reasoning_message.clone()));
+ }
+ logs.push(reasoning_message);
+ }
+ }
+
+ let text = call_output.text;
let (cleaned_text, requested_terms) = parse_search_requests(&text);
let trimmed = cleaned_text.trim();
@@ -3645,8 +3797,9 @@ async fn analyze_single_file(
SearchRequest::Content { term, mode } => {
let display_term = summarize_search_term(&term, 80);
let mode_label = mode.as_str();
- let log_message =
- format!("Search `{display_term}` in content ({mode_label})");
+ let log_message = format!(
+ "Search `{display_term}` in content ({mode_label}) — path {search_root_display}"
+ );
if let Some(tx) = progress_sender.as_ref() {
tx.send(AppEvent::SecurityReviewLog(log_message.clone()));
}
@@ -3676,7 +3829,9 @@ async fn analyze_single_file(
));
}
SearchResult::NoMatches => {
- let miss = format!("No content matches found for `{display_term}`");
+ let miss = format!(
+ "No content matches found for `{display_term}` — path {search_root_display}"
+ );
if let Some(tx) = progress_sender.as_ref() {
tx.send(AppEvent::SecurityReviewLog(miss.clone()));
}
@@ -3684,7 +3839,7 @@ async fn analyze_single_file(
}
SearchResult::Error(err) => {
let error_message = format!(
- "Ripgrep content search for `{display_term}` failed: {err}"
+ "Ripgrep content search for `{display_term}` failed: {err} — path {search_root_display}"
);
if let Some(tx) = progress_sender.as_ref() {
tx.send(AppEvent::SecurityReviewLog(error_message.clone()));
@@ -3729,8 +3884,9 @@ async fn analyze_single_file(
SearchRequest::Files { term, mode } => {
let display_term = summarize_search_term(&term, 80);
let mode_label = mode.as_str();
- let log_message =
- format!("Search files for `{display_term}` ({mode_label})");
+ let log_message = format!(
+ "Search files for `{display_term}` ({mode_label}) — path {search_root_display}"
+ );
if let Some(tx) = progress_sender.as_ref() {
tx.send(AppEvent::SecurityReviewLog(log_message.clone()));
}
@@ -3759,7 +3915,9 @@ async fn analyze_single_file(
));
}
SearchResult::NoMatches => {
- let miss = format!("No files matched pattern `{display_term}`");
+ let miss = format!(
+ "No files matched pattern `{display_term}` — path {search_root_display}"
+ );
if let Some(tx) = progress_sender.as_ref() {
tx.send(AppEvent::SecurityReviewLog(miss.clone()));
}
@@ -3767,7 +3925,7 @@ async fn analyze_single_file(
}
SearchResult::Error(err) => {
let error_message = format!(
- "Ripgrep file search for `{display_term}` failed: {err}"
+ "Ripgrep file search for `{display_term}` failed: {err} — path {search_root_display}"
);
if let Some(tx) = progress_sender.as_ref() {
tx.send(AppEvent::SecurityReviewLog(error_message.clone()));
@@ -4545,7 +4703,7 @@ async fn rerank_bugs_by_risk(
for result in chunk_results {
match result {
Ok(output) => {
- for raw_line in output.lines() {
+ for raw_line in output.text.lines() {
let line = raw_line.trim();
if line.is_empty() {
continue;
@@ -5384,7 +5542,7 @@ fn build_bugs_user_prompt(
) -> BugPromptData {
let repository_section = format!("# Repository context\n{repository_summary}\n");
let code_and_task = format!(
- "\n# Code excerpts\n{code_context}\n\n# Task\nEvaluate the project for concrete, exploitable security vulnerabilities. Prefer precise, production-relevant issues to theoretical concerns.\n\nFollow these rules:\n- Read the code and provided context to understand intended behavior before judging safety.\n- Trace attacker-controlled inputs through the call graph to the ultimate sink. Highlight any sanitization or missing validation along the way.\n- Ignore unit tests, example scripts, or tooling unless they ship to production in this repo.\n- 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).\n- Quote code snippets and locations using GitHub-style ranges (e.g. `src/service.rs#L10-L24`). Include git blame details when you have them: ` L-L`.\n- Keep all output in markdown and avoid generic disclaimers.\n- If you need more repository context, request it explicitly:\n - Emit `SEARCH: ` to run ripgrep across the repository and append matching snippets (patterns are literal by default; prefix with `regex:` to use a regular expression).\n - Emit `SEARCH_FILES: ` to list files whose contents match, mirroring the `grep_files` tool's behavior.\n Only three unique search requests per file will be honored.\n\n# Output format\nFor each vulnerability, emit a markdown block:\n\n### \n- **File & Lines:** `#Lstart-Lend`\n- **Severity:** \n- **Impact:** \n- **Likelihood:** \n- **Description:** Detailed narrative with annotated code references explaining the bug.\n- **Snippet:** Fenced code block (specify language) showing only the relevant lines with inline comments or numbered markers that you reference in the description.\n- **Dataflow:** Describe sources, propagation, sanitization, and sinks using relative paths and `L-L` ranges.\n- **PoC:** Concrete steps or payload to reproduce (or `n/a` if infeasible).\n- **Recommendation:** Actionable remediation guidance.\n- **Verification Type:** JSON array subset of [\"network_api\", \"crash_poc\", \"web_browser\"].\n- TAXONOMY: {{\"vuln_class\": \"...\", \"cwe_ids\": [...], \"owasp_categories\": [...], \"vuln_tag\": \"...\"}}\n\nEnsure severity selections are justified by the described impact and likelihood."
+ "\n# Code excerpts\n{code_context}\n\n# Task\nEvaluate the project for concrete, exploitable security vulnerabilities. Prefer precise, production-relevant issues to theoretical concerns.\n\nFollow these rules:\n- Read this file in full and review the provided context to understand intended behavior before judging safety.\n- Use the search commands 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.\n- Trace attacker-controlled inputs through the call graph to the ultimate sink. Highlight any sanitization or missing validation along the way.\n- Ignore unit tests, example scripts, or tooling unless they ship to production in this repo.\n- 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).\n- Quote code snippets and locations using GitHub-style ranges (e.g. `src/service.rs#L10-L24`). Include git blame details when you have them: ` L-L`.\n- Keep all output in markdown and avoid generic disclaimers.\n- If you need more repository context, request it explicitly while staying within the provided scope:\n - Emit `SEARCH: ` to run ripgrep across the repository and append matching snippets (patterns are literal by default; prefix with `regex:` to use a regular expression).\n - Emit `SEARCH_FILES: ` to list files whose contents match, mirroring the `grep_files` tool's behavior.\n\n# Output format\nFor each vulnerability, emit a markdown block:\n\n### \n- **File & Lines:** `#Lstart-Lend`\n- **Severity:** \n- **Impact:** \n- **Likelihood:** \n- **Description:** Detailed narrative with annotated code references explaining the bug.\n- **Snippet:** Fenced code block (specify language) showing only the relevant lines with inline comments or numbered markers that you reference in the description.\n- **Dataflow:** Describe sources, propagation, sanitization, and sinks using relative paths and `L-L` ranges.\n- **PoC:** Concrete steps or payload to reproduce (or `n/a` if infeasible).\n- **Recommendation:** Actionable remediation guidance.\n- **Verification Type:** JSON array subset of [\"network_api\", \"crash_poc\", \"web_browser\"].\n- TAXONOMY: {{\"vuln_class\": \"...\", \"cwe_ids\": [...], \"owasp_categories\": [...], \"vuln_tag\": \"...\"}}\n\nEnsure severity selections are justified by the described impact and likelihood."
);
let base_len = repository_section.len() + code_and_task.len();
let mut prompt =
@@ -5891,6 +6049,12 @@ pub(crate) async fn verify_bugs(
Ok(BugVerificationOutcome { bugs, logs })
}
+#[derive(Debug, Clone)]
+struct ModelCallOutput {
+ text: String,
+ reasoning: Option,
+}
+
async fn call_model(
client: &Client,
provider: &ModelProviderInfo,
@@ -5900,8 +6064,55 @@ async fn call_model(
user_prompt: &str,
metrics: Arc,
temperature: f32,
-) -> Result {
- metrics.record_model_call();
+) -> Result {
+ let max_attempts = provider.request_max_retries();
+ let mut attempt_errors: Vec = Vec::new();
+
+ for attempt in 0..=max_attempts {
+ metrics.record_model_call();
+
+ match call_model_attempt(
+ client,
+ provider,
+ auth,
+ model,
+ system_prompt,
+ user_prompt,
+ temperature,
+ )
+ .await
+ {
+ Ok(output) => return Ok(output),
+ Err(err) => {
+ let sanitized = sanitize_model_error(&err);
+ attempt_errors.push(format!("attempt {}: {}", attempt + 1, sanitized));
+
+ if attempt == max_attempts {
+ let attempt_count = attempt + 1;
+ let plural = if attempt_count == 1 { "" } else { "s" };
+ let joined = attempt_errors.join("\n- ");
+ return Err(format!(
+ "Model request for {model} failed after {attempt_count} attempt{plural}. Details:\n- {joined}"
+ ));
+ }
+
+ sleep(default_retry_backoff(attempt + 1)).await;
+ }
+ }
+ }
+
+ unreachable!("call_model attempts should always return");
+}
+
+async fn call_model_attempt(
+ client: &Client,
+ provider: &ModelProviderInfo,
+ auth: &Option,
+ model: &str,
+ system_prompt: &str,
+ user_prompt: &str,
+ temperature: f32,
+) -> Result {
match provider.wire_api {
WireApi::Responses => {
let builder = provider
@@ -6009,8 +6220,18 @@ async fn call_model(
}
}
-fn parse_responses_stream_output(body: &str) -> Result {
+fn sanitize_model_error(error: &str) -> String {
+ let trimmed = error.trim();
+ if trimmed.is_empty() {
+ return "unknown error".to_string();
+ }
+
+ trimmed.split_whitespace().collect::>().join(" ")
+}
+
+fn parse_responses_stream_output(body: &str) -> Result {
let mut combined = String::new();
+ let mut reasoning = String::new();
let mut fallback: Option = None;
let mut failed_error: Option = None;
let mut last_parse_error: Option = None;
@@ -6029,6 +6250,7 @@ fn parse_responses_stream_output(body: &str) -> Result {
handle_responses_event(
&data_buffer,
&mut combined,
+ &mut reasoning,
&mut fallback,
&mut failed_error,
&mut last_parse_error,
@@ -6041,6 +6263,7 @@ fn parse_responses_stream_output(body: &str) -> Result {
handle_responses_event(
&data_buffer,
&mut combined,
+ &mut reasoning,
&mut fallback,
&mut failed_error,
&mut last_parse_error,
@@ -6052,7 +6275,10 @@ fn parse_responses_stream_output(body: &str) -> Result {
}
if !combined.trim().is_empty() {
- return Ok(combined.trim().to_string());
+ return Ok(ModelCallOutput {
+ text: combined.trim().to_string(),
+ reasoning: normalize_reasoning(reasoning),
+ });
}
if let Some(value) = fallback {
@@ -6069,6 +6295,7 @@ fn parse_responses_stream_output(body: &str) -> Result {
fn handle_responses_event(
data: &str,
combined: &mut String,
+ reasoning: &mut String,
fallback: &mut Option,
failed_error: &mut Option,
last_parse_error: &mut Option,
@@ -6090,6 +6317,30 @@ fn handle_responses_event(
combined.push_str(delta);
}
}
+ "response.reasoning_text.delta" | "response.reasoning_summary_text.delta" => {
+ if let Some(delta) = event.get("delta").and_then(|v| v.as_str()) {
+ reasoning.push_str(delta);
+ } else if let Some(delta_obj) = event.get("delta").and_then(|v| v.as_object()) {
+ if let Some(text) = delta_obj
+ .get("text")
+ .and_then(|v| v.as_str())
+ .filter(|t| !t.is_empty())
+ {
+ reasoning.push_str(text);
+ }
+ if let Some(content) = delta_obj.get("content").and_then(|v| v.as_array()) {
+ for block in content {
+ if let Some(text) = block
+ .get("text")
+ .and_then(|v| v.as_str())
+ .filter(|t| !t.is_empty())
+ {
+ reasoning.push_str(text);
+ }
+ }
+ }
+ }
+ }
"response.completed" => {
if let Some(resp) = event.get("response") {
*fallback = Some(resp.clone());
@@ -6124,9 +6375,10 @@ fn handle_responses_event(
}
}
-fn parse_responses_output(value: serde_json::Value) -> Result {
+fn parse_responses_output(value: serde_json::Value) -> Result {
if let Some(array) = value.get("output").and_then(|v| v.as_array()) {
let mut combined = String::new();
+ let mut reasoning = String::new();
for item in array {
match item.get("type").and_then(|t| t.as_str()) {
Some("output_text") | Some("text") => {
@@ -6143,6 +6395,11 @@ fn parse_responses_output(value: serde_json::Value) -> Result {
combined.push_str(text);
}
}
+ Some("reasoning") => {
+ if let Some(text) = block.get("text").and_then(|t| t.as_str()) {
+ reasoning.push_str(text);
+ }
+ }
_ => {}
};
}
@@ -6152,7 +6409,11 @@ fn parse_responses_output(value: serde_json::Value) -> Result {
}
}
if !combined.trim().is_empty() {
- return Ok(combined.trim().to_string());
+ return Ok(ModelCallOutput {
+ text: combined.trim().to_string(),
+ reasoning: normalize_reasoning(reasoning)
+ .or_else(|| extract_reasoning_from_value(&value)),
+ });
}
}
@@ -6163,14 +6424,85 @@ fn parse_responses_output(value: serde_json::Value) -> Result {
.collect::>()
.join("\n");
if !merged.trim().is_empty() {
- return Ok(merged.trim().to_string());
+ return Ok(ModelCallOutput {
+ text: merged.trim().to_string(),
+ reasoning: extract_reasoning_from_value(&value),
+ });
+ }
+ }
+
+ if let Some(reasoning) = extract_reasoning_from_value(&value) {
+ if let Some(text) = value
+ .get("text")
+ .and_then(|t| t.as_str())
+ .or_else(|| value.get("output").and_then(|v| v.as_str()))
+ {
+ if !text.trim().is_empty() {
+ return Ok(ModelCallOutput {
+ text: text.trim().to_string(),
+ reasoning: Some(reasoning),
+ });
+ }
}
}
Err("Unable to parse response output".to_string())
}
-fn parse_chat_output(value: serde_json::Value) -> Result {
+fn extract_reasoning_from_value(value: &serde_json::Value) -> Option {
+ fn dfs(node: &serde_json::Value, buffer: &mut String, in_reason_context: bool) {
+ match node {
+ Value::String(text) => {
+ if in_reason_context {
+ let trimmed = text.trim();
+ if !trimmed.is_empty() {
+ if !buffer.is_empty() && !buffer.ends_with(' ') {
+ buffer.push(' ');
+ }
+ buffer.push_str(trimmed);
+ }
+ }
+ }
+ Value::Array(items) => {
+ for item in items {
+ dfs(item, buffer, in_reason_context);
+ }
+ }
+ Value::Object(map) => {
+ let mut reason_context = in_reason_context;
+ if let Some(obj_type) = map
+ .get("type")
+ .and_then(|t| t.as_str())
+ .map(|s| s.to_ascii_lowercase())
+ {
+ if obj_type.contains("reasoning") {
+ reason_context = true;
+ }
+ }
+ for (key, val) in map {
+ let key_lower = key.to_ascii_lowercase();
+ let key_is_reason = key_lower.contains("reasoning")
+ || key_lower == "reasoning_text"
+ || key_lower == "reasoning_summary"
+ || key_lower == "reasoning_content"
+ || (reason_context
+ && matches!(
+ key_lower.as_str(),
+ "text" | "content" | "delta" | "message" | "parts"
+ ));
+ dfs(val, buffer, reason_context || key_is_reason);
+ }
+ }
+ _ => {}
+ }
+ }
+
+ let mut buffer = String::new();
+ dfs(value, &mut buffer, false);
+ normalize_reasoning(buffer)
+}
+
+fn parse_chat_output(value: serde_json::Value) -> Result {
if let Some(choice) = value
.get("choices")
.and_then(|c| c.as_array())
@@ -6180,16 +6512,36 @@ fn parse_chat_output(value: serde_json::Value) -> Result {
{
if let Some(text) = content.as_str() {
if !text.trim().is_empty() {
- return Ok(text.trim().to_string());
+ return Ok(ModelCallOutput {
+ text: text.trim().to_string(),
+ reasoning: message
+ .get("reasoning")
+ .and_then(|r| r.as_str())
+ .map(|s| s.trim().to_string())
+ .and_then(normalize_reasoning)
+ .or_else(|| extract_reasoning_from_value(&value)),
+ });
}
} else if let Some(array) = content.as_array() {
- let merged = array
- .iter()
- .filter_map(|item| item.get("text").and_then(|t| t.as_str()))
- .collect::>()
- .join("\n");
- if !merged.trim().is_empty() {
- return Ok(merged.trim().to_string());
+ let mut combined = String::new();
+ let mut reasoning = String::new();
+ for item in array {
+ if let Some(part_text) = item.get("text").and_then(|t| t.as_str()) {
+ combined.push_str(part_text);
+ if !combined.ends_with('\n') {
+ combined.push('\n');
+ }
+ }
+ if let Some(reason_text) = item.get("reasoning").and_then(|r| r.as_str()) {
+ reasoning.push_str(reason_text);
+ }
+ }
+ if !combined.trim().is_empty() {
+ return Ok(ModelCallOutput {
+ text: combined.trim().to_string(),
+ reasoning: normalize_reasoning(reasoning)
+ .or_else(|| extract_reasoning_from_value(&value)),
+ });
}
}
}
@@ -6214,7 +6566,7 @@ const SPEC_COMBINE_SYSTEM_PROMPT: &str = "You are consolidating multiple specifi
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";
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";
-const MARKDOWN_FIX_MODEL: &str = "gpt-5-codex";
+const MARKDOWN_FIX_MODEL: &str = GPT_5_CODEX_MEDIUM_MODEL;
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.";
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";