Skip to content

fix(triage): gate the remediation planner on the triage verdict (ADR-0051 §6)#276

Merged
galanko merged 3 commits into
mainfrom
feat/triage-gated-fix-pipeline
Jun 15, 2026
Merged

fix(triage): gate the remediation planner on the triage verdict (ADR-0051 §6)#276
galanko merged 3 commits into
mainfrom
feat/triage-gated-fix-pipeline

Conversation

@galanko

@galanko galanko commented Jun 14, 2026

Copy link
Copy Markdown
Collaborator

User description

What & why

cliffsec fix and POST /pipeline/run-all drove the remediation planner directly, skipping triage. The exposure analyzer is a no-tools agent that stops at "Unknown", so reachability never resolved — and noise findings got heavy "fix today + WAF + egress-deny" plans. That's the false-positive-shaped output the product exists to kill.

ADR-0051 §6 already mandates the Plan gate"an issue cannot enter the plan without a recorded real verdict; triage is a gate, not a suggestion." The agentic triage Deep dive (ADR-0052/0054) that actually reads the repo, traces sinks, and rules out dev/test/non-ship code — backed by a 44-case eval with zero false-clears on a strong tier — was wired only to POST /findings/{id}/triage (web UI), never to the CLI fix path. So the capability existed; the fix path just bypassed the gate.

This PR wires the existing, eval-validated triage into the fix flow and enforces the gate.

Changes

  • cliffsec fix triages first (runs the Deep dive), then branches on the verdict:

    • real → remediation plan, exit 2 (awaiting approval) — unchanged contract
    • unexploitable / false_positivecleared as noise with the reasoning on record, no plan, exit 0
    • needs_review → flagged for human judgment, exit 2

    Triage creates a non-status-advancing workspace (ADR-0051 §6), so the finding stays new until a real verdict is human-confirmed.

  • /pipeline/run-all gains a server-side Plan gate: a finding triaged non-real (without a human-approved plan overriding it) returns gated and runs no planner — so no caller (web/API/CLI) can emit an alarmist plan on noise. Back-compat: a finding never triaged behaves exactly as before.

  • secure-repo skill docs updated for the new cleared / needs_review / real outcomes.

Tests

  • New CLI tests: cleared-noise (exit 0), needs_review (exit 2), real→plan (exit 2), 404 tolerance, timeout.
  • New route tests: gate blocks the planner on a non-real verdict; a real verdict proceeds.
  • Full suite: backend 1663 passed, CLI 98 passed, lint clean. (1 unrelated pre-existing live-LLM eval flake, untouched by this diff.)

Live verification — karakeep ground-truth set (gpt-5 Deep dive)

Cardinal test flips 5/5 FAIL → 5/5 PASS:

finding ground truth old verdict new verdict
shell-quote noise "Unknown" + alarmist plan unexploitable, no plan — "no occurrences in pnpm-lock.yaml or imports"
happy-dom RCE noise "Conditional" + punt unexploitable, no plan — "no references to happy-dom/ECMAScriptModuleCompiler"
drizzle-orm noise "Unknown" + punt unexploitable, no plan — "runtime pins ^0.45.2; never calls sql.identifier()"
minimatch noise "Conditional" + punt unexploitable, no plan — "only glob is hard-coded /**/**/asset.bin, not user input"
nodemailer addr-parser DoS real (missed) not surfaced needs_review, reachable — traced users.ts:236 → email.ts:190 sendMail; adversarial panel downgraded real→needs_review (SMTP-config-gated); kept, no premature plan

Noise cleared with file-grounded reasoning; the real one kept and surfaced. Zero false positives.

🤖 Generated with Claude Code


Generated description

Below is a concise technical summary of the changes proposed in this PR:
Introduce an ADR-0051 §6 Plan gate for fix by triaging via POST /findings/{id}/triage, inspecting the resulting sidebar verdict, and emitting noise-cleared or human-review signals before invoking the planner. Reinforce the gate in run_all_pipeline by checking sidebar.triage verdict/status, skipping planning when a finding is still new with a non-real verdict, and extend docs/tests for the cleared/needs_review/real outcomes.

