Skip to content

feat(resume): opt-in grounded retraction of fixed findings#773

Open
seanturner83 wants to merge 2 commits into
usestrix:mainfrom
seanturner83:feat/resume-retract-vulnerability
Open

feat(resume): opt-in grounded retraction of fixed findings#773
seanturner83 wants to merge 2 commits into
usestrix:mainfrom
seanturner83:feat/resume-retract-vulnerability

Conversation

@seanturner83

Copy link
Copy Markdown
Contributor

Problem

On --resume, prior findings are rehydrated so the cumulative report/SARIF carries forward across runs. That set is append-only today: if a later resumed run sees a finding has been fixed, there's no way to drop it. The resolved finding re-emits into every subsequent SARIF forever — a scanner alert (e.g. GHAS) that can never auto-resolve (fail-closed).

This matters when resume is driven as a PR-lifecycle loop (rescan on each push): the cumulative finding set needs to shrink as fixes land, not just grow.

What this adds (opt-in, default off)

  • ReportState.retract_vulnerability_report(id, reason) — the inverse of add_vulnerability_report: drops the report, deletes its stale per-finding MD, and lets save_run_data() rewrite CSV/JSON/SARIF from the reduced set. Idempotent.

  • retract_vulnerability_report tool — exposed on the root agent only on a resumed run and only when STRIX_RESUME_RETRACT is set. Default OFF: with the flag unset, resume behaves exactly as today (append-only) — this PR is a no-op for existing users until they opt in.

  • Groundedness guard — before dropping, the tool re-verifies against the live /workspace tree (via an injected sandbox reader) that the vulnerable sink is actually gone. If a finding's fix_before sink is still present verbatim → REFUSE; if no reader is wired → REFUSE (fail-safe). A confident-but-wrong agent cannot silently drop a real finding.

Prerequisite bugfix: id stability under removal (ungated)

add_vulnerability_report allocated ids as vuln-{len(reports)+1}. That's only correct while the set is append-only — once a report can be removed, length-based ids recycle: retract vuln-0002 from [0001,0002,0003] and the next add computes len+1 = 3vuln-0003, colliding with a live id (overwriting its MD) and letting a retracted id later re-emit under a different finding (SARIF fingerprint collision).

Fixed by allocating from the max ordinal ever seen (including retracted ids retained in _saved_vuln_ids), so ids stay monotonic and stable under removal. This is a no-op for append-only runs (max-seen == len there), so it doesn't change existing behaviour — it just makes retraction safe.

Why this shape

The retraction capability lives in strix (report state + a resume-gated tool), not in the SDK — it's specific to how strix manages a cumulative, resumable finding set. Kept fully behind an env flag so it lands dormant and opt-in.

Tests

tests/test_retract_vulnerability.py (9 tests): retract removal / idempotency / reason-required / MD-deletion, id stability under removal, and the guard's refuse-without-reader / refuse-when-sink-present / allow-when-gone / allow-when-nothing-verifiable paths. Existing report + state tests unchanged (46 pass). ruff clean.

On --resume, prior findings are rehydrated so the cumulative report/SARIF
carries forward across runs. That set is append-only today: if a later run
sees a finding has been FIXED, there's no way to drop it, so a resolved
finding re-emits into every subsequent SARIF forever — a scanner alert that
can never auto-resolve (fail-CLOSED). This adds the inverse of report
creation, behind an explicit opt-in.

- `ReportState.retract_vulnerability_report(id, reason)` — inverse of
  add_vulnerability_report: drops the report, deletes its stale MD, and lets
  save_run_data() rewrite CSV/JSON/SARIF from the reduced set. Idempotent.

- `retract_vulnerability_report` tool, exposed on the root agent ONLY on a
  resumed run AND only when `STRIX_RESUME_RETRACT` is set. Default OFF —
  resume stays append-only (unchanged behaviour) unless opted in.

- Groundedness guard: before dropping, re-verify against the live workspace
  tree (via an injected sandbox reader) that the vulnerable sink is actually
  gone. If a fix_before sink is still present verbatim → REFUSE; if no reader
  is wired → REFUSE (fail-safe). A confident-but-wrong agent can't silently
  drop a real finding.

- id stability: allocate the next `vuln-NNNN` from the max ordinal ever seen
  (incl. retracted ids retained in _saved_vuln_ids), not the list length.
  Length-based ids recycle once a report can be removed — a later finding
  would reuse a retracted id and collide with its MD / SARIF fingerprint.
  No-op for append-only runs (max-seen == len there).

