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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **`cora review` and `cora scan` run without an API key (#646).** With no key they run the deterministic checks only, print a one-line notice on stderr, and label the result "Deterministic checks only (no LLM judge)" (SARIF: `cora.judge = none` on the invocation, even with zero results). `--no-llm` forces it and silences the notice. A missing key is no longer an error for these two commands; any other config error still is. The judge is chosen once at the entry point (`judge::select_judge`, `JudgeChoice`). `cora commit` and MCP `cora.review_diff` still need a key.

### Security

- **Bumped `rustls` 0.23.45 → 0.23.46 and `quinn-proto` 0.11.16 → 0.11.19** (RUSTSEC-2026-0338/0339/0340/0341/0342 and RUSTSEC-2026-0346/0347/0348/0349), plus the yanked `chacha20` 0.10.1 → 0.10.2. Lockfile-only patch bumps; this was failing the `Security Audit (CVE)` job on every PR.
Expand Down
4 changes: 4 additions & 0 deletions docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ Complete command reference for the cora CLI.
| `cora review --max-diff-size` `<chars>` | Override max diff size |
| `cora review --memory` | Recall project patterns from Uteke before review |
| `cora review --learn` | Save findings to Uteke after review (implies `--memory`) |
| `cora review --no-llm` | Deterministic checks only, never call an LLM |
| `cora commit` | Review staged + generate commit message + commit (HITL prompt) |
| `cora commit --yolo` | Auto-commit without prompts |
| `cora commit --force` | Commit even if quality gate fails |
Expand All @@ -89,6 +90,9 @@ Complete command reference for the cora CLI.
| `cora scan --focus` `security` | Override focus areas |
| `cora scan --batch-files` `N` | Max files per LLM batch (default: 20) |
| `cora scan --no-continue-on-batch-error` | Abort on batch failure (default: skip and continue) |
| `cora scan --no-llm` | Deterministic checks only, never call an LLM |

**No API key needed for deterministic checks.** `cora review` and `cora scan` run without any LLM configured: with no key they run the deterministic checks only (rules, secrets, security, index scans), print a one-line notice on stderr, and mark the result "Deterministic checks only (no LLM judge)" (in SARIF: `invocations[0].properties["cora.judge"] = "none"`), so a clean result is never mistaken for an AI review. `--no-llm` forces this even when a key exists and silences the notice. `cora commit` still needs a key for the commit message.

`cora scan` runs the same deterministic checks as `cora review` (built-in and custom rules, secrets, security patterns, plus unused imports and dead code from the index) on every file, then asks the LLM about each batch. Findings are filtered by `ignore.rules` and inline `cora-ignore:` markers and capped by `rules_engine.max_findings`. Unlike `review`, scan has no `--ci`, `--severity`, `--quiet`, `--stream`, `--output-file` or `--no-cache`, does not apply the quality gate, and keeps every finding in the output (`hook.min_severity` only decides the exit code in `block` mode).

Expand Down
23 changes: 14 additions & 9 deletions src/commands/review.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use crate::config::schema::Config;
use crate::engine::Severity;
use crate::engine::chunker;
use crate::engine::finalize::{self, HistoryEntry, ShapeOptions};
use crate::engine::judge::JudgeChoice;
use crate::engine::quality_gate;
use crate::engine::types::ReviewResponse;
use crate::formatters::{OutputFormat, formatter_for};
Expand Down Expand Up @@ -121,7 +122,7 @@ impl ReviewResult {
#[allow(clippy::cast_possible_truncation, clippy::too_many_arguments)]
pub async fn execute_review(
config: &Config,
llm_config: &crate::engine::LLMConfig,
judge: &JudgeChoice,
opts: &ReviewOptions,
format: OutputFormat,
progress: &ProgressReporter,
Expand Down Expand Up @@ -151,7 +152,7 @@ pub async fn execute_review(
// Auto-chunk mode: split diff and review each chunk
return execute_chunked_review(
config,
llm_config,
judge,
opts,
format,
progress,
Expand Down Expand Up @@ -191,15 +192,17 @@ pub async fn execute_review(

// 4. Emit calling_llm event
if progress.is_enabled() {
progress.calling_llm(&llm_config.provider, &llm_config.model);
if let JudgeChoice::Llm(llm_config) = judge {
progress.calling_llm(&llm_config.provider, &llm_config.model);
}
}

let llm_start = std::time::Instant::now();

// 5. Call the LLM engine
let response = match crate::engine::review::review_diff_with_cache(
let response = match crate::engine::review::review_diff_with_judge(
config,
llm_config,
judge,
&diff,
opts.stream,
!opts.no_cache,
Expand Down Expand Up @@ -387,7 +390,7 @@ fn get_diff(opts: &ReviewOptions, _config: &Config) -> Result<String> {
#[allow(clippy::cast_possible_truncation, clippy::too_many_arguments)]
async fn execute_chunked_review(
config: &Config,
llm_config: &crate::engine::LLMConfig,
judge: &JudgeChoice,
opts: &ReviewOptions,
format: OutputFormat,
progress: &ProgressReporter,
Expand Down Expand Up @@ -445,14 +448,16 @@ async fn execute_chunked_review(
}

if progress.is_enabled() {
progress.calling_llm(&llm_config.provider, &llm_config.model);
if let JudgeChoice::Llm(llm_config) = judge {
progress.calling_llm(&llm_config.provider, &llm_config.model);
}
}

let chunk_start = std::time::Instant::now();

match crate::engine::review::review_diff_with_cache(
match crate::engine::review::review_diff_with_judge(
config,
llm_config,
judge,
&chunk.diff,
opts.stream,
!opts.no_cache,
Expand Down
73 changes: 58 additions & 15 deletions src/commands/scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ use tracing::debug;
use crate::config::schema::Config;
use crate::engine::ReviewIssue;
use crate::engine::finalize::{self, HistoryEntry, ShapeOptions};
use crate::engine::judge::{HttpScanJudge, ScanBatch, ScanJudge, admit};
use crate::engine::judge::{
DETERMINISTIC_ONLY, HttpScanJudge, JudgeChoice, ScanBatch, ScanJudge, admit,
};
use crate::engine::postprocess::{Source, dropped_warning, postprocess_report};
use crate::engine::scanner::{FileEntry, batch_files, format_batch_for_prompt, walk_project};
use crate::engine::types::TokenUsage;
Expand Down Expand Up @@ -51,7 +53,7 @@ pub struct ScanOptions {
#[allow(clippy::too_many_lines)]
pub async fn execute_scan(
config: &Config,
llm_config: &crate::engine::LLMConfig,
judge: &JudgeChoice,
opts: &ScanOptions,
format: OutputFormat,
) -> Result<i32> {
Expand Down Expand Up @@ -166,20 +168,27 @@ pub async fn execute_scan(
None
};

// 5. Judge the batches and collect issues
let judge = HttpScanJudge { llm_config };
// 5. Judge the batches and collect issues (no LLM: deterministic only)
let BatchRun {
issues: mut all_issues,
tokens: total_tokens,
} = judge_batches(
&judge,
config,
&batches,
&effective_focus,
brain_ctx.as_deref(),
opts.continue_on_batch_error,
)
.await?;
} = match judge {
JudgeChoice::Llm(llm_config) => {
judge_batches(
&HttpScanJudge { llm_config },
config,
&batches,
&effective_focus,
brain_ctx.as_deref(),
opts.continue_on_batch_error,
)
.await?
}
JudgeChoice::Deterministic => BatchRun {
issues: Vec::new(),
tokens: None,
},
};

// 5. Merge deterministic + index findings with the LLM findings, then
// apply ignore.rules and inline cora-ignore markers. Deterministic
Expand All @@ -203,6 +212,13 @@ pub async fn execute_scan(
);
let should_block = shaped.should_block;

// A clean deterministic result is not an AI scan: say so in the summary.
let no_judge_note = if matches!(judge, JudgeChoice::Deterministic) {
format!(" {DETERMINISTIC_ONLY}")
} else {
String::new()
};

let response = crate::engine::ScanResponse {
issues: all_issues,
files_scanned: files.len(),
Expand All @@ -219,7 +235,7 @@ pub async fn execute_scan(
} else {
String::new()
}
),
) + &no_judge_note,
tokens_used: total_tokens,
should_block,
};
Expand Down Expand Up @@ -522,7 +538,7 @@ mod tests {
batch_files: 0,
continue_on_batch_error: true,
};
let code = execute_scan(&config, &llm, &opts, OutputFormat::Json)
let code = execute_scan(&config, &JudgeChoice::Llm(llm), &opts, OutputFormat::Json)
.await
.unwrap();
assert_eq!(code, 2, "deterministic finding must survive LLM failure");
Expand Down Expand Up @@ -625,4 +641,31 @@ mod tests {
.unwrap();
assert!(run.issues.is_empty());
}

#[tokio::test]
async fn deterministic_scan_needs_no_llm_and_still_blocks() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("app.py"), "password = \"hunter2hunter2\"\n").unwrap();
let mut config = Config::default();
config.hook.mode = "block".to_string();
let opts = ScanOptions {
path: Some(dir.path().to_string_lossy().to_string()),
include: vec![],
exclude: vec![],
extensions: vec![],
incremental: false,
focus: vec![],
batch_files: 0,
continue_on_batch_error: true,
};
let code = execute_scan(
&config,
&JudgeChoice::Deterministic,
&opts,
OutputFormat::Json,
)
.await
.unwrap();
assert_eq!(code, 2);
}
}
93 changes: 90 additions & 3 deletions src/engine/judge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,59 @@ impl Judge for HttpJudge<'_> {
}
}

/// Who judges a `cora review` / `cora scan` run, chosen once at the entry point.
#[derive(Debug, Clone)]
pub enum JudgeChoice {
/// An OpenAI-compatible LLM.
Llm(LLMConfig),
/// No LLM: deterministic checks only.
Deterministic,
}

/// One-line stderr notice when a missing API key turned a run deterministic.
pub const NO_LLM_NOTICE: &str = "ℹ No LLM configured: running deterministic checks only. \
Run `cora auth login` (or set an API key) to enable AI review; `--no-llm` silences this notice.";

/// Pick the judge from `--no-llm` and the result of building the LLM config.
///
/// A missing API key (`NoApiKey`) is not an error: the run is deterministic.
/// Any other config error is real and is returned. The bool says whether the
/// choice was a fallback (key missing, `--no-llm` not given), so the caller can
/// print [`NO_LLM_NOTICE`].
pub fn choose(
no_llm: bool,
built: Result<LLMConfig, CoraError>,
) -> Result<(JudgeChoice, bool), CoraError> {
if no_llm {
return Ok((JudgeChoice::Deterministic, false));
}
match built {
Ok(llm) => Ok((JudgeChoice::Llm(llm), false)),
Err(CoraError::NoApiKey) => Ok((JudgeChoice::Deterministic, true)),
Err(e) => Err(e),
}
}

/// Choose the judge for a command from the loaded config. Prints the notice on
/// fallback unless `quiet`.
pub fn select_judge(
config: &Config,
cli_api_key: Option<&str>,
no_llm: bool,
quiet: bool,
) -> Result<JudgeChoice, CoraError> {
let built = if no_llm {
Err(CoraError::NoApiKey) // not consulted
} else {
crate::config::loader::build_llm_config(config, cli_api_key)
};
let (judge, fell_back) = choose(no_llm, built)?;
if fell_back && !quiet {
eprintln!("{NO_LLM_NOTICE}");
}
Ok(judge)
}

/// What a judge returned for one scan batch.
#[derive(Debug, Default)]
pub struct ScanBatch {
Expand Down Expand Up @@ -184,16 +237,18 @@ impl ScanJudge for HttpScanJudge<'_> {
}
}

/// Summary text of a run that had no LLM judge. Formatters key off it to say,
/// in every output format, that a clean result is not an AI review.
pub const DETERMINISTIC_ONLY: &str = "Deterministic checks only (no LLM judge).";

/// Judge that finds nothing: the review is the deterministic findings only.
// Selected by `cora review --no-llm` / a missing API key (#646).
#[allow(dead_code)]
pub struct NoJudge;

impl Judge for NoJudge {
async fn judge(&self, _config: &Config, _packet: &Packet) -> Result<ReviewResponse, CoraError> {
Ok(ReviewResponse {
issues: Vec::new(),
summary: "Deterministic checks only (no LLM judge).".to_string(),
summary: DETERMINISTIC_ONLY.to_string(),
tokens_used: None,
should_block: false,
dropped_findings: 0,
Expand Down Expand Up @@ -457,4 +512,36 @@ mod tests {
assert!(out[0].rule_id.is_none());
assert!(out[0].body.contains("ignore previous instructions"));
}

// ─── #646: choosing the judge ───

fn llm() -> LLMConfig {
LLMConfig::default()
}

#[test]
fn key_present_means_llm() {
let (j, fell_back) = choose(false, Ok(llm())).unwrap();
assert!(matches!(j, JudgeChoice::Llm(_)));
assert!(!fell_back);
}

#[test]
fn missing_key_falls_back_to_deterministic_with_notice() {
let (j, fell_back) = choose(false, Err(CoraError::NoApiKey)).unwrap();
assert!(matches!(j, JudgeChoice::Deterministic));
assert!(fell_back, "caller must print the notice");
}

#[test]
fn no_llm_flag_wins_even_with_a_key_and_is_silent() {
let (j, fell_back) = choose(true, Ok(llm())).unwrap();
assert!(matches!(j, JudgeChoice::Deterministic));
assert!(!fell_back);
}

#[test]
fn other_config_errors_are_not_swallowed() {
assert!(choose(false, Err(CoraError::LlmParse("bad".into()))).is_err());
}
}
Loading
Loading