From be1c2e4767a8e6661cd826396115e82efd85290d Mon Sep 17 00:00:00 2001 From: yoni Date: Fri, 25 Sep 2026 07:15:11 +0000 Subject: [PATCH] Withhold automatic fixes when repairs exceed the recorded draft Repair can change files beyond the candidate's draft edits while the manifest still verifies. Comparing the applied draft hashes against the final manifest now demotes the result to ready_with_gaps, so SARIF and other auto-apply consumers never offer a fix that omits verified changes. --- strix/fix/prepare.py | 26 ++++++++++++++++++++++++++ tests/test_fix_preparation.py | 24 ++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/strix/fix/prepare.py b/strix/fix/prepare.py index 9f80df0d2..0beacb9ef 100644 --- a/strix/fix/prepare.py +++ b/strix/fix/prepare.py @@ -306,6 +306,29 @@ async def build_git_manifest( return entries, summary.decode(errors="replace").strip(), None +def _applied_hashes(workspace: Path, candidate: FixCandidateV1) -> dict[Path, str]: + """Content hashes of each edited file, captured right after the draft is applied.""" + applied: dict[Path, str] = {} + for edit in candidate.draft_edits: + path = (workspace / edit.file).resolve() + applied[path] = hashlib.sha256(path.read_bytes()).hexdigest() + return applied + + +def _change_extends_draft( + workspace: Path, + applied_sha256: dict[Path, str], + manifest: list[FileManifestEntry], +) -> bool: + """Whether the verified change set differs from the applied draft edits.""" + final_changes = { + (workspace / entry.path).resolve(): entry.resulting_sha256 for entry in manifest + } + return set(final_changes) != set(applied_sha256) or any( + resulting != applied_sha256[resolved] for resolved, resulting in final_changes.items() + ) + + async def _verify_source(context: PreparationContext) -> bool: identity = context.candidate.source_identity if identity is None: @@ -429,6 +452,7 @@ async def prepare_fix( ) context.candidate = anchored _apply_edits(workspace, context.candidate) + applied_sha256 = _applied_hashes(workspace, context.candidate) checks: list[CheckResult] = [] reproduction: CheckResult | None = None @@ -474,6 +498,8 @@ async def prepare_fix( for result in checks if not result.required and result.status is not CheckStatus.PASSED ) + if _change_extends_draft(workspace, applied_sha256, manifest): + gaps.append("The verified change extends beyond the recorded draft edits.") if reproduction is None and not verifier.reproduction_executed: gaps.append("No executable security reproduction was available.") elif reproduction is not None and reproduction.status is CheckStatus.UNAVAILABLE: diff --git a/tests/test_fix_preparation.py b/tests/test_fix_preparation.py index 1d10d4855..4eb222cb8 100644 --- a/tests/test_fix_preparation.py +++ b/tests/test_fix_preparation.py @@ -244,6 +244,30 @@ async def test_prepare_fix_returns_ready_with_manifest(tmp_path: Path) -> None: assert (workspace / "app.py").read_text(encoding="utf-8").endswith("return 'safe'\n") +@pytest.mark.asyncio +async def test_prepare_fix_demotes_ready_when_repair_exceeds_draft( + tmp_path: Path, +) -> None: + workspace, commit = _workspace(tmp_path) + + async def widening_repair( + _context: PreparationContext, + _checks: list[CheckResult], + ) -> None: + (workspace / "hardening.py").write_text("HELPER = True\n", encoding="utf-8") + + result = await prepare_fix( + _request(_candidate(commit)), + workspace, + repair=widening_repair, + verify=_verified, + ) + + assert result.state is PreparationState.READY_WITH_GAPS + assert any("beyond the recorded draft edits" in gap for gap in result.gaps) + assert result.candidate.digest() == result.candidate_digest + + @pytest.mark.asyncio async def test_prepare_fix_retries_failed_checks(tmp_path: Path) -> None: workspace, commit = _workspace(tmp_path)