From a34f6c9f08e94930f744a162d44ec6d53102f8b5 Mon Sep 17 00:00:00 2001 From: "kh.ai" Date: Wed, 15 Oct 2025 13:46:33 -0700 Subject: [PATCH] u --- .../components/chat/terminal-chat-input.tsx | 367 +++++++++++++++- .../src/components/chat/terminal-chat.tsx | 1 + codex-cli/src/components/help-overlay.tsx | 4 + codex-cli/src/utils/slash-commands.ts | 4 + codex-cli/tests/clear-command.test.tsx | 5 + .../tests/multiline-history-behavior.test.tsx | 16 +- codex-cli/tests/slash-commands.test.ts | 1 + .../terminal-chat-input-compact.test.tsx | 5 + ...l-chat-input-file-tag-suggestions.test.tsx | 5 + .../terminal-chat-input-multiline.test.tsx | 10 + codex-rs/Cargo.lock | 2 + codex-rs/core/src/config.rs | 1 + codex-rs/core/src/model_provider_info.rs | 12 + codex-rs/core/tests/previous_response_id.rs | 1 + codex-rs/core/tests/stream_no_completed.rs | 1 + codex-rs/tui/Cargo.toml | 2 + codex-rs/tui/src/app.rs | 10 + codex-rs/tui/src/app_event.rs | 7 + codex-rs/tui/src/chatwidget.rs | 406 +++++++++++++++++- codex-rs/tui/src/lib.rs | 1 + codex-rs/tui/src/slash_command.rs | 5 + 21 files changed, 857 insertions(+), 9 deletions(-) diff --git a/codex-cli/src/components/chat/terminal-chat-input.tsx b/codex-cli/src/components/chat/terminal-chat-input.tsx index c8c5bf8216..e38588e497 100644 --- a/codex-cli/src/components/chat/terminal-chat-input.tsx +++ b/codex-cli/src/components/chat/terminal-chat-input.tsx @@ -10,19 +10,25 @@ import type { import MultilineTextEditor from "./multiline-editor"; import { TerminalChatCommandReview } from "./terminal-chat-command-review.js"; import TextCompletions from "./terminal-chat-completions.js"; -import { loadConfig } from "../../utils/config.js"; +import { loadConfig, type AppConfig } from "../../utils/config.js"; import { getFileSystemSuggestions } from "../../utils/file-system-suggestions.js"; import { expandFileTags } from "../../utils/file-tag-utils"; import { createInputItem } from "../../utils/input-utils.js"; import { log } from "../../utils/logger/log.js"; import { setSessionId } from "../../utils/session.js"; import { SLASH_COMMANDS, type SlashCommand } from "../../utils/slash-commands"; +import { + runSecurityReview, + SecurityReviewError, +} from "../../utils/security-review.js"; +import type { SecurityReviewMode } from "../../utils/security-review.js"; import { loadCommandHistory, addToHistory, } from "../../utils/storage/command-history.js"; import { clearTerminal, onExit } from "../../utils/terminal.js"; import { Box, Text, useApp, useInput, useStdin } from "ink"; +import path from "node:path"; import { fileURLToPath } from "node:url"; import React, { useCallback, @@ -39,6 +45,130 @@ const suggestions = [ "are there any bugs in my code?", ]; +const SEC_REVIEW_COMMAND = "/secreview"; + +type SecReviewCommandOptions = { + mode: SecurityReviewMode; + includePaths: Array; + outputPath?: string; + repoPath?: string; + modelName?: string; +}; + +function tokenizeCommand(input: string): Array { + const tokens: Array = []; + const regex = /"([^"]*)"|'([^']*)'|(\S+)/g; + let match: RegExpExecArray | null; + while ((match = regex.exec(input)) !== null) { + if (match[1] != null) { + tokens.push(match[1]); + } else if (match[2] != null) { + tokens.push(match[2]); + } else if (match[3] != null) { + tokens.push(match[3]); + } + } + return tokens; +} + +function parseSecReviewCommand(input: string): SecReviewCommandOptions { + const tokens = tokenizeCommand(input).slice(1); // drop the command itself + let mode: SecurityReviewMode = "full"; + const includePaths: Array = []; + let outputPath: string | undefined; + let repoPath: string | undefined; + let modelName: string | undefined; + + const parseMode = (value: string, option: string) => { + if (value === "bugs") { + mode = "bugs"; + } else if (value === "full") { + mode = "full"; + } else { + throw new Error(`Unknown ${option} value "${value}". Use "full" or "bugs".`); + } + }; + + for (let i = 0; i < tokens.length; i += 1) { + const token = tokens[i]; + + const expectValue = (label: string): string => { + if (i + 1 >= tokens.length) { + throw new Error(`Expected value after ${label}`); + } + i += 1; + return tokens[i]; + }; + + if (token === "--") { + break; + } else if (token === "bugs" || token === "--bugs" || token === "--mode=bugs") { + mode = "bugs"; + } else if (token === "full" || token === "--full" || token === "--mode=full") { + mode = "full"; + } else if (token === "--mode") { + parseMode(expectValue("--mode"), "--mode"); + } else if (token.startsWith("--mode=")) { + parseMode(token.slice("--mode=".length), "--mode"); + } else if (token === "--path" || token === "-p") { + includePaths.push(expectValue(token)); + } else if (token.startsWith("--path=")) { + includePaths.push(token.slice("--path=".length)); + } else if (token.startsWith("-p=")) { + includePaths.push(token.slice("-p=".length)); + } else if ( + token === "--output" || + token === "-o" || + token === "--output-location" + ) { + outputPath = expectValue(token); + } else if (token.startsWith("--output=")) { + outputPath = token.slice("--output=".length); + } else if (token.startsWith("-o=")) { + outputPath = token.slice("-o=".length); + } else if ( + token === "--repo" || + token === "--repo-location" || + token === "--repository" + ) { + repoPath = expectValue(token); + } else if (token.startsWith("--repo=")) { + repoPath = token.slice("--repo=".length); + } else if (token.startsWith("--repo-location=")) { + repoPath = token.slice("--repo-location=".length); + } else if (token === "--model" || token === "--model-name") { + modelName = expectValue(token); + } else if (token.startsWith("--model=")) { + modelName = token.slice("--model=".length); + } else if (token.startsWith("--model-name=")) { + modelName = token.slice("--model-name=".length); + } else if (token.length > 0) { + includePaths.push(token); + } + } + + return { + mode, + includePaths: includePaths.filter((p) => p.length > 0), + outputPath, + repoPath, + modelName, + }; +} + +function trimLogOutput(logText: string, maxLines: number = 40): string { + const normalised = logText.replace(/\r\n/g, "\n").trimEnd(); + if (normalised === "") { + return "(empty)"; + } + const lines = normalised.split("\n"); + if (lines.length <= maxLines) { + return normalised; + } + const tail = lines.slice(-maxLines); + return ["â€Ļ (showing last " + maxLines + " lines)", ...tail].join("\n"); +} + export default function TerminalChatInput({ isNew, loading, @@ -60,6 +190,7 @@ export default function TerminalChatInput({ active, thinkingSeconds, items = [], + config, }: { isNew: boolean; loading: boolean; @@ -85,6 +216,7 @@ export default function TerminalChatInput({ thinkingSeconds: number; // New: current conversation items so we can include them in bug reports items?: Array; + config: AppConfig; }): React.ReactElement { // Slash command suggestion index const [selectedSlashSuggestion, setSelectedSlashSuggestion] = @@ -512,6 +644,230 @@ export default function TerminalChatInput({ } else if (inputValue.startsWith("/approval")) { setInput(""); openApprovalOverlay(); + return; + } else if (inputValue.startsWith(SEC_REVIEW_COMMAND)) { + setInput(""); + const commandId = `secreview-${Date.now()}`; + + let parsed: SecReviewCommandOptions; + try { + parsed = parseSecReviewCommand(inputValue); + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + setItems((prev) => [ + ...prev, + { + id: `${commandId}-parse-error`, + type: "message", + role: "system", + content: [ + { + type: "input_text", + text: `âš ī¸ Unable to parse ${SEC_REVIEW_COMMAND} arguments: ${message}`, + }, + ], + }, + ]); + return; + } + + const repoPath = parsed.repoPath + ? path.isAbsolute(parsed.repoPath) + ? parsed.repoPath + : path.resolve(process.cwd(), parsed.repoPath) + : process.cwd(); + + const resolvedOutputPath = + parsed.outputPath != null + ? path.isAbsolute(parsed.outputPath) + ? parsed.outputPath + : path.resolve(repoPath, parsed.outputPath) + : undefined; + + const scopeDescription = + parsed.includePaths.length > 0 + ? parsed.includePaths.join(", ") + : "entire repository"; + + const introLines = [ + `🔐 Running AppSec security review (mode: ${parsed.mode}).`, + `Repository: ${repoPath}`, + `Scope: ${scopeDescription}`, + ]; + + if (resolvedOutputPath) { + introLines.push(`Output: ${resolvedOutputPath}`); + } + + if (parsed.modelName) { + introLines.push(`Model override: ${parsed.modelName}`); + } + + setItems((prev) => [ + ...prev, + { + id: `${commandId}-start`, + type: "message", + role: "system", + content: [ + { + type: "input_text", + text: introLines.join("\n"), + }, + ], + }, + ]); + + try { + const result = await runSecurityReview({ + repoPath, + includePaths: parsed.includePaths, + outputPath: resolvedOutputPath, + modelName: parsed.modelName, + mode: parsed.mode, + config, + }); + + const summaryLines = [ + "✅ AppSec review complete.", + `Artifacts: ${result.outputRoot}`, + ]; + if (!result.reportContent) { + summaryLines.push("â„šī¸ report.md not found in output."); + } + if (!result.bugsContent) { + summaryLines.push("â„šī¸ context/bugs.md not found in output."); + } + + setItems((prev) => [ + ...prev, + { + id: `${commandId}-complete`, + type: "message", + role: "system", + content: [ + { + type: "input_text", + text: summaryLines.join("\n"), + }, + ], + }, + ]); + + if (parsed.mode === "full" && result.reportContent) { + setItems((prev) => [ + ...prev, + { + id: `${commandId}-report`, + type: "message", + role: "assistant", + content: [ + { + type: "output_text", + text: `# AppSec Security Review Report\n\n${result.reportContent}`, + }, + ], + }, + ]); + } + + if (result.bugsContent) { + const heading = + parsed.mode === "full" + ? "## Bugs Summary" + : "# AppSec Bugs Summary"; + setItems((prev) => [ + ...prev, + { + id: `${commandId}-bugs`, + type: "message", + role: "assistant", + content: [ + { + type: "output_text", + text: `${heading}\n\n${result.bugsContent}`, + }, + ], + }, + ]); + } else { + setItems((prev) => [ + ...prev, + { + id: `${commandId}-no-bugs`, + type: "message", + role: "system", + content: [ + { + type: "input_text", + text: + "No bug summary produced. Check the output directory for details.", + }, + ], + }, + ]); + } + + if (parsed.mode === "bugs" && result.reportContent) { + setItems((prev) => [ + ...prev, + { + id: `${commandId}-report-location`, + type: "message", + role: "system", + content: [ + { + type: "input_text", + text: `Full report available at ${result.reportPath}`, + }, + ], + }, + ]); + } + if (result.stdout.trim()) { + setItems((prev) => [ + ...prev, + { + id: `${commandId}-logs`, + type: "message", + role: "system", + content: [ + { + type: "input_text", + text: `Logs:\n${trimLogOutput(result.stdout)}`, + }, + ], + }, + ]); + } + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + const stderr = + error instanceof SecurityReviewError && error.stderr + ? `\n\nstderr last lines:\n${trimLogOutput(error.stderr)}` + : ""; + const stdout = + error instanceof SecurityReviewError && error.stdout + ? `\n\nstdout last lines:\n${trimLogOutput(error.stdout)}` + : ""; + setItems((prev) => [ + ...prev, + { + id: `${commandId}-error`, + type: "message", + role: "system", + content: [ + { + type: "input_text", + text: `❌ AppSec review failed: ${message}${stderr}${stdout}`, + }, + ], + }, + ]); + } + return; } else if (["exit", "q", ":q"].includes(inputValue)) { setInput(""); @@ -707,13 +1063,13 @@ export default function TerminalChatInput({ submitInput([inputItem]); // Get config for history persistence. - const config = loadConfig(); + const historyConfig = loadConfig(); // Add to history and update state. const updatedHistory = await addToHistory(value, history, { - maxSize: config.history?.maxSize ?? 1000, - saveHistory: config.history?.saveHistory ?? true, - sensitivePatterns: config.history?.sensitivePatterns ?? [], + maxSize: historyConfig.history?.maxSize ?? 1000, + saveHistory: historyConfig.history?.saveHistory ?? true, + sensitivePatterns: historyConfig.history?.sensitivePatterns ?? [], }); setHistory(updatedHistory); @@ -742,6 +1098,7 @@ export default function TerminalChatInput({ onCompact, skipNextSubmit, items, + config, ], ); diff --git a/codex-cli/src/components/chat/terminal-chat.tsx b/codex-cli/src/components/chat/terminal-chat.tsx index d41a94990f..0402d0e8b4 100644 --- a/codex-cli/src/components/chat/terminal-chat.tsx +++ b/codex-cli/src/components/chat/terminal-chat.tsx @@ -580,6 +580,7 @@ export default function TerminalChat({ }} items={items} thinkingSeconds={thinkingSeconds} + config={config} /> )} {overlayMode === "history" && ( diff --git a/codex-cli/src/components/help-overlay.tsx b/codex-cli/src/components/help-overlay.tsx index d302f7551e..d5e858c932 100644 --- a/codex-cli/src/components/help-overlay.tsx +++ b/codex-cli/src/components/help-overlay.tsx @@ -56,6 +56,10 @@ export default function HelpOverlay({ /bug – generate a prefilled GitHub issue URL with session log + + /secreview – run AppSec security review and + show the results + /diff – view working tree git diff diff --git a/codex-cli/src/utils/slash-commands.ts b/codex-cli/src/utils/slash-commands.ts index c139c04ae9..c87dace47c 100644 --- a/codex-cli/src/utils/slash-commands.ts +++ b/codex-cli/src/utils/slash-commands.ts @@ -24,6 +24,10 @@ export const SLASH_COMMANDS: Array = [ { command: "/help", description: "Show list of commands" }, { command: "/model", description: "Open model selection panel" }, { command: "/approval", description: "Open approval mode selection panel" }, + { + command: "/secreview", + description: "Run AppSec security review and display the generated reports", + }, { command: "/bug", description: "Generate a prefilled GitHub issue URL with session log", diff --git a/codex-cli/tests/clear-command.test.tsx b/codex-cli/tests/clear-command.test.tsx index 09180a8fdc..79e4ecfb8f 100644 --- a/codex-cli/tests/clear-command.test.tsx +++ b/codex-cli/tests/clear-command.test.tsx @@ -61,6 +61,11 @@ describe("/clear command", () => { active: true, thinkingSeconds: 0, items: existingItems, + config: { + model: "codex-mini-latest", + instructions: "", + provider: "openai", + }, }; const { stdin, flush, cleanup } = renderTui( diff --git a/codex-cli/tests/multiline-history-behavior.test.tsx b/codex-cli/tests/multiline-history-behavior.test.tsx index e9120e7a3d..e3dc36e9e9 100644 --- a/codex-cli/tests/multiline-history-behavior.test.tsx +++ b/codex-cli/tests/multiline-history-behavior.test.tsx @@ -66,17 +66,27 @@ function stubProps(): any { loading: false, submitInput: vi.fn(), confirmationPrompt: null, + explanation: undefined, submitConfirmation: vi.fn(), setLastResponseId: vi.fn(), - // Cast to any to satisfy the generic React.Dispatch signature without - // pulling the ResponseItem type into the test bundle. - setItems: (() => {}) as any, + setItems: vi.fn(), contextLeftPercent: 100, openOverlay: vi.fn(), openModelOverlay: vi.fn(), openHelpOverlay: vi.fn(), + openApprovalOverlay: vi.fn(), + openSessionsOverlay: vi.fn(), + openDiffOverlay: vi.fn(), + onCompact: vi.fn(), interruptAgent: vi.fn(), active: true, + thinkingSeconds: 0, + items: [], + config: { + model: "codex-mini-latest", + instructions: "", + provider: "openai", + }, }; } diff --git a/codex-cli/tests/slash-commands.test.ts b/codex-cli/tests/slash-commands.test.ts index b4374e06c3..48a99e7dc8 100644 --- a/codex-cli/tests/slash-commands.test.ts +++ b/codex-cli/tests/slash-commands.test.ts @@ -10,6 +10,7 @@ test("SLASH_COMMANDS includes expected commands", () => { expect(commands).toContain("/help"); expect(commands).toContain("/model"); expect(commands).toContain("/approval"); + expect(commands).toContain("/secreview"); expect(commands).toContain("/clearhistory"); expect(commands).toContain("/diff"); }); diff --git a/codex-cli/tests/terminal-chat-input-compact.test.tsx b/codex-cli/tests/terminal-chat-input-compact.test.tsx index ced707cf76..80c90870f2 100644 --- a/codex-cli/tests/terminal-chat-input-compact.test.tsx +++ b/codex-cli/tests/terminal-chat-input-compact.test.tsx @@ -26,6 +26,11 @@ describe("TerminalChatInput compact command", () => { interruptAgent: () => {}, active: true, thinkingSeconds: 0, + config: { + model: "codex-mini-latest", + instructions: "", + provider: "openai", + }, }; const { lastFrameStripped } = renderTui(); const frame = lastFrameStripped(); diff --git a/codex-cli/tests/terminal-chat-input-file-tag-suggestions.test.tsx b/codex-cli/tests/terminal-chat-input-file-tag-suggestions.test.tsx index ac399c8583..cb812d1598 100644 --- a/codex-cli/tests/terminal-chat-input-file-tag-suggestions.test.tsx +++ b/codex-cli/tests/terminal-chat-input-file-tag-suggestions.test.tsx @@ -81,6 +81,11 @@ describe("TerminalChatInput file tag suggestions", () => { interruptAgent: vi.fn(), active: true, thinkingSeconds: 0, + config: { + model: "codex-mini-latest", + instructions: "", + provider: "openai", + }, }; beforeEach(() => { diff --git a/codex-cli/tests/terminal-chat-input-multiline.test.tsx b/codex-cli/tests/terminal-chat-input-multiline.test.tsx index 6d0f4336df..97f61363ee 100644 --- a/codex-cli/tests/terminal-chat-input-multiline.test.tsx +++ b/codex-cli/tests/terminal-chat-input-multiline.test.tsx @@ -47,6 +47,11 @@ describe("TerminalChatInput multiline functionality", () => { interruptAgent: () => {}, active: true, thinkingSeconds: 0, + config: { + model: "codex-mini-latest", + instructions: "", + provider: "openai", + }, }; const { stdin, lastFrameStripped, flush, cleanup } = renderTui( @@ -99,6 +104,11 @@ describe("TerminalChatInput multiline functionality", () => { interruptAgent: () => {}, active: true, thinkingSeconds: 0, + config: { + model: "codex-mini-latest", + instructions: "", + provider: "openai", + }, }; const { stdin, lastFrameStripped, flush, cleanup } = renderTui( diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index e59dbfa255..cf948b553e 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -818,10 +818,12 @@ dependencies = [ "ratatui", "ratatui-image", "regex-lite", + "reqwest", "serde_json", "shlex", "strum 0.27.1", "strum_macros 0.27.1", + "time", "tokio", "tracing", "tracing-appender", diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index d67e692fc8..856d8a2239 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -722,6 +722,7 @@ disable_response_storage = true query_params: None, http_headers: None, env_http_headers: None, + supports_temperature: true, }; let model_provider_map = { let mut model_provider_map = built_in_model_providers(); diff --git a/codex-rs/core/src/model_provider_info.rs b/codex-rs/core/src/model_provider_info.rs index b38c912d34..c4a58d8ad3 100644 --- a/codex-rs/core/src/model_provider_info.rs +++ b/codex-rs/core/src/model_provider_info.rs @@ -64,6 +64,14 @@ pub struct ModelProviderInfo { /// value should be used. If the environment variable is not set, or the /// value is empty, the header will not be included in the request. pub env_http_headers: Option>, + + /// Whether the provider accepts an explicit `temperature` parameter. + #[serde(default = "default_supports_temperature")] + pub supports_temperature: bool, +} + +const fn default_supports_temperature() -> bool { + true } impl ModelProviderInfo { @@ -205,6 +213,7 @@ pub fn built_in_model_providers() -> HashMap { .into_iter() .collect(), ), + supports_temperature: false, }, ), ] @@ -234,6 +243,7 @@ base_url = "http://localhost:11434/v1" query_params: None, http_headers: None, env_http_headers: None, + supports_temperature: true, }; let provider: ModelProviderInfo = toml::from_str(azure_provider_toml).unwrap(); @@ -259,6 +269,7 @@ query_params = { api-version = "2025-04-01-preview" } }), http_headers: None, env_http_headers: None, + supports_temperature: true, }; let provider: ModelProviderInfo = toml::from_str(azure_provider_toml).unwrap(); @@ -287,6 +298,7 @@ env_http_headers = { "X-Example-Env-Header" = "EXAMPLE_ENV_VAR" } env_http_headers: Some(maplit::hashmap! { "X-Example-Env-Header".to_string() => "EXAMPLE_ENV_VAR".to_string(), }), + supports_temperature: true, }; let provider: ModelProviderInfo = toml::from_str(azure_provider_toml).unwrap(); diff --git a/codex-rs/core/tests/previous_response_id.rs b/codex-rs/core/tests/previous_response_id.rs index e64271a0ff..a83bf2af6a 100644 --- a/codex-rs/core/tests/previous_response_id.rs +++ b/codex-rs/core/tests/previous_response_id.rs @@ -107,6 +107,7 @@ async fn keeps_previous_response_id_between_tasks() { query_params: None, http_headers: None, env_http_headers: None, + supports_temperature: true, }; // Init session diff --git a/codex-rs/core/tests/stream_no_completed.rs b/codex-rs/core/tests/stream_no_completed.rs index da2736aa77..bb9612312d 100644 --- a/codex-rs/core/tests/stream_no_completed.rs +++ b/codex-rs/core/tests/stream_no_completed.rs @@ -96,6 +96,7 @@ async fn retries_on_early_close() { query_params: None, http_headers: None, env_http_headers: None, + supports_temperature: true, }; let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new()); diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 74aedfa353..e78b5e2abe 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -40,6 +40,7 @@ ratatui = { version = "0.29.0", features = [ ] } ratatui-image = "8.0.0" regex-lite = "0.1" +reqwest = { version = "0.12", features = ["json"] } serde_json = { version = "1", features = ["preserve_order"] } shlex = "1.3.0" strum = "0.27.1" @@ -59,6 +60,7 @@ tui-markdown = "0.3.3" tui-textarea = "0.7.0" unicode-segmentation = "1.12.0" uuid = "1" +time = { version = "0.3", features = ["formatting"] } [dev-dependencies] insta = "1.43.1" diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index e1dde8332d..ce4937434e 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -266,6 +266,11 @@ impl<'a> App<'a> { widget.add_diff_output(text); } } + SlashCommand::SecurityReview => { + if let AppState::Chat { widget } = &mut self.app_state { + widget.start_security_review_with_defaults(); + } + } }, AppEvent::StartFileSearch(query) => { self.file_search.on_user_query(query); @@ -275,6 +280,11 @@ impl<'a> App<'a> { widget.apply_file_search_result(query, matches); } } + AppEvent::SecurityReviewFinished { mode, outcome } => { + if let AppState::Chat { widget } = &mut self.app_state { + widget.handle_security_review_finished(mode, outcome); + } + } } } terminal.clear()?; diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index fd6b2479ee..7f5e3a9d30 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -2,6 +2,7 @@ use codex_core::protocol::Event; use codex_file_search::FileMatch; use crossterm::event::KeyEvent; +use crate::security_review::{SecurityReviewFailure, SecurityReviewMode, SecurityReviewResult}; use crate::slash_command::SlashCommand; #[allow(clippy::large_enum_variant)] @@ -45,4 +46,10 @@ pub(crate) enum AppEvent { query: String, matches: Vec, }, + + /// Completion event for a `/secreview` invocation. + SecurityReviewFinished { + mode: SecurityReviewMode, + outcome: Result, + }, } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 865e339763..32118b6847 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1,4 +1,4 @@ -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Arc; use codex_core::codex_wrapper::init_codex; @@ -29,6 +29,7 @@ use ratatui::widgets::Widget; use ratatui::widgets::WidgetRef; use tokio::sync::mpsc::UnboundedSender; use tokio::sync::mpsc::unbounded_channel; +use tokio::task::JoinHandle; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; @@ -38,7 +39,10 @@ use crate::bottom_pane::InputResult; use crate::conversation_history_widget::ConversationHistoryWidget; use crate::history_cell::PatchEventType; use crate::user_approval_widget::ApprovalRequest; +use crate::security_review::{run_security_review, SecurityReviewFailure, SecurityReviewMode, SecurityReviewRequest, SecurityReviewResult}; use codex_file_search::FileMatch; +use path_clean::PathClean; +use shlex; pub(crate) struct ChatWidget<'a> { app_event_tx: AppEventSender, @@ -49,6 +53,8 @@ pub(crate) struct ChatWidget<'a> { config: Config, initial_user_message: Option, token_usage: TokenUsage, + security_review_handle: Option>, + active_security_review_mode: Option, } #[derive(Clone, Copy, Eq, PartialEq)] @@ -79,6 +85,15 @@ fn create_initial_user_message(text: String, image_paths: Vec) -> Optio } } +#[derive(Debug, Default)] +struct ParsedSecReviewCommand { + mode: SecurityReviewMode, + include_paths: Vec, + output_path: Option, + repo_path: Option, + model_name: Option, +} + impl ChatWidget<'_> { pub(crate) fn new( config: Config, @@ -135,6 +150,8 @@ impl ChatWidget<'_> { initial_images, ), token_usage: TokenUsage::default(), + security_review_handle: None, + active_security_review_mode: None, } } @@ -182,6 +199,11 @@ impl ChatWidget<'_> { fn submit_user_message(&mut self, user_message: UserMessage) { let UserMessage { text, image_paths } = user_message; + + if self.try_handle_slash_command(&text) { + return; + } + let mut items: Vec = Vec::new(); if !text.is_empty() { @@ -218,6 +240,240 @@ impl ChatWidget<'_> { self.conversation_history.scroll_to_bottom(); } + fn try_handle_slash_command(&mut self, text: &str) -> bool { + let trimmed = text.trim(); + if trimmed.starts_with("/secreview") { + match parse_security_review_command(trimmed) { + Ok(command) => { + if let Err(err) = self.launch_security_review(command) { + self.report_security_review_error(err); + } + } + Err(err) => self.report_security_review_error(err), + } + return true; + } + false + } + + pub(crate) fn start_security_review_with_defaults(&mut self) { + let command = ParsedSecReviewCommand::default(); + if let Err(err) = self.launch_security_review(command) { + self.report_security_review_error(err); + } + } + + fn launch_security_review(&mut self, command: ParsedSecReviewCommand) -> Result<(), String> { + let repo_candidate = if let Some(repo_override) = command.repo_path.as_ref() { + let candidate = Path::new(repo_override); + if candidate.is_absolute() { + candidate.to_path_buf() + } else { + self.config.cwd.join(candidate) + } + } else { + self.config.cwd.clone() + } + .clean(); + + let repo_path = match repo_candidate.canonicalize() { + Ok(path) => path, + Err(_) => repo_candidate.clone(), + }; + + if !repo_path.exists() { + return Err(format!( + "Repository path '{}' does not exist.", + repo_path.display() + )); + } + if !repo_path.is_dir() { + return Err(format!( + "Repository path '{}' is not a directory.", + repo_path.display() + )); + } + + let mut resolved_paths: Vec = Vec::new(); + let mut display_paths: Vec = Vec::new(); + + for include in &command.include_paths { + let candidate = resolve_path(&repo_path, include); + let canonical = match candidate.canonicalize() { + Ok(path) => path, + Err(_) => candidate.clone(), + }; + + if !canonical.exists() { + return Err(format!("Path '{}' does not exist.", canonical.display())); + } + if !canonical.starts_with(&repo_path) { + return Err(format!( + "Path '{}' is outside the repository root '{}'.", + canonical.display(), + repo_path.display() + )); + } + + let relative = canonical + .strip_prefix(&repo_path) + .unwrap_or(&canonical) + .display() + .to_string(); + display_paths.push(relative); + resolved_paths.push(canonical); + } + + let output_root = if let Some(output_override) = command.output_path.as_ref() { + let candidate = Path::new(output_override); + if candidate.is_absolute() { + candidate.to_path_buf() + } else { + repo_path.join(candidate) + } + } else { + repo_path.join("appsec_review") + } + .clean(); + + let model_name = command + .model_name + .clone() + .unwrap_or_else(|| self.config.model.clone()); + + if self.security_review_handle.is_some() { + return Err("A security review is already running. Please wait for it to finish or abort it before starting another.".to_string()); + } + + let scope_description = if resolved_paths.is_empty() { + "entire repository".to_string() + } else { + display_paths.join(", ") + }; + + let summary = format!( + "🔐 Running AppSec security review (mode: {}).\nRepository: {}\nScope: {}\nOutput: {}\nModel: {}", + command.mode.as_str(), + repo_path.display(), + scope_description, + output_root.display(), + model_name + ); + self.conversation_history.add_background_event(summary); + self.conversation_history.scroll_to_bottom(); + self.bottom_pane.set_task_running(true); + self.request_redraw(); + + let provider = self.config.model_provider.clone(); + let request = SecurityReviewRequest { + repo_path: repo_path.clone(), + include_paths: resolved_paths, + output_root: output_root.clone(), + mode: command.mode, + model: model_name, + provider, + progress_sender: Some(self.app_event_tx.clone()), + }; + + let app_event_tx = self.app_event_tx.clone(); + let mode = command.mode; + let handle = tokio::spawn(async move { + let outcome = run_security_review(request).await; + app_event_tx.send(AppEvent::SecurityReviewFinished { mode, outcome }); + }); + self.security_review_handle = Some(handle); + self.active_security_review_mode = Some(mode); + + Ok(()) + } + + fn report_security_review_error(&mut self, message: String) { + self.security_review_handle = None; + self.active_security_review_mode = None; + self.bottom_pane.set_task_running(false); + self.conversation_history + .add_background_event(format!("❌ {message}")); + self.conversation_history.scroll_to_bottom(); + self.request_redraw(); + } + + pub(crate) fn handle_security_review_finished( + &mut self, + mode: SecurityReviewMode, + outcome: Result, + ) { + self.security_review_handle = None; + self.active_security_review_mode = None; + self.bottom_pane.set_task_running(false); + match outcome { + Ok(result) => { + let SecurityReviewResult { + bugs_markdown, + report_markdown, + bugs_path, + report_path, + logs, + } = result; + + let mut summary = format!( + "✅ AppSec security review complete (mode: {}).\nBugs saved to {}.", + mode.as_str(), + bugs_path.display() + ); + if let Some(report_path) = report_path.as_ref() { + summary.push_str(&format!( + "\nReport saved to {}.", + report_path.display() + )); + } + self.conversation_history.add_background_event(summary); + + if matches!(mode, SecurityReviewMode::Full) { + if let Some(markdown) = report_markdown.and_then(|m| { + if m.trim().is_empty() { + None + } else { + Some(m) + } + }) { + self.conversation_history.add_agent_message( + &self.config, + format!("# AppSec Security Review Report\n\n{markdown}"), + ); + } + } + + if !bugs_markdown.trim().is_empty() { + let heading = if matches!(mode, SecurityReviewMode::Full) { + "## Bugs Summary" + } else { + "# AppSec Bugs Summary" + }; + self.conversation_history.add_agent_message( + &self.config, + format!("{heading}\n\n{bugs_markdown}"), + ); + } + + if let Some(log_text) = format_security_review_logs(&logs) { + self.conversation_history + .add_background_event(format!("Logs:\n{log_text}")); + } + } + Err(error) => { + let SecurityReviewFailure { message, logs } = error; + let mut summary = format!("❌ AppSec security review failed: {message}"); + if let Some(log_text) = format_security_review_logs(&logs) { + summary.push_str(&format!("\n\nLogs:\n{log_text}")); + } + self.conversation_history.add_background_event(summary); + } + } + + self.conversation_history.scroll_to_bottom(); + self.request_redraw(); + } + pub(crate) fn handle_codex_event(&mut self, event: Event) { let Event { id, msg } = event; match msg { @@ -420,6 +676,20 @@ impl ChatWidget<'_> { /// Returns true if the key press was handled, false if it was not. /// If the key press was not handled, the caller should handle it (likely by exiting the process). pub(crate) fn on_ctrl_c(&mut self) -> bool { + if let Some(handle) = self.security_review_handle.take() { + handle.abort(); + let mode = self + .active_security_review_mode + .take() + .unwrap_or_else(SecurityReviewMode::default); + let failure = SecurityReviewFailure { + message: "AppSec security review aborted by user.".to_string(), + logs: vec!["AppSec security review aborted by user.".to_string()], + }; + self.handle_security_review_finished(mode, Err(failure)); + return true; + } + if self.bottom_pane.is_task_running() { self.bottom_pane.clear_ctrl_c_quit_hint(); self.submit_op(Op::Interrupt); @@ -481,3 +751,137 @@ fn add_token_usage(current_usage: &TokenUsage, new_usage: &TokenUsage) -> TokenU total_tokens: current_usage.total_tokens + new_usage.total_tokens, } } + +fn parse_security_review_command(input: &str) -> Result { + let tokens = shlex::split(input).ok_or_else(|| "Unable to parse command arguments.".to_string())?; + if tokens.is_empty() { + return Err("Empty command.".to_string()); + } + + if tokens[0] != "/secreview" { + return Err("Unrecognized command.".to_string()); + } + + let mut command = ParsedSecReviewCommand::default(); + let mut idx = 1; + while idx < tokens.len() { + let token = &tokens[idx]; + + if token == "--" { + for extra in tokens.iter().skip(idx + 1) { + if !extra.is_empty() { + command.include_paths.push(extra.to_string()); + } + } + break; + } else if matches!( + token.as_str(), + "bugs" | "--bugs" | "--mode=bugs" + ) { + command.mode = SecurityReviewMode::Bugs; + } else if matches!( + token.as_str(), + "full" | "--full" | "--mode=full" + ) { + command.mode = SecurityReviewMode::Full; + } else if token == "--mode" { + idx += 1; + if idx >= tokens.len() { + return Err("Expected value after --mode.".to_string()); + } + command.mode = parse_mode(&tokens[idx])?; + } else if let Some(value) = token.strip_prefix("--mode=") { + command.mode = parse_mode(value)?; + } else if token == "--path" || token == "-p" { + idx += 1; + if idx >= tokens.len() { + return Err(format!("Expected value after {token}.")); + } + command.include_paths.push(tokens[idx].clone()); + } else if let Some(value) = token.strip_prefix("--path=") { + command.include_paths.push(value.to_string()); + } else if let Some(value) = token.strip_prefix("-p=") { + command.include_paths.push(value.to_string()); + } else if matches!( + token.as_str(), + "--output" | "-o" | "--output-location" + ) { + idx += 1; + if idx >= tokens.len() { + return Err(format!("Expected value after {token}.")); + } + command.output_path = Some(tokens[idx].clone()); + } else if let Some(value) = token.strip_prefix("--output=") { + command.output_path = Some(value.to_string()); + } else if let Some(value) = token.strip_prefix("-o=") { + command.output_path = Some(value.to_string()); + } else if matches!( + token.as_str(), + "--repo" | "--repo-location" | "--repository" + ) { + idx += 1; + if idx >= tokens.len() { + return Err(format!("Expected value after {token}.")); + } + command.repo_path = Some(tokens[idx].clone()); + } else if let Some(value) = token.strip_prefix("--repo=") { + command.repo_path = Some(value.to_string()); + } else if let Some(value) = token.strip_prefix("--repo-location=") { + command.repo_path = Some(value.to_string()); + } else if token == "--model" || token == "--model-name" { + idx += 1; + if idx >= tokens.len() { + return Err(format!("Expected value after {token}.")); + } + command.model_name = Some(tokens[idx].clone()); + } else if let Some(value) = token.strip_prefix("--model=") { + command.model_name = Some(value.to_string()); + } else if let Some(value) = token.strip_prefix("--model-name=") { + command.model_name = Some(value.to_string()); + } else if !token.is_empty() { + command.include_paths.push(token.clone()); + } + + idx += 1; + } + + Ok(command) +} + +fn parse_mode(value: &str) -> Result { + match value.to_ascii_lowercase().as_str() { + "full" => Ok(SecurityReviewMode::Full), + "bugs" | "bugs-only" | "bugsonly" => Ok(SecurityReviewMode::Bugs), + other => Err(format!("Unknown mode '{other}'. Use 'full' or 'bugs'.")), + } +} + +fn resolve_path(base: &Path, candidate: &str) -> PathBuf { + let path = Path::new(candidate); + if path.is_absolute() { + path.to_path_buf() + } else { + base.join(path) + } + .clean() +} + +fn format_security_review_logs(logs: &[String]) -> Option { + if logs.is_empty() { + return None; + } + + let joined = logs.join("\n"); + if joined.trim().is_empty() { + return None; + } + + let lines: Vec<&str> = joined.lines().collect(); + const MAX_LINES: usize = 40; + if lines.len() <= MAX_LINES { + Some(joined) + } else { + let tail = lines[lines.len().saturating_sub(MAX_LINES)..].join("\n"); + Some(format!("â€Ļ (showing last {MAX_LINES} lines)\n{tail}")) + } +} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 4ca305b35e..f28d720157 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -20,6 +20,7 @@ use tracing_subscriber::EnvFilter; use tracing_subscriber::prelude::*; mod app; +mod security_review; mod app_event; mod app_event_sender; mod bottom_pane; diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index bb72ce561c..a68fbe3ba3 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -14,6 +14,8 @@ pub enum SlashCommand { // more frequently used commands should be listed first. New, Diff, + #[strum(serialize = "secreview")] + SecurityReview, Quit, ToggleMouseMode, } @@ -30,6 +32,9 @@ impl SlashCommand { SlashCommand::Diff => { "Show git diff of the working directory (including untracked files)" } + SlashCommand::SecurityReview => { + "Run AppSec security review and display the generated reports" + } } }