Skip to content

Commit 7c1190d

Browse files
committed
feat(forge): add /forge slash command for validation orchestration
Implement the /forge command with subcommands: - /forge (or /forge run) - Run all validation agents - /forge status - Show current validation status - /forge config - Show configuration - /forge agents - List available agents - /forge check <agent> - Run specific agent only Includes alias 'validate', category 'Development', comprehensive help text, and 9 unit tests covering all subcommands.
1 parent 856faad commit 7c1190d

File tree

12 files changed

+529
-99
lines changed

12 files changed

+529
-99
lines changed

src/cortex-agents/src/forge/agents/aggregator.rs

Lines changed: 49 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -106,22 +106,34 @@ impl ForgeResponse {
106106

107107
/// Get critical findings count.
108108
pub fn critical_count(&self) -> usize {
109-
self.finding_counts.get(&Severity::Critical).copied().unwrap_or(0)
109+
self.finding_counts
110+
.get(&Severity::Critical)
111+
.copied()
112+
.unwrap_or(0)
110113
}
111114

112115
/// Get error findings count.
113116
pub fn error_count(&self) -> usize {
114-
self.finding_counts.get(&Severity::Error).copied().unwrap_or(0)
117+
self.finding_counts
118+
.get(&Severity::Error)
119+
.copied()
120+
.unwrap_or(0)
115121
}
116122

117123
/// Get warning findings count.
118124
pub fn warning_count(&self) -> usize {
119-
self.finding_counts.get(&Severity::Warning).copied().unwrap_or(0)
125+
self.finding_counts
126+
.get(&Severity::Warning)
127+
.copied()
128+
.unwrap_or(0)
120129
}
121130

122131
/// Get info findings count.
123132
pub fn info_count(&self) -> usize {
124-
self.finding_counts.get(&Severity::Info).copied().unwrap_or(0)
133+
self.finding_counts
134+
.get(&Severity::Info)
135+
.copied()
136+
.unwrap_or(0)
125137
}
126138
}
127139