TopicDetails
Run-all plan gate Enforce the Plan gate inside run_all_pipeline by inspecting sidebar.triage verdict/status, blocking planner execution for non-real verdicts unless a human confirmation or approved plan exists, and cover the gate with dedicated backend route tests.
Modified files (2)
  • backend/cliff/api/routes/agent_execution.py
  • backend/tests/test_routes_agent_execution.py
Latest Contributors(0)
UserCommitDate
CLI fix triage gate Gate cliffsec fix on the Deep dive triage verdict before planning, emit cleared/noise or awaiting-human-review payloads, and document the cleared/needs_review/real outcomes while covering the CLI and doc flows with the new tests.
Modified files (4)
  • cli/cliff_cli/cli.py
  • cli/tests/test_cli.py
  • plugins/cliff-security/skills/cliff-security/SKILL.md
  • plugins/cliff-security/skills/cliff-security/knowledge/secure-repo.md
Latest Contributors(0)
UserCommitDate
Review this PR on Baz | Customize your next review

…0051 §6)

`cliffsec fix` and `/pipeline/run-all` drove the remediation planner directly,
skipping triage entirely — so reachability never resolved (the exposure
analyzer is a no-tools agent that stops at "Unknown") and noise findings got
heavy "fix today + WAF + egress-deny" plans. ADR-0051 §6 already mandates the
Plan gate ("an issue cannot enter the plan without a recorded `real` verdict");
the fix path just wasn't enforcing it. The agentic triage Deep dive (ADR-0052/
0054) that actually reads the repo, traces sinks, and rules out dev/test/
non-ship code was wired only to `POST /findings/{id}/triage` (web UI), never to
the CLI fix path.

This wires the existing, eval-validated triage into the fix flow and gates the
planner on its verdict:

- `cliffsec fix` now triages first (runs the Deep dive), then branches:
  - real            -> remediation plan, exit 2 (awaiting approval) [unchanged]
  - unexploitable /
    false_positive  -> cleared as noise WITH reasoning, no plan, exit 0
  - needs_review    -> flagged for human judgment, exit 2
  Triage creates a non-status-advancing workspace (ADR-0051 §6), so the finding
  stays `new` until a real verdict is human-confirmed.
- `/pipeline/run-all` gains a server-side Plan gate: if a finding was triaged
  non-real (and no human-approved plan overrides it), it returns `gated` and
  runs no planner — so no caller (web/API/CLI) can emit an alarmist plan on
  noise. Back-compat: a finding that was never triaged behaves as before.
- secure-repo skill docs document the new cleared/needs_review/real outcomes.

Verified live against the karakeep ground-truth set (5/5, gpt-5 Deep dive):
shell-quote/happy-dom/drizzle/minimatch -> unexploitable (file-grounded, no
plan); nodemailer address-parser DoS -> needs_review, reachable with a traced
exploit path (kept, not dismissed). Cardinal test flips 5/5 FAIL -> 5/5 PASS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 14, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Introduces a triage verdict gate across three layers: the backend POST /pipeline/run-all endpoint now returns status="gated" when the workspace triage verdict is not "real" and no approved plan exists; the CLI fix command is rewritten to poll triage verdict first and branch on unexploitable/false_positive/needs_review/real; and plugin documentation is updated to match the new branching behavior.

Changes

Triage-Gated Fix Flow