Tests: retract removal/idempotency/reason-required/MD-deletion, id stability
under removal, and the guard's refuse/allow paths. Existing report + state
tests unchanged (46 pass).
@greptile-apps

greptile-apps Bot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds opt-in retraction of fixed findings during resumed scans. The main changes are:

  • A resume-only root-agent tool for retracting findings.
  • Source-tree checks before a finding is removed.
  • Report-state removal and artifact rewriting.
  • Max-based vulnerability ID allocation.
  • Tests for basic retraction and guard behavior.

Confidence Score: 4/5

The retraction path can remove live findings, and report IDs can be reused after a later resume.

  • Missing verification data currently permits deletion.
  • Failed or misdirected reads are treated as confirmed file absence.
  • Reader state is shared across scan lifecycles.
  • The ID high-water mark is not persisted.

strix/tools/reporting/retract_tool.py, strix/core/runner.py, strix/report/state.py

Security Review

The retraction guard can remove live security findings without valid source verification when fix_before is absent, workspace reads fail, or shared reader state points at another scan.

Important Files Changed

Filename Overview
strix/tools/reporting/retract_tool.py Adds the retraction tool and guard, but missing sink data and process-global reader state can produce incorrect retractions.
strix/core/runner.py Injects the sandbox reader, but failed or misdirected reads are reported as confirmed file absence.
strix/report/state.py Adds report removal and max-based allocation, but the ID high-water mark does not survive rehydration.
strix/agents/factory.py Adds default-off, resume-only registration of the root retraction tool.
strix/config/settings.py Adds the default-off environment setting for resume retraction.
tests/test_retract_vulnerability.py Covers basic retraction but omits cross-process ID reuse, failed reads, and shared-reader cases.
Prompt To Fix All With AI
Fix the following 4 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 4
strix/tools/reporting/retract_tool.py:78-79
**Missing Sink Bypasses Verification**

`fix_before` is optional in existing reports, but this branch allows retraction when no location contains it. A resumed finding without `fix_before` can therefore be removed without reading the current source, even when the vulnerability is still present.

### Issue 2 of 4
strix/core/runner.py:262-275
**Read Failures Become Missing Files**

The reader returns `None` after every candidate path fails, and the guard treats `None` as proof that the sink is gone. A changed or omitted `workspace_subdir`, a permission failure, or another nonzero `cat` result can therefore approve retraction while the vulnerable file still exists elsewhere in the workspace.

### Issue 3 of 4
strix/tools/reporting/retract_tool.py:46-52
**Reader State Crosses Scan Boundaries**

This single module-level reader is shared by every scan in the process and is never reset. Concurrent resumed scans can overwrite it and verify a finding against another scan's sandbox, while a later scan can retain a reader bound to an old session, producing an incorrect retraction decision.

### Issue 4 of 4
strix/report/state.py:224-229
**Retracted Maximum ID Is Reused**

The “ever seen” IDs exist only in memory and hydration rebuilds `_saved_vuln_ids` from live reports. If `vuln-0003` is the highest ID and is retracted, the next process loads only `0001` and `0002`, so this allocator issues `vuln-0003` again and breaks the promised stable report and SARIF identity.

Reviews (1): Last reviewed commit: "feat(resume): opt-in grounded retraction..." | Re-trigger Greptile

Comment thread strix/tools/reporting/retract_tool.py Outdated
Comment on lines +78 to +79
if not sinks:
return True, "no fix-site (fix_before) locations to verify — allowing"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 security Missing Sink Bypasses Verification

fix_before is optional in existing reports, but this branch allows retraction when no location contains it. A resumed finding without fix_before can therefore be removed without reading the current source, even when the vulnerability is still present.

Prompt To Fix With AI
This is a comment left during a code review.
Path: strix/tools/reporting/retract_tool.py
Line: 78-79

Comment:
**Missing Sink Bypasses Verification**

`fix_before` is optional in existing reports, but this branch allows retraction when no location contains it. A resumed finding without `fix_before` can therefore be removed without reading the current source, even when the vulnerability is still present.

How can I resolve this? If you propose a fix, please make it concise.