@@ -299,8 +311,8 @@ impl ValidationAgent for AggregatorAgent {
299311
result.summary = forge_response.summary.clone();
300312

301313
// Add a meta-finding with the ForgeResponse as JSON
302-
let response_json = serde_json::to_string_pretty(&forge_response)
303-
.map_err(AgentError::Serialization)?;
314+
let response_json =
315+
serde_json::to_string_pretty(&forge_response).map_err(AgentError::Serialization)?;
304316

305317
// Store the full response in the summary for downstream consumers
306318
result.summary = format!(
@@ -408,10 +420,20 @@ fn generate_detailed_report(response: &ForgeResponse, all_findings: &[Finding])
408420
// Finding counts table
409421
report.push_str("\n### Findings by Severity\n\n");
410422
report.push_str("| Severity | Count |\n|----------|-------|\n");
411-
for severity in [Severity::Critical, Severity::Error, Severity::Warning, Severity::Info] {
423+
for severity in [
424+
Severity::Critical,
425+
Severity::Error,
426+
Severity::Warning,
427+
Severity::Info,
428+
] {
412429
let count = response.finding_counts.get(&severity).copied().unwrap_or(0);
413430
if count > 0 {
414-
report.push_str(&format!("| {} {} | {} |\n", severity.icon(), severity.name(), count));
431+
report.push_str(&format!(
432+
"| {} {} | {} |\n",
433+
severity.icon(),
434+
severity.name(),
435+
count
436+
));
415437
}
416438
}
417439

@@ -428,15 +450,27 @@ fn generate_detailed_report(response: &ForgeResponse, all_findings: &[Finding])
428450
if !all_findings.is_empty() {
429451
report.push_str("### Detailed Findings\n\n");
430452

431-
for severity in [Severity::Critical, Severity::Error, Severity::Warning, Severity::Info] {
453+
for severity in [
454+
Severity::Critical,
455+
Severity::Error,
456+
Severity::Warning,
457+
Severity::Info,
458+
] {
432459
if let Some(findings) = response.findings_by_severity.get(&severity) {
433460
if !findings.is_empty() {
434-
report.push_str(&format!("#### {} {} Findings\n\n", severity.icon(), severity.name()));
461+
report.push_str(&format!(
462+
"#### {} {} Findings\n\n",
463+
severity.icon(),
464+
severity.name()
465+
));
435466

436467
// Limit output to first 10 findings per severity
437468
let display_count = findings.len().min(10);
438469
for finding in findings.iter().take(display_count) {
439-
report.push_str(&format!("- **[{}]** {}\n", finding.rule_id, finding.message));
470+
report.push_str(&format!(
471+
"- **[{}]** {}\n",
472+
finding.rule_id, finding.message
473+
));
440474
if let Some(ref file) = finding.file {
441475
report.push_str(&format!(" - File: `{}`", file.display()));
442476
if let Some(line) = finding.line {
@@ -497,7 +531,10 @@ mod tests {
497531
ValidationStatus::Failed
498532
);
499533
assert_eq!(
500-
combine_status(ValidationStatus::PassedWithWarnings, ValidationStatus::Passed),
534+
combine_status(
535+
ValidationStatus::PassedWithWarnings,
536+
ValidationStatus::Passed
537+
),
501538
ValidationStatus::PassedWithWarnings
502539
);
503540
}

src/cortex-agents/src/forge/agents/mod.rs

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,11 @@ pub struct RuleInfo {
145145

146146
impl RuleInfo {
147147
/// Create a new rule info.
148-
pub fn new(id: impl Into<String>, name: impl Into<String>, description: impl Into<String>) -> Self {
148+
pub fn new(
149+
id: impl Into<String>,
150+
name: impl Into<String>,
151+
description: impl Into<String>,
152+
) -> Self {
149153
Self {
150154
id: id.into(),
151155
name: name.into(),
@@ -236,7 +240,12 @@ impl Finding {
236240

237241
/// Format finding as a human-readable string.
238242
pub fn format(&self) -> String {
239-
let mut result = format!("{} [{}] {}", self.severity.icon(), self.rule_id, self.message);
243+
let mut result = format!(
244+
"{} [{}] {}",
245+
self.severity.icon(),
246+
self.rule_id,
247+
self.message
248+
);
240249

241250
if let Some(ref file) = self.file {
242251
result.push_str(&format!("\n at {}", file.display()));
@@ -332,17 +341,26 @@ impl ValidationResult {
332341

333342
/// Check if validation passed (no errors or criticals).
334343
pub fn is_passed(&self) -> bool {
335-
matches!(self.status, ValidationStatus::Passed | ValidationStatus::PassedWithWarnings)
344+
matches!(
345+
self.status,
346+
ValidationStatus::Passed | ValidationStatus::PassedWithWarnings
347+
)
336348
}
337349

338350
/// Get all critical findings.
339351
pub fn critical_findings(&self) -> Vec<&Finding> {
340-
self.findings.iter().filter(|f| f.severity == Severity::Critical).collect()
352+
self.findings
353+
.iter()
354+
.filter(|f| f.severity == Severity::Critical)
355+
.collect()
341356
}
342357

343358
/// Get all error findings.
344359
pub fn error_findings(&self) -> Vec<&Finding> {
345-
self.findings.iter().filter(|f| f.severity == Severity::Error).collect()
360+
self.findings
361+
.iter()
362+
.filter(|f| f.severity == Severity::Error)
363+
.collect()
346364
}
347365
}
348366

@@ -482,7 +500,8 @@ impl ValidationContext {
482500

483501
/// Add a previous result (for dependent agents).
484502
pub fn with_previous_result(mut self, result: ValidationResult) -> Self {
485-
self.previous_results.insert(result.agent_id.clone(), result);
503+
self.previous_results
504+
.insert(result.agent_id.clone(), result);
486505
self
487506
}
488507

src/cortex-agents/src/forge/agents/quality.rs

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,7 @@ use tokio::fs;
1414
use tokio::io::AsyncBufReadExt;
1515

1616
use super::{
17-
AgentError, Finding, RuleInfo, Severity, ValidationAgent, ValidationContext,
18-
ValidationResult,
17+
AgentError, Finding, RuleInfo, Severity, ValidationAgent, ValidationContext, ValidationResult,
1918
};
2019

2120
// ============================================================================
@@ -294,17 +293,17 @@ impl QualityAgent {
294293
// Check for bare .unwrap() calls (not followed by expect-like context)
295294
if line.contains(".unwrap()") {
296295
// Check if it's in a test (heuristic: file or function name contains test)
297-
let is_test_file = file_path
298-
.to_string_lossy()
299-
.contains("test")
296+
let is_test_file = file_path.to_string_lossy().contains("test")
300297
|| file_path
301298
.file_stem()
302299
.map_or(false, |s| s.to_string_lossy().ends_with("_test"));
303300

304301
if !is_test_file {
305302
// Check if the next line or same line has a comment explaining it
306303
let has_context = line.contains("// ")
307-
&& (line.contains("safe") || line.contains("always") || line.contains("guaranteed"));
304+
&& (line.contains("safe")
305+
|| line.contains("always")
306+
|| line.contains("guaranteed"));
308307

309308
if !has_context {
310309
findings.push(
@@ -342,7 +341,9 @@ impl QualityAgent {
342341
.at_file(file_path)
343342
.at_line(line_num)
344343
.with_snippet(truncate_line(&line, 80))
345-
.with_suggestion("Handle the Result explicitly or use `_ = expr;` if intentional"),
344+
.with_suggestion(
345+
"Handle the Result explicitly or use `_ = expr;` if intentional",
346+
),
346347
);
347348
}
348349
}
@@ -434,7 +435,9 @@ impl QualityAgent {
434435
.at_file(file_path)
435436
.at_line(line_num)
436437
.with_snippet(truncate_line(trimmed, 80))
437-
.with_suggestion("Remove unused code or document why suppression is needed"),
438+
.with_suggestion(
439+
"Remove unused code or document why suppression is needed",
440+
),
438441
);
439442
}
440443
}

src/cortex-agents/src/forge/agents/security.rs

Lines changed: 29 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,7 @@ use tokio::fs;
1313
use tokio::io::AsyncBufReadExt;
1414

1515
use super::{
16-
AgentError, Finding, RuleInfo, Severity, ValidationAgent, ValidationContext,
17-
ValidationResult,
16+
AgentError, Finding, RuleInfo, Severity, ValidationAgent, ValidationContext, ValidationResult,
1817
};
1918

2019
// ============================================================================
@@ -49,11 +48,7 @@ const SECRET_PATTERNS: &[(&str, &str)] = &[
4948
/// Patterns for base64-encoded or hex-encoded potential secrets.
5049
const ENCODED_SECRET_PATTERNS: &[&str] = &[
5150
// Common API key prefixes
52-
"sk_live_",
53-
"sk_test_",
54-
"pk_live_",
55-
"pk_test_",
56-
"xox", // Slack tokens
51+
"sk_live_", "sk_test_", "pk_live_", "pk_test_", "xox", // Slack tokens
5752
"ghp_", // GitHub personal access token
5853
"gho_", // GitHub OAuth token
5954
"ghu_", // GitHub user-to-server token
@@ -64,7 +59,11 @@ const ENCODED_SECRET_PATTERNS: &[&str] = &[
6459
/// Known vulnerable crate patterns (simplified - in production would use advisory database).
6560
const VULNERABLE_CRATES: &[(&str, &str, &str)] = &[
6661
("chrono", "<0.4.20", "RUSTSEC-2020-0159: Potential segfault"),
67-
("smallvec", "<0.6.14", "RUSTSEC-2019-0009: Double-free vulnerability"),
62+
(
63+
"smallvec",
64+
"<0.6.14",
65+
"RUSTSEC-2019-0009: Double-free vulnerability",
66+
),
6867
("regex", "<1.5.5", "RUSTSEC-2022-0013: Denial of service"),
6968
];
7069

@@ -145,7 +144,8 @@ impl SecurityAgent {
145144

146145
// Skip comments that are likely documentation
147146
let trimmed = line.trim();
148-
if trimmed.starts_with("//") || trimmed.starts_with("///") || trimmed.starts_with("//!") {
147+
if trimmed.starts_with("//") || trimmed.starts_with("///") || trimmed.starts_with("//!")
148+
{
149149
// Still check for actual secret values in comments
150150
if !contains_suspicious_value(&line) {
151151
continue;
@@ -226,10 +226,7 @@ impl SecurityAgent {
226226
Finding::new(
227227
rule_id,
228228
severity,
229-
format!(
230-
"Vulnerable dependency: {} {} ({})",
231-
name, version, advisory
232-
),
229+
format!("Vulnerable dependency: {} {} ({})", name, version, advisory),
233230
)
234231
.at_file(&cargo_lock)
235232
.with_suggestion(format!("Update {} to a patched version", name)),
@@ -392,11 +389,15 @@ impl SecurityAgent {
392389
&& line.contains("format!")
393390
{
394391
findings.push(
395-
Finding::new(rule_id, Severity::Error, "Potential SQL injection vulnerability")
396-
.at_file(file_path)
397-
.at_line(line_num)
398-
.with_snippet(truncate_line(&line, 80))
399-
.with_suggestion("Use parameterized queries instead of string formatting"),
392+
Finding::new(
393+
rule_id,
394+
Severity::Error,
395+
"Potential SQL injection vulnerability",
396+
)
397+
.at_file(file_path)
398+
.at_line(line_num)
399+
.with_snippet(truncate_line(&line, 80))
400+
.with_suggestion("Use parameterized queries instead of string formatting"),
400401
);
401402
}
402403
}
@@ -571,7 +572,10 @@ fn looks_like_hardcoded_secret(line: &str) -> bool {
571572
if let Some(end_quote) = line[start_quote + 1..].find('"') {
572573
let value = &line[start_quote + 1..start_quote + 1 + end_quote];
573574
// Long alphanumeric strings are suspicious
574-
if value.len() > 20 && value.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '-')
575+
if value.len() > 20
576+
&& value
577+
.chars()
578+
.all(|c| c.is_alphanumeric() || c == '_' || c == '-')
575579
{
576580
return true;
577581
}
@@ -649,9 +653,13 @@ mod tests {
649653

650654
#[test]
651655
fn test_looks_like_hardcoded_secret() {
652-
assert!(!looks_like_hardcoded_secret("let api_key = env!(\"API_KEY\");"));
656+
assert!(!looks_like_hardcoded_secret(
657+
"let api_key = env!(\"API_KEY\");"
658+
));
653659
assert!(!looks_like_hardcoded_secret("api_key: \"${API_KEY}\""));
654-
assert!(!looks_like_hardcoded_secret("password: \"your_password_here\""));
660+
assert!(!looks_like_hardcoded_secret(
661+
"password: \"your_password_here\""
662+
));
655663
}
656664

657665
#[test]

src/cortex-agents/src/forge/config.rs

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -583,12 +583,13 @@ impl ConfigLoader {
583583
return Ok(None);
584584
}
585585

586-
let content = tokio::fs::read_to_string(&config_file)
587-
.await
588-
.map_err(|e| ConfigError::ReadError {
589-
path: config_file.clone(),
590-
source: e,
591-
})?;
586+
let content =
587+
tokio::fs::read_to_string(&config_file)
588+
.await
589+
.map_err(|e| ConfigError::ReadError {
590+
path: config_file.clone(),
591+
source: e,
592+
})?;
592593

593594
let config = toml::from_str(&content).map_err(|e| ConfigError::ParseError {
594595
path: config_file,
@@ -630,12 +631,12 @@ impl ConfigLoader {
630631
continue;
631632
}
632633

633-
let content = tokio::fs::read_to_string(&rules_file)
634-
.await
635-
.map_err(|e| ConfigError::ReadError {
634+
let content = tokio::fs::read_to_string(&rules_file).await.map_err(|e| {
635+
ConfigError::ReadError {
636636
path: rules_file.clone(),
637637
source: e,
638-
})?;
638+
}
639+
})?;
639640

640641
let rules: AgentRulesFile =
641642
toml::from_str(&content).map_err(|e| ConfigError::ParseError {
@@ -752,7 +753,9 @@ priority = 5
752753
assert!(config.global.fail_fast);
753754
assert_eq!(config.defaults.timeout_seconds, 180);
754755

755-
let security = config.get_agent("security").expect("should have security agent");
756+
let security = config
757+
.get_agent("security")
758+
.expect("should have security agent");
756759
assert_eq!(security.name, "Security Scanner");
757760
assert_eq!(security.priority, 10);
758761
assert_eq!(security.depends_on, vec!["lint"]);

0 commit comments

Comments
 (0)