Layer / File(s) Summary
Backend run_all_pipeline Plan Gate
backend/cliff/api/routes/agent_execution.py, backend/tests/test_routes_agent_execution.py
Adds a preflight check in run_all_pipeline that loads the workspace sidebar, extracts triage verdict, and returns RunAllResponse(status="gated") when verdict is not "real" and no approved plan exists. Backend tests patched with get_sidebar=None for existing test scenarios; three new tests added for gated, empty-verdict, and real-verdict paths.
CLI fix Command Triage-First Rewrite
cli/cliff_cli/cli.py, cli/tests/test_cli.py
Adds _triage_reason() helper and rewrites fix to call triage endpoint, poll sidebar for verdict, and branch accordingly: emits "cleared" for unexploitable/false_positive (exit 0), emits awaiting=human_review for non-real verdicts (exit 2), and only calls pipeline/run-all when verdict is real. Validation-only and plan-approval paths now include explicit verdict: "real" field. Tests replaced with triage-aware scenarios covering all verdict branches, 404-tolerance on lazy sidebar seeding, and polling-timeout JSON error output.
Plugin docs: exit-code contract and fix loop
plugins/cliff-security/skills/cliff-security/SKILL.md, plugins/cliff-security/skills/cliff-security/knowledge/secure-repo.md
Exit-code 0 table row now explicitly notes that fix clearing a finding as noise (cleared: true) is exit 0. Fix-loop instructions expanded to describe the three triage verdict branches and clarify that only real findings proceed to planning.

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant fix_command as cliffsec fix
  participant TriageEndpoint as Triage API
  participant SidebarPoller as Sidebar Poll
  participant RunAllEndpoint as POST /pipeline/run-all

  User->>fix_command: cliffsec fix <finding>
  fix_command->>TriageEndpoint: create/reuse workspace → workspace_id
  loop poll until verdict set
    fix_command->>SidebarPoller: GET workspace sidebar
    SidebarPoller-->>fix_command: triage verdict
  end

  alt verdict == "unexploitable" or "false_positive"
    fix_command-->>User: cleared=true + reason, exit 0
  else verdict != "real"
    fix_command-->>User: awaiting=human_review + reason, exit 2
  else verdict == "real"
    fix_command->>RunAllEndpoint: POST pipeline/run-all
    RunAllEndpoint-->>fix_command: plan steps
    fix_command-->>User: verdict=real + plan, awaiting=plan_approval, exit 2
  end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • cliff-security/cliff#210: Both PRs modify the backend/cliff/api/routes/agent_execution.py run-all pipeline flow—this PR adds a "plan gate" before starting background execution, while the retrieved PR changes the same run-all executor loop to fail fast on failed/rate_limited results.

Poem

🐇 A bunny once guarded the plan-making gate,
No fix shall proceed 'til the verdict is straight!
If real, hop along — let the pipeline ignite,
If unexploitable, cleared with delight.
For needs_review, we pause and we wait,
Only truth gets to plan — that's the triage mandate! 🔒

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 77.78% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title directly summarizes the main change: gating the remediation planner on the triage verdict. It is specific, concise, and accurately reflects the core objective demonstrated across all modified files (backend API, CLI, and tests).
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/triage-gated-fix-pipeline

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with 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.

Inline comments:
In `@backend/tests/test_routes_agent_execution.py`:
- Around line 318-342: The test currently only asserts that executor.execute was
not called, but this does not catch a potential regression where a background
task could be scheduled via asyncio.create_task before execute() is invoked. Add
asyncio.create_task to the patch context manager alongside the existing patches
for get_workspace and get_sidebar, mock it appropriately, and add an assertion
after the request to verify that asyncio.create_task was never called (or check
its call count to ensure no background task was created for the gated branch).

In `@cli/cliff_cli/cli.py`:
- Around line 336-346: The is_done lambda function in the poll call is only
checking for the existence of a triage object but not verifying that the verdict
field is populated within it. Update the is_done lambda to check both that the
triage object exists AND that the verdict field within the triage object is
non-empty. This will ensure polling continues until the verdict is actually
ready, preventing premature completion that could cause incorrect routing.