Comment thread strix/core/runner.py Outdated
Comment on lines +262 to +275
except Exception as exc: # noqa: BLE001 — unreadable ≠ proof of absence
logger.debug("retract reader: %s/%s unreadable: %r", base, rel, exc)
continue
if getattr(result, "exit_code", 1) == 0:
# ExecResult.stdout is bytes; the guard does a str
# substring check, so decode here (a raw bytes return
# would raise TypeError on every retract).
stdout = result.stdout
return (
stdout.decode("utf-8", errors="replace")
if isinstance(stdout, bytes | bytearray)
else stdout
)
return None # not found under any workspace root → treat as gone

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 security Read Failures Become Missing Files

The reader returns None after every candidate path fails, and the guard treats None as proof that the sink is gone. A changed or omitted workspace_subdir, a permission failure, or another nonzero cat result can therefore approve retraction while the vulnerable file still exists elsewhere in the workspace.

Prompt To Fix With AI
This is a comment left during a code review.
Path: strix/core/runner.py
Line: 262-275

Comment:
**Read Failures Become Missing Files**

The reader returns `None` after every candidate path fails, and the guard treats `None` as proof that the sink is gone. A changed or omitted `workspace_subdir`, a permission failure, or another nonzero `cat` result can therefore approve retraction while the vulnerable file still exists elsewhere in the workspace.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +46 to +52
_reader: list[ReadTargetFileFn | None] = [None]


def set_target_file_reader(reader: ReadTargetFileFn | None) -> None:
"""Wire the sandbox-backed target-file reader (called at agent build time on
resume). Passing None disables grounded retraction (fail-safe refuse)."""
_reader[0] = reader

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 security Reader State Crosses Scan Boundaries

This single module-level reader is shared by every scan in the process and is never reset. Concurrent resumed scans can overwrite it and verify a finding against another scan's sandbox, while a later scan can retain a reader bound to an old session, producing an incorrect retraction decision.

Prompt To Fix With AI
This is a comment left during a code review.
Path: strix/tools/reporting/retract_tool.py
Line: 46-52

Comment:
**Reader State Crosses Scan Boundaries**

This single module-level reader is shared by every scan in the process and is never reset. Concurrent resumed scans can overwrite it and verify a finding against another scan's sandbox, while a later scan can retain a reader bound to an old session, producing an incorrect retraction decision.

How can I resolve this? If you propose a fix, please make it concise.

Comment thread strix/report/state.py Outdated
Comment on lines +224 to +229
max_ordinal = 0
for rid in (
*(r.get("id") for r in self.vulnerability_reports),
*self._saved_vuln_ids,
):
if isinstance(rid, str) and rid.startswith("vuln-"):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Retracted Maximum ID Is Reused

The “ever seen” IDs exist only in memory and hydration rebuilds _saved_vuln_ids from live reports. If vuln-0003 is the highest ID and is retracted, the next process loads only 0001 and 0002, so this allocator issues vuln-0003 again and breaks the promised stable report and SARIF identity.

Prompt To Fix With AI
This is a comment left during a code review.
Path: strix/report/state.py
Line: 224-229

Comment:
**Retracted Maximum ID Is Reused**

The “ever seen” IDs exist only in memory and hydration rebuilds `_saved_vuln_ids` from live reports. If `vuln-0003` is the highest ID and is retracted, the next process loads only `0001` and `0002`, so this allocator issues `vuln-0003` again and breaks the promised stable report and SARIF identity.

How can I resolve this? If you propose a fix, please make it concise.

… docs

Address Greptile review on the retract PR:

- Trust model (the guard never vetoes): Strix trusts the LLM to find a vuln,
  so it trusts the LLM's judgement that it's fixed. The retract now ALWAYS
  proceeds on the agent's cited reason; the guard is an audit signal, not a
  gate. It returns a 3-way verdict — gone / present / inconclusive — recorded
  on the result as `grounded` + `guard_verdict`. This fixes the previous
  fail-OPEN paths (no fix_before sink, failed read → silently allowed) AND the
  opposite fail-CLOSED risk (stubbornly refusing a genuine fix on a moved file
  or a finding with no structured sink). A wrong drop is self-correcting:
  findings are re-derived every scan, so it re-appears next run.

- id high-water mark now PERSISTS (run_record["max_vuln_ordinal"], restored by
  hydrate_from_run_dir). Without it a retracted top id was reused after a
  resume in a fresh process — _saved_vuln_ids is rebuilt only from live reports
  on rehydration. (Greptile issue 4.)

- reader hygiene: the sandbox reader now returns None ONLY on positively-
  confirmed absence ("No such file") and RAISES on any other failed/ambiguous
  read, so "couldn't read" is scored inconclusive, never mistaken for "gone"
  (issue 2). Lifted to a module-level `_make_workspace_file_reader` (testable,
  and no longer a 50-line closure in the scan body). The reader is reset to
  None in the run's finally block so a torn-down session can't leak into a
  later scan's grounding (issue 3).

