Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.1.6] - 2026-06-01

### Added

- **Custom system prompts via config** — `review.system_prompt`, `review.system_prompt_file`, `scan.system_prompt`, `scan.system_prompt_file` fields in `.cora.yaml` (#94)
- **`response_format` config** — opt-in `json_object` response format for providers that support it, via `review.response_format: json_object` (#92)
- **File path injection into prompts** — valid diff file paths are injected into the review user prompt to reduce LLM hallucination (#93)
- **Post-parse file path filtering** — issues referencing non-existent files are filtered out after LLM response parsing (#93)
- **Enhanced default system prompts** — both review and scan prompts now include explicit anti-hallucination constraints, severity definitions, and format instructions (#95)

### Fixed

- **Path traversal in `system_prompt_file`** — arbitrary file read vulnerability. Now validates file path is within canonicalized project root (#92)
- **Symlink bypass in path traversal guard** — project root is now canonicalized to match resolved file paths

## [0.1.5] - 2026-06-01

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "cora-cli"
version = "0.1.5"
version = "0.1.6"
edition = "2024"
description = "CLI-first AI code review — BYOK, diff/scan/branch, pre-commit hooks"
license = "MIT"
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,10 @@ Download the latest release from [GitHub Releases](https://github.com/ajianaz/co

```bash
# Determine your platform tag from the releases page, e.g.:
# cora-aarch64-unknown-linux-gnu-v0.1.5.tar.gz
# cora-x86_64-unknown-linux-gnu-v0.1.5.tar.gz
# cora-aarch64-apple-darwin-v0.1.5.tar.gz
# cora-x86_64-pc-windows-msvc-v0.1.5.zip
# cora-aarch64-unknown-linux-gnu-v0.1.6.tar.gz
# cora-x86_64-unknown-linux-gnu-v0.1.6.tar.gz
# cora-aarch64-apple-darwin-v0.1.6.tar.gz
# cora-x86_64-pc-windows-msvc-v0.1.6.zip

# Example: Linux aarch64
VERSION=$(curl -s https://api.github.com/repos/ajianaz/cora-cli/releases/latest | grep tag_name | cut -d'"' -f4)
Expand Down
2 changes: 2 additions & 0 deletions src/commands/scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@ pub async fn execute_scan(
&files_content,
&effective_focus,
&config.rules,
&config.response_format,
None,
)
.await?;

Expand Down
216 changes: 216 additions & 0 deletions src/config/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,16 @@ pub struct Config {
pub hook: HookConfig,
/// Output configuration.
pub output: OutputConfig,
/// Response format for LLM API calls ("none" or "json_object").
pub response_format: String,
/// Optional custom system prompt that replaces the default for review.
pub review_system_prompt_override: Option<String>,
/// Optional custom system prompt file path for review.
pub review_system_prompt_file: Option<String>,
/// Optional custom system prompt that replaces the default for scan.
pub scan_system_prompt_override: Option<String>,
/// Optional custom system prompt file path for scan.
pub scan_system_prompt_file: Option<String>,
}

/// Provider configuration.
Expand Down Expand Up @@ -83,6 +93,11 @@ impl Default for Config {
format: "pretty".to_string(),
color: true,
},
response_format: "none".to_string(),
review_system_prompt_override: None,
review_system_prompt_file: None,
scan_system_prompt_override: None,
scan_system_prompt_file: None,
}
}
}
Expand All @@ -109,6 +124,10 @@ pub struct CoraFile {
pub hook: Option<HookSection>,
#[serde(skip_serializing_if = "Option::is_none")]
pub output: Option<OutputSection>,
#[serde(skip_serializing_if = "Option::is_none")]
pub review: Option<ReviewSection>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scan: Option<ScanSection>,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
Expand Down Expand Up @@ -147,6 +166,26 @@ pub struct OutputSection {
pub color: Option<bool>,
}

/// Review-specific configuration section.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ReviewSection {
#[serde(skip_serializing_if = "Option::is_none")]
pub response_format: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub system_prompt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub system_prompt_file: Option<String>,
}

/// Scan-specific configuration section.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ScanSection {
#[serde(skip_serializing_if = "Option::is_none")]
pub system_prompt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub system_prompt_file: Option<String>,
}

impl CoraFile {
pub fn from_str(content: &str) -> Result<Self> {
serde_yaml_ng::from_str(content).context("failed to parse .cora.yaml")
Expand Down Expand Up @@ -198,6 +237,25 @@ impl CoraFile {
config.output.color = v;
}
}
if let Some(r) = &self.review {
if let Some(v) = &r.response_format {
config.response_format = v.clone();
}
if let Some(v) = &r.system_prompt {
config.review_system_prompt_override = Some(v.clone());
}
if let Some(v) = &r.system_prompt_file {
config.review_system_prompt_file = Some(v.clone());
}
}
if let Some(s) = &self.scan {
if let Some(v) = &s.system_prompt {
config.scan_system_prompt_override = Some(v.clone());
}
if let Some(v) = &s.system_prompt_file {
config.scan_system_prompt_file = Some(v.clone());
}
}
}
}

Expand Down Expand Up @@ -468,4 +526,162 @@ output:
assert_eq!(back.provider.provider, cfg.provider.provider);
assert_eq!(back.output.format, cfg.output.format);
}

// ─── Config::default() new fields ───

#[test]
fn config_default_response_format_none() {
let cfg = Config::default();
assert_eq!(cfg.response_format, "none");
}

#[test]
fn config_default_system_prompt_overrides_none() {
let cfg = Config::default();
assert!(cfg.review_system_prompt_override.is_none());
assert!(cfg.review_system_prompt_file.is_none());
assert!(cfg.scan_system_prompt_override.is_none());
assert!(cfg.scan_system_prompt_file.is_none());
}

// ─── ReviewSection parsing and merge ───

#[test]
fn parse_review_section_with_response_format() {
let yaml = r#"
review:
response_format: json_object
"#;
let cora = CoraFile::from_str(yaml).unwrap();
assert_eq!(
cora.review.as_ref().unwrap().response_format.as_deref(),
Some("json_object")
);
}

#[test]
fn parse_review_section_with_system_prompt() {
let yaml = r#"
review:
system_prompt: |
You are a security-focused reviewer.
system_prompt_file: .cora/prompts/review.md
"#;
let cora = CoraFile::from_str(yaml).unwrap();
assert_eq!(
cora.review.as_ref().unwrap().system_prompt.as_deref(),
Some("You are a security-focused reviewer.\n")
);
assert_eq!(
cora.review.as_ref().unwrap().system_prompt_file.as_deref(),
Some(".cora/prompts/review.md")
);
}

#[test]
fn merge_review_response_format() {
let mut cfg = Config::default();
let cora = CoraFile {
review: Some(ReviewSection {
response_format: Some("json_object".to_string()),
system_prompt: None,
system_prompt_file: None,
}),
..Default::default()
};
cora.merge_into(&mut cfg);
assert_eq!(cfg.response_format, "json_object");
}

#[test]
fn merge_review_system_prompt() {
let mut cfg = Config::default();
let cora = CoraFile {
review: Some(ReviewSection {
response_format: None,
system_prompt: Some("Custom prompt here.".to_string()),
system_prompt_file: None,
}),
..Default::default()
};
cora.merge_into(&mut cfg);
assert_eq!(
cfg.review_system_prompt_override.as_deref(),
Some("Custom prompt here.")
);
}

#[test]
fn merge_review_system_prompt_file() {
let mut cfg = Config::default();
let cora = CoraFile {
review: Some(ReviewSection {
response_format: None,
system_prompt: None,
system_prompt_file: Some("prompts/review.md".to_string()),
}),
..Default::default()
};
cora.merge_into(&mut cfg);
assert_eq!(
cfg.review_system_prompt_file.as_deref(),
Some("prompts/review.md")
);
}

// ─── ScanSection parsing and merge ───

#[test]
fn parse_scan_section_with_system_prompt() {
let yaml = r#"
scan:
system_prompt: |
You are a performance-focused scanner.
"#;
let cora = CoraFile::from_str(yaml).unwrap();
assert_eq!(
cora.scan.as_ref().unwrap().system_prompt.as_deref(),
Some("You are a performance-focused scanner.\n")
);
}

#[test]
fn merge_scan_system_prompt() {
let mut cfg = Config::default();
let cora = CoraFile {
scan: Some(ScanSection {
system_prompt: Some("Performance only.".to_string()),
system_prompt_file: None,
}),
..Default::default()
};
cora.merge_into(&mut cfg);
assert_eq!(
cfg.scan_system_prompt_override.as_deref(),
Some("Performance only.")
);
}

// ─── Full .cora.yaml with review and scan sections ───

#[test]
fn parse_cora_file_with_review_and_scan() {
let yaml = r#"
review:
response_format: json_object
system_prompt: |
Security only.
scan:
system_prompt: |
Performance only.
system_prompt_file: scan.md
"#;
let cora = CoraFile::from_str(yaml).unwrap();
let review = cora.review.unwrap();
assert_eq!(review.response_format.as_deref(), Some("json_object"));
assert_eq!(review.system_prompt.as_deref(), Some("Security only.\n"));
let scan = cora.scan.unwrap();
assert_eq!(scan.system_prompt.as_deref(), Some("Performance only.\n"));
assert_eq!(scan.system_prompt_file.as_deref(), Some("scan.md"));
}
}
Loading
Loading