In `@cli/tests/test_cli.py`:
- Line 483: The JSON string containing the "reason" field at line 483 exceeds
the configured maximum line length, causing the ruff E501 check to fail. Break
this long line into multiple lines by splitting the string across multiple lines
while preserving the JSON structure, such as by moving the string value to a new
line or using implicit string concatenation. Ensure the resulting code is valid
JSON and adheres to the project's max line length configuration.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 6cc3ddaa-f266-4ba4-b9f7-154104f1895f

📥 Commits

Reviewing files that changed from the base of the PR and between 0574997 and 2be0d4e.

📒 Files selected for processing (6)
  • backend/cliff/api/routes/agent_execution.py
  • backend/tests/test_routes_agent_execution.py
  • cli/cliff_cli/cli.py
  • cli/tests/test_cli.py
  • plugins/cliff-security/skills/cliff-security/SKILL.md
  • plugins/cliff-security/skills/cliff-security/knowledge/secure-repo.md

Comment thread backend/tests/test_routes_agent_execution.py
Comment thread cli/cliff_cli/cli.py
Comment thread cli/tests/test_cli.py Outdated
Comment on lines +337 to +348
# The Plan gate (ADR-0051 §6 / ADR-0054): a finding triaged as *not real*
# never gets a remediation plan — clearing noise with reasoning is the
# product's value, and an alarmist plan on a non-reachable finding is a
# false-positive-shaped result. The CLI (`cliffsec fix`) gates on the
# verdict before it ever calls run-all; this is the server-side backstop so
# NO caller (web, API) can drive the planner on noise. Defence-in-depth, so
# it is intentionally narrow: it engages only when triage has run AND
# returned a non-real verdict AND there is no human-approved plan overriding
# it. A finding that was never triaged behaves exactly as before.
sidebar = await get_sidebar(db, workspace_id)
triage = sidebar.triage if sidebar else None
if triage is not None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The status="gated" path returns 2xx, so the frontend's optimistic in_progress/planning update never rolls back — should we either raise a 4xx or use a started: false flag? Also, the gate triggers on any non-real verdict including empty ones from partial writes like {"triage": {}} — can we require a truthy verdict (or reuse _has_triage_verdict) so only concrete non-real verdicts block the pipeline?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
In backend/cliff/api/routes/agent_execution.py around lines 337-351 inside
`run_all_pipeline`: 1. The "gated" branch returns RunAllResponse(status="gated") with a
2xx, causing the frontend to stay stuck in optimistic planning/in_progress state.
Refactor so a gated/no-plan outcome is unambiguously "not started" — either raise an
HTTPException with a 4xx status or add a `started: false` flag that the frontend
rollback logic keys off. 2. The plan-gating condition fires even when
`triage.get("verdict")` is empty (e.g., partial sidebar write `{"triage": {}}`). Require
a truthy/valid verdict before gating — reuse `_has_triage_verdict` or add an explicit
guard — so partial/empty verdicts fall through to normal pipeline execution while
still respecting the no-human-approved-plan-override condition.

Heads up!

Your free trial ends tomorrow.
To keep getting your PRs reviewed by Baz, update your team's subscription

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Commit 7f639b8 addressed this comment by requiring a concrete, truthy triage verdict before entering the gated path, so partial writes like {"triage": {}} now fall through to normal execution. It did not change the 2xx status="gated" response, but it did fix one of the specific gating bugs raised.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Empty-verdict point: fixed in 7f639b8 — the gate now triggers only on a concrete (truthy) non-real verdict; a partial {"triage": {}}/no-verdict write falls through to the normal pipeline. Added a regression test (test_run_all_not_gated_on_empty_triage_verdict).

On the 2xx/frontend-rollback point — I don't think it applies here: useRunAllPipeline (frontend/src/api/hooks.ts) does no optimistic state update; onSuccess only invalidateQueries (agent-runs, sidebar, findings), so the UI re-fetches server truth and reflects the gated outcome — there's no optimistic state to leave stuck. Its only caller is the post-failure Retry button (IssueSidePanel.tsx), which fires on a workspace that already has a real verdict + plan, so the gate passes there. 202 is also accurate: the request was validly handled and gated is a handled outcome, not a client error (a 4xx would be incorrect). Left as-is.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Thanks for the clarification — that makes sense. I'll save this to memory once the PR is merged.

