Compare commits

...

1 Commits
pr4913 ... sec

Author SHA1 Message Date
kh.ai
a34f6c9f08 u 2025-10-15 13:46:33 -07:00
21 changed files with 857 additions and 9 deletions

View File

@@ -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<string>;
outputPath?: string;
repoPath?: string;
modelName?: string;
};
function tokenizeCommand(input: string): Array<string> {
const tokens: Array<string> = [];
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<string> = [];
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<ResponseItem>;
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,
],
);

View File

@@ -580,6 +580,7 @@ export default function TerminalChat({
}}
items={items}
thinkingSeconds={thinkingSeconds}
config={config}
/>
)}
{overlayMode === "history" && (

View File

@@ -56,6 +56,10 @@ export default function HelpOverlay({
<Text color="cyan">/bug</Text> generate a prefilled GitHub issue URL
with session log
</Text>
<Text>
<Text color="cyan">/secreview</Text> run AppSec security review and
show the results
</Text>
<Text>
<Text color="cyan">/diff</Text> view working tree git diff
</Text>

View File

@@ -24,6 +24,10 @@ export const SLASH_COMMANDS: Array<SlashCommand> = [
{ 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",

View File

@@ -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(

View File

@@ -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",
},
};
}

View File

@@ -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");
});

View File

@@ -26,6 +26,11 @@ describe("TerminalChatInput compact command", () => {
interruptAgent: () => {},
active: true,
thinkingSeconds: 0,
config: {
model: "codex-mini-latest",
instructions: "",
provider: "openai",
},
};
const { lastFrameStripped } = renderTui(<TerminalChatInput {...props} />);
const frame = lastFrameStripped();

View File

@@ -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(() => {

View File

@@ -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(

2
codex-rs/Cargo.lock generated
View File

@@ -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",

View File

@@ -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();

View File

@@ -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<HashMap<String, String>>,
/// 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<String, ModelProviderInfo> {
.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();

View File

@@ -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

View File

@@ -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());

View File

@@ -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"

View File

@@ -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()?;

View File

@@ -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<FileMatch>,
},
/// Completion event for a `/secreview` invocation.
SecurityReviewFinished {
mode: SecurityReviewMode,
outcome: Result<SecurityReviewResult, SecurityReviewFailure>,
},
}

View File

@@ -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<UserMessage>,
token_usage: TokenUsage,
security_review_handle: Option<JoinHandle<()>>,
active_security_review_mode: Option<SecurityReviewMode>,
}
#[derive(Clone, Copy, Eq, PartialEq)]
@@ -79,6 +85,15 @@ fn create_initial_user_message(text: String, image_paths: Vec<PathBuf>) -> Optio
}
}
#[derive(Debug, Default)]
struct ParsedSecReviewCommand {
mode: SecurityReviewMode,
include_paths: Vec<String>,
output_path: Option<String>,
repo_path: Option<String>,
model_name: Option<String>,
}
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<InputItem> = 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<PathBuf> = Vec::new();
let mut display_paths: Vec<String> = 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<SecurityReviewResult, SecurityReviewFailure>,
) {
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<ParsedSecReviewCommand, String> {
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<SecurityReviewMode, String> {
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<String> {
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}"))
}
}

View File

@@ -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;

View File

@@ -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"
}
}
}