- docs: STRIX_RESUME_RETRACT in configuration.mdx; a "Resuming a scan" +
  "Retracting fixed findings" section in usage/cli.mdx documenting the opt-in
  and the trust model.

Tests updated to the 3-way verdict + tool-always-retracts behaviour; 60 tests
pass (14 retract + 46 existing), ruff clean.
@seanturner83

Copy link
Copy Markdown
Contributor Author

Thanks @greptile-apps — this was a genuinely useful pass. Addressed in 986c974:

Issue 4 (retracted max ID reused after resume) — real bug, fixed. You're right: _saved_vuln_ids is rebuilt only from live reports on rehydration, so a retracted top id was lost and reused in a fresh process. The high-water mark now persists in run_record["max_vuln_ordinal"] (restored by hydrate_from_run_dir), verified across a process boundary.

Issue 3 (reader crosses scan boundaries) — fixed. The reader is reset to None in the run's finally block, and lifted out of the scan body into a module-level _make_workspace_file_reader (also makes it testable).

Issues 1 & 2 (missing sink / failed read → retraction allowed) — reworked, but by design rather than as a hard block. These flagged a real tension, and the resolution is a deliberate trust-model choice worth stating explicitly:

Strix trusts the LLM to find a vulnerability, so it trusts the LLM's judgement that one is fixed. The guard therefore never vetoes the agent — a hard refuse would swing into the opposite failure: stubbornly keeping a genuinely-fixed finding the guard just can't re-locate (moved/renamed file, a finding with no structured fix_before, a transient read failure), leaving an alert that can never resolve. Instead the guard returns a 3-way verdict recorded on the result:

  • gonegrounded=true (fix independently corroborated),
  • presentgrounded=false + loud warning (contradicts the claim, but the agent may have legitimately re-reasoned — line now unreachable/guarded — so it's flagged, not blocked),
  • inconclusivegrounded=false (no sink / unreadable — honoured on the agent's word).

The reader change from Issue 2 still matters for honesty here: it now returns None only on confirmed absence and raises on a failed/ambiguous read, so "couldn't read" is scored inconclusive, never mistaken for gone. And a mistaken retraction is self-correcting — findings are re-derived on every scan, so anything wrongly dropped re-appears next run. The whole feature is opt-in (STRIX_RESUME_RETRACT, default off).

Documented the behaviour + trust model in docs/usage/cli.mdx (Resuming a scan → Retracting fixed findings) and docs/advanced/configuration.mdx.

60 tests pass (14 retract + 46 existing), ruff clean.

@greptile-apps

greptile-apps Bot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

The rework in 986c974 is well-executed. All four original issues are resolved, and the trust-model rationale is clearly documented at both the code and docs level. A few observations on the final shape:

Trust model (Issues 1 & 2) — the framing is correct. The 3-way verdict with GUARD_PRESENT logging a warning but not blocking is the right call. The old hard-refuse was a false safety — a static fix_before substring match doesn't capture "line is now unreachable/behind a guard" and would have caused permanent false-stuck alerts exactly as described. The reader correctly raising on ambiguous reads (vs. returning None) is the key fix that makes inconclusive honest.

One minor audit-log gap worth considering: GUARD_PRESENT and GUARD_INCONCLUSIVE both write grounded=False to the result, so downstream tooling (e.g. log aggregation, SARIF annotation) can't distinguish "guard actively contradicted the claim" from "couldn't verify". If you ever want that signal for audit trails or alerting, a third value like grounded="contradicted" would make that split explicit without changing any behavior. Not a blocker — just a future niceness.

ID high-water persistence (Issue 4) — correct. run_record["max_vuln_ordinal"] surviving through run.jsonhydrate_from_run_dir_next_vuln_ordinal properly closes the process-boundary gap. The max(persisted, live reports, saved_ids) formulation is also resilient to a stale or missing record.

Reader hygiene (Issue 3) — clean. Lifting _make_workspace_file_reader to module level and clearing in the finally block prevents both the cross-scan contamination and the leaked-session risks. The _reader: list[...] pattern to avoid global is a nice touch.

Overall this is solid — the feature is opt-in, fail-safe by default, self-correcting by design, and now correctly auditable. Happy to approve from a review standpoint.

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