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

## [Unreleased]

## [0.2.2] - 2026-06-15

Triage hardening for SAST/code findings — the Plan-gate bright line ("a
report must never carry a false positive") now holds for code findings the
way it already did for dependencies. Plus the `cliffsec` CLI release that
carries the triage-first `fix` flow to users.

### Fixed

- **Code/SAST triage no longer ships fake `real` verdicts.** The Quick-read
synthesizer projected a confident `real` for code findings whenever the
exposure analyzer's free-text reachability *looked* affirmative — including
pure speculation like "likely reachable … needs verification to confirm"
(the analyzer reasons from the file path, never opening the flagged line).
On a SAST-heavy repo that shipped fake CRITICAL SQL-injection findings. Two
fail-safes now guard the verdict: (1) reachability classification is
hedge-aware — "likely / suggests / appears / needs verification" is treated
as *undetermined*, never a confident reachable; (2) a `code` (and `secret`)
finding can't be confidently cleared OR confirmed from the Quick read at all
— it defers to `needs_review`, which auto-escalates to the file-reading Deep
dive (ADR-0052), where the `file:line` is actually opened before any verdict.
- **Hedged negatives are no longer confidently cleared.** Reachability
classification now checks hedging *before* the negative keywords, so
"cannot confirm it is not reachable" / "appears unreachable but unverified"
route to `needs_review` instead of a confident `unexploitable` — closing the
same false-clear on the clearing side. A clean "no path found" still clears.
- **`secret` findings defer like code.** A leaked-secret finding has no CVE
(the enricher always abstains), so the dependency projection would clear it
as `false_positive` — false-clearing a real secret. Secrets now defer to
`needs_review`. (`posture` keeps the projection — it's a deterministic Cliff
check, real by definition.)
- **Triage never strands on a failed prerequisite.** When the enricher or
exposure agent failed (e.g. a 150s timeout on a deploy-time migration file),
triage aborted with no verdict — leaving the CLI to time out and exit 1. It
now degrades to a `needs_review` verdict landed in the sidebar and chat
card. Triage always produces a verdict; never a silent clear, never a crash.
The degrade triggers on *any* non-`completed` prerequisite status (not just
`failed`/`rate_limited`), and the degraded verdict is computed without stale
output from a prior triage attempt in the same workspace.
- **Deep dive won't confirm an ungrounded path.** A `trace_path` result that
claims `reached=yes` but cites no `file:line` hop is now routed to
`needs_review` instead of proceeding to a confident `real` — the symmetric
guard to the disproof challenge on the clearing side.
- **OpenAI BYOK save now works.** The validator probe was hardcoded to
`gpt-5`, which is in OpenAI's reasoning-model family and rejects
`max_tokens` with a 400. The non-classified-4xx catch-all rendered
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.2.1
0.2.2
145 changes: 140 additions & 5 deletions backend/cliff/agents/runtime/triage_synthesizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,27 @@
``NEEDS_REVIEW_CONFIDENCE`` threshold or exploitability is ``unknown``
(ADR-0051 §9). The threshold is a tunable constant, not a magic number frozen
in the branches.

Two fail-safes guard against shipping a confident verdict the quick read hasn't
earned (the Plan-gate bright line — a report must never carry a false positive):

1. **Speculative reachability never confirms.** The exposure analyzer's
free-text ``reachable`` is classified hedge-aware: phrasing like "likely …
needs verification to confirm" is *speculation*, not a confirmed reachable,
so it maps to ``unknown`` (→ needs_review), never a confident ``real``.

2. **No-advisory-model findings defer.** ``code`` and ``secret`` findings have
no CVE/advisory (the enricher always abstains), and for code the exposure
analyzer reasons from the file PATH, never the file's code — so the quick
read can't responsibly clear OR confirm them. They defer to ``needs_review``,
which auto-escalates to the file-reading Deep dive (ADR-0052), where the
flagged ``file:line`` is opened before any verdict. The dependency-shaped
projection below (abstention → false_positive, reachable+facing → real)
assumes a CVE/advisory model: applying it to code shipped fake CRITICAL
SQL-injection ``real`` verdicts, and to a secret it would false-clear a real
leaked credential as ``false_positive``. ``dependency`` (CVE model) and
``posture`` (deterministic Cliff check, real by definition) keep the
projection.
"""

from __future__ import annotations
Expand All @@ -34,6 +55,12 @@
#: Tuned against the eval harness — see backend/tests/agents/eval.
NEEDS_REVIEW_CONFIDENCE = 0.70

#: Scanner finding types the quick read must NOT confidently judge — first-party
#: findings with no CVE/advisory model, where the dependency projection's
#: abstention + reachability semantics don't hold. They defer to the Deep dive.
#: (`dependency` keeps the projection; `posture` is a deterministic Cliff check.)
_DEFER_TYPES = frozenset({"code", "secret"})

# Confidence anchors per verdict (all confident verdicts sit at/above the
# threshold; every needs_review sits below it — see the §9 invariant test).
_CONF_REAL = 0.82
Expand Down Expand Up @@ -64,29 +91,71 @@
"indeterminate",
"uncertain",
)
#: Hedge / speculation markers. The exposure analyzer flagging a sink "likely
#: reachable … needs verification to confirm" has NOT confirmed reachability —
#: it has guessed. Treat such phrasing as ``unknown`` (→ needs_review), never a
#: confident reachable. ``likely`` lives here, NOT in affirmatives: "likely
#: reachable" is a probability, not a confirmation, and promoting it to a
#: confident ``real`` is exactly the false positive this guards against.
_REACH_HEDGE = (
"likely",
"probabl", # probable / probably
"suggest", # suggests / suggesting
"appears",
"seems",
"may ", # "may be reachable" — trailing space avoids matching e.g. "mayhem"
"might",
"could",
"would ", # "would need to trace"
"potential",
"presum", # presumably
"assume",
"needs verification",
"need to verify",
"needs confirmation",
"to confirm",
"not confirmed",
"unverified",
"needs analysis",
"need to trace",
"needs further",
"requires verification",
"cannot confirm",
"unable to confirm",
)
_REACH_AFFIRMATIVE = (
"reachable",
"yes",
"true",
"confirmed",
"likely",
"direct",
"exploitable",
)
#: Anything uncertain OR hedged is undetermined. One set, one membership pass —
#: the two source tuples stay separate only for their distinct doc-comments.
_REACH_UNKNOWN = _REACH_UNCERTAIN + _REACH_HEDGE


def _classify_reachable(reachable: str | None) -> str:
"""Map the analyzer's free-text ``reachable`` to ``yes`` / ``no`` /
``unknown``. An empty value means the analyzer ran but couldn't determine
reachability → ``unknown`` (the *missing exposure* case is handled by the
caller before this is reached)."""
caller before this is reached).

Uncertainty/hedging is checked FIRST — it dominates any negative or
affirmative keyword the same text also contains. So a hedged NEGATIVE
("cannot confirm it is not reachable", "appears unreachable but unverified")
is NOT confidently cleared as a no-path, and a hedged AFFIRMATIVE ("likely
reachable … needs verification") is NOT confirmed as reachable. Only a clean,
un-hedged statement reaches the negative/affirmative checks (the Plan-gate
bright line: speculation never becomes a confident verdict)."""
if not reachable:
return "unknown"
s = reachable.strip().lower()
if any(k in s for k in _REACH_UNKNOWN):
return "unknown"
if any(k in s for k in _REACH_NEGATIVE) or s in ("no", "false", "none"):
return "no"
if any(k in s for k in _REACH_UNCERTAIN):
return "unknown"
if any(k in s for k in _REACH_AFFIRMATIVE):
return "yes"
Comment on lines +155 to 160

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Hedge-safe logic can still be bypassed by affirmative substring collisions.

The affirmative branch does raw substring matching, so uncertain text like unconfirmed reachable can match confirmed and be classified as yes. That can reintroduce confident real projections from non-confirmed reachability language.

Suggested hardening patch
+import re
 from typing import Any
@@
 _REACH_NEGATIVE = (
@@
     "no reachable path",
+    "non-exploitable",
+    "non exploitable",
 )
@@
 _REACH_AFFIRMATIVE = (
@@
     "exploitable",
 )
+
+_REACH_AFFIRMATIVE_RE = re.compile(
+    r"\b(?:reachable|yes|true|confirmed|direct|exploitable)\b"
+)
@@
-    if any(k in s for k in _REACH_AFFIRMATIVE):
+    if _REACH_AFFIRMATIVE_RE.search(s):
         return "yes"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/cliff/agents/runtime/triage_synthesizer.py` around lines 155 - 160,
The affirmative branch using substring matching on _REACH_AFFIRMATIVE can
incorrectly classify hedged text like "unconfirmed reachable" as "yes" because
it matches "confirmed" as a substring. To fix this, ensure that uncertain or
negated variations of affirmative keywords are caught by the unknown check
before reaching the affirmative check. Either add hedging modifiers like
"unconfirmed" to the _REACH_UNKNOWN set so they are caught earlier in the
evaluation, or modify the affirmative substring matching to use word boundary
checks instead of raw substring matching to avoid matching affirmative keywords
that are part of negated compound words.

return "unknown"
Expand All @@ -103,8 +172,15 @@ def _enricher_abstained(enrichment: dict[str, Any] | None) -> bool:
def synthesize_triage(
enrichment: dict[str, Any] | None,
exposure: dict[str, Any] | None,
finding_type: str = "dependency",
) -> TriageOutput:
"""Project a triage verdict from recorded enricher + exposure output."""
"""Project a triage verdict from recorded enricher + exposure output.

``finding_type`` is the scanner finding's class (``dependency`` / ``code`` /
``secret`` / ``posture``). ``code`` and ``secret`` findings defer to the Deep
dive — see ``_deferred_quick_verdict`` — because they have no advisory model
the projection can rest on; ``dependency`` and ``posture`` use the
dependency-shaped projection below."""
known_exploits = bool((enrichment or {}).get("known_exploits"))

# Missing exposure entirely → never a confident clear (ADR-0051 §10).
Expand All @@ -126,6 +202,16 @@ def synthesize_triage(
],
)

# First-party findings with no advisory model (`code`, `secret`) can't be
# confidently cleared OR confirmed by the dependency-shaped projection below:
# the enricher always abstains (no CVE), and the quick read reasons from the
# file path, not the file's code. Defer to needs_review (→ auto-escalates to
# the file-reading Deep dive / a human). Never a confident verdict from
# speculation. `dependency` (CVE model) and `posture` (deterministic Cliff
# check) keep the projection.
if finding_type in _DEFER_TYPES:
return _deferred_quick_verdict(finding_type, exposure)

reachable_raw = exposure.get("reachable")
reached = _classify_reachable(reachable_raw if isinstance(reachable_raw, str) else None)
internet_facing = exposure.get("internet_facing")
Expand Down Expand Up @@ -200,6 +286,55 @@ def synthesize_triage(
)


def _deferred_quick_verdict(finding_type: str, exposure: dict[str, Any]) -> TriageOutput:
"""Verdict for a finding the quick read can't responsibly judge (``code`` /
``secret``): defer to the Deep dive.

These are first-party findings with no advisory model, so the
dependency-shaped projection (abstention → false_positive, reachable+facing
→ real) doesn't fit — applying it false-clears real secrets and fake-confirms
speculative code reachability. Return ``needs_review``, which auto-escalates
to the file-reading Deep dive (ADR-0052 / ``decide_escalation``); when no Deep
dive can run, the honest "needs your review" stands rather than a fabricated
verdict. The analyzer's hunch is preserved as context, never promoted.
"""
if finding_type == "secret":
reason = (
"A flagged secret needs review to confirm it's a true exposure — not a "
"placeholder, test fixture, or already-rotated value. The quick read "
"can't clear it."
)
check_result = "Needs your review"
detail_default = "A potential secret was flagged — escalated for review."
else: # code / SAST
reason = (
"Confirming whether untrusted input reaches this code requires reading "
"the flagged line — deferred to deep analysis (the quick read reasons "
"from the file path, not the code)."
)
check_result = "Needs a code read"
detail_default = (
"The flagged line wasn't opened — escalated for a file-level trace "
"before any verdict."
)
evidence = exposure.get("reachability_evidence")
reachable_raw = exposure.get("reachable")
hint = evidence or (reachable_raw if isinstance(reachable_raw, str) else None)
return TriageOutput(
verdict="needs_review",
confidence=_CONF_NEEDS_REVIEW,
exploitability={"exploitable": "unknown", "reason": reason},
checks=[
{
"eyebrow": "REACHABILITY",
"result": check_result,
"kind": "warn",
"detail": hint or detail_default,
}
],
)


def _build_reachability(reached: str, evidence: str | None) -> dict[str, Any] | None:
if reached == "no":
return {"reached": False, "path": [], "summary": evidence or "No path found."}
Expand Down
38 changes: 38 additions & 0 deletions backend/cliff/agents/triage_deep/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,20 @@ def _map_reach(reach: dict) -> TriageReachability:
return TriageReachability(reached=reach.get("reached") == "yes", path=nodes)


def _has_grounded_path(reach: dict) -> bool:
"""Whether a ``reached=yes`` trace is GROUNDED — cites at least one hop with a
real ``file:line`` (the trace prompt's "no hop … without a file:line you
actually read"). An empty path, or hops with no concrete ``file:line``, is a
speculative "yes", not confirmed reachability, and must not project to a
confident ``real``."""
for hop in reach.get("path") or []:
file = hop.get("file")
line = hop.get("line")
if isinstance(file, str) and file.strip() and isinstance(line, int) and line > 0:
return True
return False

Comment thread
coderabbitai[bot] marked this conversation as resolved.

def _kill_corroborated(ro: dict, facts: dict, repo_knowledge: dict) -> bool:
"""Whether a rule_out kill is backed by a structural signal (ADR-0052).

Expand Down Expand Up @@ -366,6 +380,30 @@ def prov(exit_stage: str) -> TriageProvenance:
provenance=prov("trace_path"),
)

# A "yes" with no grounded path (no file:line hop) is a SPECULATIVE
# reachable, not a confirmed one — the trace prompt's "no hop, and no
# guard, without a file:line you actually read". Honor that in code:
# route to needs_review instead of proceeding to a confident `real`, so
# an ungrounded "yes" can't false-flag (the symmetric guard to the
# disproof challenge on the clearing side). Plan/challenge would have
# nothing concrete to work from anyway.
if not _has_grounded_path(reach):
return TriageOutput(
verdict="needs_review",
confidence=_CONF_UNKNOWN,
reachability=_map_reach(reach),
checks=[
TriageCheck(
eyebrow="Reachability",
result="Claimed reachable without a traced path",
kind="warn",
detail="No file:line hop was recorded — routed to review "
"rather than confirmed as reachable.",
)
],
provenance=prov("trace_path"),
)

# 4. Plan the exploit (strong) — reachable-but-no-exploit = hardening.
plan = await self._stages.plan(
_deps(clone_dir, finding, {**base, "facts": facts, "reachability": reach}),
Expand Down
Loading
Loading