Comment thread cli/cliff_cli/cli.py
Comment on lines +289 to +300
def _triage_reason(triage: dict[str, Any]) -> str | None:
"""One-line reasoning behind a triage verdict (mirrors the chat-card line).

Prefers the exploitability reason, then the reachability summary, then the
first proof-check detail — whichever the synthesizer/Deep dive populated.
"""
exploit = triage.get("exploitability") or {}
if exploit.get("reason"):
return exploit["reason"]
reach = triage.get("reachability") or {}
if reach.get("summary"):
return reach["summary"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

_triage_reason duplicates triage_runner._verdict_summary’s reason selection, should we surface a single triage.reason from the response instead of re-implementing the fallback order in the CLI?

Severity

Want Baz to fix this for you? Activate Fixer

Heads up!

Your free trial ends tomorrow.
To keep getting your PRs reviewed by Baz, update your team's subscription

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

_triage_reason lives in the cliffsec CLI — a separate package that talks to the daemon over HTTP, so it can't import the backend's _verdict_summary. True dedup would mean adding a computed reason field to the TriageOutput schema server-side and having the CLI read it; that's a schema change beyond this PR's scope. The CLI extracts from the same canonical fields (exploitability.reasonreachability.summary → first check detail). Leaving as a reasonable follow-up rather than expanding scope here.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Thanks, that makes sense — the package boundary and schema-change scope are good constraints to keep in mind here. I’ll save this to memory once the PR is merged.

galanko and others added 2 commits June 15, 2026 12:03
- Fix Ruff E501 in test_cli.py (CI lint-and-test was red).
- run-all Plan gate now triggers only on a CONCRETE non-real verdict; a
  partial/empty triage write ({} / no verdict) falls through to the normal
  pipeline instead of blocking it (Baz). Adds a regression test.
- `cliffsec fix` polls until triage.verdict is populated, not just until a
  triage object exists, so an in-flight write can't route to the non-real
  branch early (CodeRabbit).
- Harden the gated route test: assert the gated branch schedules no
  background pipeline (asyncio.create_task), not just that execute() didn't run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ssion

Two real bugs surfaced by a pre-merge multi-agent code review:

1. (high) The server-side Plan gate broke the web "Looks real — remediate"
   flow. That action confirms a non-real finding by advancing its status to
   `triaged` (ADR-0051 §6) and THEN fires run-all — but the gate keyed only on
   `sidebar.triage.verdict` (still non-real), so it returned `gated`, scheduled
   no pipeline, and the optimistic UI hung on "Thinking" with no plan and no
   error. The gate now bypasses on human confirmation: it blocks only an
   unconfirmed finding (status `new`) with a non-real verdict and no approved
   plan. The CLI `real` path passes the verdict check and never reaches here;
   confirmRealAndRemediate runs onStart in the status-mutation's onSuccess, so
   `triaged` is persisted before run-all reads it (no race). New test.

2. (moderate) Re-running `fix` on an already-shipped finding wrongly reported
   `awaiting=plan_approval` (exit 2): the prior `if validation and not
   plan.get("plan_steps")` fell through to the plan-gate emit when both a plan
   and a validation result were present. Reverted to `if validation:` —
   validation present means resolved, not awaiting approval. Regression test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@galanko

galanko commented Jun 15, 2026

Copy link
Copy Markdown
Collaborator Author

Pre-merge self-review (multi-agent /code-review)

Ran a multi-angle review over the diff before merge. Two real bugs found and fixed in d331d38:

  1. (high) Server Plan gate broke the web "Looks real — remediate" flow. That action confirms a non-real finding by advancing its status to triaged (ADR-0051 §6) and then fires run-all, but the gate keyed only on sidebar.triage.verdict (still non-real) → returned gated, scheduled no pipeline, and the optimistic UI hung on "Thinking" with no plan/error. The gate now bypasses on human confirmation (blocks only an unconfirmed new finding with a non-real verdict and no approved plan). Verified race-free: confirmRealAndRemediate runs onStart in the status mutation's onSuccess, so triaged persists before run-all reads it. Added test_run_all_not_gated_when_human_confirmed_real.

  2. (moderate) Re-running fix on an already-shipped finding wrongly reported awaiting=plan_approval (exit 2). The if validation and not plan.get("plan_steps") guard fell through to the plan gate when both a plan and a validation result were present; reverted to if validation:. Added a regression test.

Deferred (noted, not blocking): the triage poll has no is_failed predicate (a backend triage failure spins to --timeout rather than erroring promptly — pre-existing poll pattern); _capturing_create_task test scaffolding is duplicated across the run-all tests (cleanup).

All CI green; route tests 36, CLI 28.

Comment on lines +347 to +352
triage = sidebar.triage if sidebar and isinstance(sidebar.triage, dict) else None
# Gate only on a CONCRETE non-real verdict. A missing/partial triage write
# ({} or no ``verdict``) leaves ``verdict`` empty and falls through to the
# normal pipeline — only a real dismissal/needs_review verdict blocks the
# planner, so an in-flight or never-triaged finding behaves as before.
verdict = (triage.get("verdict") or "").lower() if triage else ""

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

triage.get("verdict").lower() is called unconditionally, but sidebar.triage can be arbitrary JSON from PUT /workspaces/{workspace_id}/sidebar, so a non-string or missing verdict raises AttributeError and 500s POST /pipeline/run-all — should we guard with isinstance(verdict, str) and fall back to '' when not?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
In `backend/cliff/api/routes/agent_execution.py` around lines 347-352 inside
`run_all_pipeline`, add a type guard before calling `.lower()` on
`triage.get("verdict")`. If the value is missing or not a `str`, treat it as unapproved
(e.g., normalize to `''` or `None`) so malformed or legacy sidebar JSON can't raise
`AttributeError`. Ensure the endpoint continues down the normal gated response path
instead of failing the request.

Heads up!

Your free trial ends tomorrow.
To keep getting your PRs reviewed by Baz, update your team's subscription

Comment on lines +362 to +365
finding = await get_finding(db, workspace.finding_id)
human_confirmed = finding is not None and finding.status != "new"
plan_approved = bool((sidebar.plan or {}).get("approved")) if sidebar else False
if not human_confirmed and not plan_approved:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

human_confirmed treats any status other than "new" as confirmed-real, so mark_started_on_workspace_create() + run_triage_endpoint() can leave a non-real triage eligible for /pipeline/run-all; should we narrow this to finding.status == "triaged" plus the approved-plan override instead of status != "new"?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
backend/cliff/api/routes/agent_execution.py around lines 318-367 inside
`run_all_pipeline`, the gate computes `human_confirmed = finding is not None and
finding.status != "new"`, which incorrectly treats any non-new Finding.status as a
confirmed-real verdict and can bypass the auto-planning/noise block. Refactor this logic
so the bypass only happens when the finding is actually in the confirmed-real state
(e.g., `finding.status == "triaged"`) and still allow the approved-plan override to
bypass as intended. Ensure `finding` is fetched before the gate, and update the log
message/variable naming to reflect the narrower condition so non-real triage updates
can’t slip through.

Heads up!

Your free trial ends tomorrow.
To keep getting your PRs reviewed by Baz, update your team's subscription

@galanko galanko merged commit dfb0cbd into main Jun 15, 2026
17 checks passed
@galanko galanko deleted the feat/triage-gated-fix-pipeline branch June 15, 2026 10:41
galanko added a commit that referenced this pull request Jun 15, 2026
…eal` + cut 0.2.2 (#277)

* fix(triage): fail safe on code/SAST findings — never a speculative `real` (ADR-0051/0052)

The Quick-read synthesizer shipped fake CRITICAL `real` verdicts on SAST/code
findings. Root cause (Mealie round-3 audit): the exposure analyzer reasons about
a code finding from the file PATH, not the file's code, and hedges ("Likely
reachable … needs verification to confirm") — but `_classify_reachable` matched
the affirmative keywords ("likely"/"reachable") → "yes", and with
`internet_facing=true` the dependency-shaped projection emitted `real` 0.82. The
Deep dive that would open the file:line never ran (no repo profile/clone), so the
speculation shipped as the final verdict.

Four fail-safes, all upholding ADR-0051 §10 (never false-clear, never
false-confirm):

- Hedge-aware reachability: "likely / suggests / appears / needs verification"
  classify as undetermined, never a confident reachable. `likely` is a
  probability, not a confirmation — removed from the affirmative set.
- Code/SAST findings defer: `synthesize_triage` gains `finding_type`; a `code`
  finding can't be confidently cleared OR confirmed from the Quick read (it
  never opens the file), so it returns `needs_review`, which auto-escalates to
  the file-reading Deep dive. Dependency/posture keep the existing projection.
- Triage never strands: a failed prerequisite (e.g. the exposure agent timing
  out on a deploy-time migration file) degrades to `needs_review` instead of
  aborting with no verdict — the prior behavior left the CLI to poll-timeout and
  exit 1. Triage always lands a verdict; never a silent clear, never a crash.
- Deep-dive grounded-path guard: a `trace_path` `reached=yes` with no file:line
  hop is ungrounded speculation → `needs_review`, not a confident `real`
  (symmetric to the disproof challenge on the clearing side).

Verified: all 5 Mealie noise findings now triage to `needs_review` (zero `real`),
both deterministically against the recorded exposure inputs and live end-to-end
through the daemon. The two prior `real` false positives and the migration
crash are gone.

Also cuts the 0.2.2 release (VERSION + backend + cliffsec CLI + CHANGELOG) so the
triage-first `cliffsec fix` flow (#276) reaches installed users.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(triage): address review — hedged-negative false-clear, secret deferral, prereq robustness

Code-review pass (CodeRabbit + a multi-angle finder sweep) surfaced four more
defects in the same false-verdict class; all fixed with tests:

- **Hedged negatives were confidently cleared.** `_classify_reachable` checked
  NEGATIVE before HEDGE, so "cannot confirm it is not reachable" / "appears
  unreachable but unverified" matched "not reachable" → `unexploitable` 0.88 —
  a false-clear of a possibly-real finding. Hedging/uncertainty is now checked
  FIRST and dominates any negative/affirmative keyword; a clean "no path found"
  still clears. (Combined `_REACH_UNCERTAIN`+`_REACH_HEDGE` into one pass.)

- **`secret` findings fell through to the dependency projection.** A leaked
  secret has no CVE → the enricher abstains → the projection cleared it as
  `false_positive`, false-clearing a real secret. The deferral now covers
  `code` AND `secret` (no-advisory-model types); `dependency`/`posture` keep
  the projection. `_code_finding_deferral` → `_deferred_quick_verdict`, type-aware.

- **Deep-dive grounded-path guard required `file` only.** Now requires a real
  `file:line` (int line > 0), matching the trace prompt's "no hop without a
  file:line you actually read" — a `{file, line: null}` hop no longer counts.

- **Prereq-failure degrade hardened.** Degrade on ANY non-`completed` status
  (not just failed/rate_limited — e.g. awaiting_permission), and synthesize the
  degraded verdict from THIS run's inputs only (drop a prior attempt's stale
  enricher/exposure output) so a degraded verdict can't be a confident
  projection from stale data.

Full agents suite: 249 passed. ADR-0051 amendment + CHANGELOG updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant