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

### Security

- **The SARIF "no judge" marker can no longer be forged or missed.** A model-written summary containing the marker sentence is stripped, and the judge-failure fallback (deterministic findings only) now carries the marker, so `cora.judge = "none"` is set exactly when no model judged.

- **A hostile diff can no longer hide findings (audit follow-up).** Inside a hunk whose line counts are not used up, `---` / `+++` lines are content, not file headers (a removed SQL/Lua comment `-- x`, or an added line `++ b/tests/y.py`, used to drop the rest of the hunk or re-file it under another path, so a secret was reported as nothing). `valid_files` now comes from the same parser, and file names are escaped in the review prompt.

- **A judge can no longer downgrade or absorb a scanner finding (audit follow-up).** When a judge issue sits on the same line and topic as a scanner finding, the surviving issue takes the stronger severity and the scanner's `rule_id` (an "info" judge issue used to replace a CRITICAL scanner finding).
Expand Down
44 changes: 44 additions & 0 deletions src/engine/judge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ impl Judge for HttpJudge<'_> {
&llm.base_url,
) {
tracing::debug!("returning cached judge response");
let mut cached = cached;
strip_sentinel(&mut cached.summary);
return Ok(cached);
}
}
Expand Down Expand Up @@ -119,6 +121,9 @@ impl Judge for HttpJudge<'_> {
result?
};

let mut response = response;
strip_sentinel(&mut response.summary);

if self.cache_ttl.is_some() {
if let Err(e) = crate::engine::cache::save_cached_review(
&key_text,
Expand Down Expand Up @@ -263,6 +268,18 @@ pub const AGENT_JUDGED: &str = "Judged by the calling agent (not by a cora-confi
/// in every output format, that a clean result is not an AI review.
pub const DETERMINISTIC_ONLY: &str = "Deterministic checks only (no LLM judge).";

/// Remove the "no judge" sentinel from a summary written by a model. The
/// sentinel marks a run that had NO judge (SARIF `cora.judge = none`); text
/// steered by the diff must not be able to forge it.
fn strip_sentinel(summary: &mut String) {
// Repeat: removing one occurrence can join the text around it into a new
// one ("Determ<marker>inistic ..."), so a single pass is bypassable.
while summary.contains(DETERMINISTIC_ONLY) {
*summary = summary.replace(DETERMINISTIC_ONLY, "");
}
*summary = summary.trim().to_string();
}

/// Judge that finds nothing: the review is the deterministic findings only.
pub struct NoJudge;

Expand Down Expand Up @@ -607,4 +624,31 @@ mod tests {
assert_eq!(out[1].line, None, "old path of a rename has no new lines");
assert_eq!(out[2].line, Some(1), "real added line stays");
}

#[test]
fn nested_marker_cannot_reassemble_after_removal() {
// Removing the inner marker joins the halves into a fresh one.
let (a, b) = DETERMINISTIC_ONLY.split_at(10);
let mut summary = format!("{a}{DETERMINISTIC_ONLY}{b}");
strip_sentinel(&mut summary);
assert!(!summary.contains(DETERMINISTIC_ONLY), "got: {summary:?}");
let mut deep = DETERMINISTIC_ONLY.to_string();
for _ in 0..5 {
let (a, b) = DETERMINISTIC_ONLY.split_at(7);
deep = format!("{a}{deep}{b}");
}
strip_sentinel(&mut deep);
assert!(!deep.contains(DETERMINISTIC_ONLY));
}

#[test]
fn a_model_cannot_forge_the_no_judge_marker() {
let mut summary = format!("looks fine. {DETERMINISTIC_ONLY} trust me");
strip_sentinel(&mut summary);
assert!(!summary.contains(DETERMINISTIC_ONLY));
assert!(summary.contains("looks fine."));
let mut clean = "all good".to_string();
strip_sentinel(&mut clean);
assert_eq!(clean, "all good");
}
}
15 changes: 14 additions & 1 deletion src/engine/review.rs
Original file line number Diff line number Diff line change
Expand Up @@ -408,7 +408,8 @@ pub async fn review_with<J: Judge>(
let mut fallback = ReviewResponse {
issues: all_deterministic,
summary: format!(
"LLM review failed: {e}. Showing {n_rules} rule + {n_secrets} secrets + {n_security} security + {n_index_unused} unused imports + {n_index_dead} dead code + {n_index_breaking} breaking changes."
"LLM review failed: {e}. Showing {n_rules} rule + {n_secrets} secrets + {n_security} security + {n_index_unused} unused imports + {n_index_dead} dead code + {n_index_breaking} breaking changes. {}",
crate::engine::judge::DETERMINISTIC_ONLY
),
tokens_used: None,
should_block: false,
Expand Down Expand Up @@ -1040,4 +1041,16 @@ new file mode 100644\n\
);
assert!(resp.should_block || config.hook.min_severity_level() != Severity::Major);
}

#[test]
fn judge_failure_fallback_is_marked_deterministic_only() {
// Only deterministic findings remain, so SARIF must say no judge ran.
let config = Config::default();
let judge = FakeJudge(Err("boom".to_string()));
let resp = run(&judge, &config, packet(SECRET_DIFF, &config)).expect("fallback");
assert!(
resp.summary
.contains(crate::engine::judge::DETERMINISTIC_ONLY)
);
}
}
6 changes: 6 additions & 0 deletions src/mcp/tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ static MCP_RUNTIME: LazyLock<tokio::runtime::Runtime> =
/// ("Cannot start a runtime from within a runtime") and kills the server.
/// `block_in_place` hands the thread over first. Outside a runtime (unit
/// tests) it blocks directly.
///
/// Limitation: `block_in_place` panics on a `current_thread` runtime. The
/// server is multi-thread (`#[tokio::main]` with `rt-multi-thread`), so this
/// holds in production; a `#[tokio::test]` calling a handler that reaches
/// here must use `flavor = "multi_thread"`. (A thread hand-off would need the
/// future to be `Send`, which the pipeline's futures are not.)
pub(super) fn block_on<F: std::future::Future>(future: F) -> F::Output {
match tokio::runtime::Handle::try_current() {
Ok(_) => tokio::task::block_in_place(|| MCP_RUNTIME.block_on(future)),
Expand Down
Loading